T102 follow-on: the chat rail reads a thread only with a branch session (plan 034) - #85
Conversation
…on (plan 034) Finding F1 of the holder's T096 dry run: on a standalone plane, switching the chat rail's loaded document read /workbench/thread. The plane has no branch session, so every read answered 403 thread_capability_unavailable, and Chromium logged one console error per switch, which AT-R1's oracle counts as undeclared. Holder ruling F1 (i) (fix the product, not the oracle): app.js wires the rail's thread read only when workbenchGate.session exists, the same condition as refusalTransport. A composed host, which has a branch session, keeps reading threads exactly as before. app.js gains doxbenchThreadSeam(session, consoleTokenOf, injectedFetch), and the doxBench bundle's `thread` is composed through it from workbenchGate.session. With a session column it is createDoxBenchThreadLoader, unchanged. With none it is a seam that answers null and sends nothing. It is not an absent seam: the rail reads an absent `thread` as the pre-§11 rail, which keeps the previous document's transcript across a switch, and carrying one document's turns into another's next request is the defect the switch exists to close. The null is the answer the 403 produced, so the rail still adopts the empty transcript, without the request or its console error. tests/test_thread_read_by_session.py (new): - the seam, run under node from app.js's own text: no session column means a function that sends nothing and answers null; a session column means the loader, with the same URL, header and refusal reading; - the switch, in the real shell: on a standalone plane, every switch asks the seam and sends no thread request; on a composed host, every switch reads the thread with its usual query; - the composition pin in app.js. tests/test_workbench_edit_by_scope.py: the shell harness's mount takes an optional `thread` seam, and _stage can write extra modules beside the views. The new module reuses both. The census row for app.js is re-measured. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y name Mutation run on 9959f89: with no seam at all on a plane with no session column (`return null` in doxbenchThreadSeam), the seam harness called the null and crashed, so the module's three seam cases ERRORED rather than failing. The harness now records a seam that is not a function rather than calling it. The switch scenario's counter hands down no seam as none, exactly as the shell would see it, so the standalone switch case fails too: the rail no longer asks. 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 fixes standalone chat-rail document switching by composing a thread-read seam from workbenchGate.session: standalone planes receive a null-returning no-op that clears the transcript without issuing the failing request, while composed hosts continue using the existing thread loader unchanged. New Node-backed seam and real-shell switch tests cover both paths, and the boundary census is re-measured. Sequence diagram for session-aware chat rail thread readssequenceDiagram
participant Rail as ChatRail
participant App as app.js
participant Seam as doxbenchThreadSeam
participant Loader as createDoxBenchThreadLoader
participant Workbench as WorkbenchGate
participant API as ThreadAPI
App->>Workbench: read session
App->>Seam: doxbenchThreadSeam(session, consoleTokenOf, injectedFetch)
alt session exists
Seam->>Loader: return loader
Rail->>Loader: read thread
Loader->>API: GET /workbench/thread
API-->>Loader: thread or 403
Loader-->>Rail: thread or null
else no session
Seam-->>App: return null-returning seam
Rail->>Seam: read thread
Seam-->>Rail: null
end
Flow diagram for standalone chat rail document switchingflowchart TD
A[Switch loaded document] --> B{workbenchGate.session exists?}
B -->|Yes| C[Use createDoxBenchThreadLoader]
C --> D[Request workbench/thread]
D --> E[Adopt thread or null transcript]
B -->|No| F[Use null-returning seam]
F --> E
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
🟢 Approval recommended
The focused implementation preserves composed-host behavior and has comprehensive regression coverage.
Review effort: Balanced
Findings: None
What changed in this PR
Gates chat-rail thread reads on branch-session availability while preserving transcript clearing.
Changes:
- Adds a session-aware thread seam.
- Adds standalone and composed-host coverage.
- Updates test harness support and boundary census.
| File | Description |
|---|---|
src/opendox/web/app.js |
Adds and wires the session-aware thread seam. |
tests/test_thread_read_by_session.py |
Tests seam, switching, and composition behavior. |
tests/test_workbench_edit_by_scope.py |
Supports injected thread modules and seams. |
tests/fixtures/web_boundary_census.yaml |
Updates app.js LOC and totals. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
READY at c7b2635 — Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Holder: the T102 follow-on, carrying holder ruling F1 (i) (#656 5973854291), recorded in plan 034 by openxFactory#1225. On a standalone plane, the chat rail sends no
|
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 2 days and 1 hour by commenting @sourcery-ai review. Upgrade to get a review now.
A plain merge: the branch holds none of #85's commits. One conflict, the web census's class-A total: main's 18486 (18459 + #85's 27 lines in app.js) against this branch's 18499 (18459 + T100's 40 lines in views/doxbench-chat.js). RE-DERIVED from the merged tree's rows, every one of which matches its file: 18526 = 18459 + 27 + 40. EXPECT_SKIPPED is untouched by #85 and stays main's 11. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…squash A plain merge. This branch holds neither of #85's own commits, so there is nothing to use as a merge base other than main's own history. Resolved by hand, the one conflict: - web_boundary_census.yaml, totals A. Taken from neither side: every row's loc was re-measured against the merged tree (none differ), and every class's total was re-derived from the rows. A is 26 files, 18581 lines (this branch 18554, main 18486, base 18459). B, C and "?" are unchanged. No test the merge brings in reads a standalone child's token from /capabilities. test_thread_read_by_session.py drives app.js's seams with an injected caps object. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
#85 makes the chat rail read a thread only with a branch session. It touches app.js, the web boundary census, test_workbench_edit_by_scope.py and the new test_thread_read_by_session.py. T082 edits none of them, and the merge had no conflicts, the census included. T082's shape is unchanged by it: - `SETTINGS_DOCUMENTS` is still declared once; - `import dataclasses` is still restored. Local, LANG=C.UTF-8, no GIT_* or XF_*, no PostgreSQL service: the whole suite ran 3810 passed, 177 skipped, 0 failed. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034's **T082** (`specs/034-opendox-standalone-operation/tasks.md`) realizes #1144's **16.5**: *"Every other surface works with no model. Documents, generation, the views, sessions and saving answer exactly as they do with a model configured."* Its falsifier is `tests/test_chat_model_configuration.py`. Its **After** line is T081, T084 and T085, and all three have landed. **Base: `main`.** This PR is no longer stacked. The predecessors landed as: - #71 (T085) as `2680eb5e`; - #74 (T081) as `9a490405`; - #77 (T084) as `e49b17c3`; - #80 (T103) as `390e2c28`; - #81 (T102) as `0116293a`; - #85 (the T102 follow-on) as `c4b55cc4`. The branch takes `main` with merge commits only (`7332be52`, `c002fac0`, `87a753a8`, `a25606bb`, `9948dc2b`), never by rebasing. It was drafted under Brett Heap's word of 2026-10-02 (`openxFactory#656` comment `5960162524`, *"Draft all of them now (Recommended)"*) and claimed in `#656` comment `5962404984` (P3-N: T082, with T083 as a local check only). The holder posts READY, and the landers merge. ## Rulings - R1Q10 (a), `5850003126`: each consumer mechanism gets openDox's own neutral default. - `5961364221` item 1: standalone, model approval refuses by name, and the intake surface answers `offered: false` with the reason. - `5961651355`: the standalone scope marks a tile's own documents editable. Saving still needs a live session, and sessions are reached only through the host's gate verbs. - The holder's shape for this PR: - send the same standalone request twice, once with no model configured and once with a binding declared through `model-binding add`; - assert that each answer is an answer, never a dropped connection, and that the two are equal; - mark the cases that need T084 `xfail(strict=True)` until it lands. It has landed, and the markers are gone. - **The holder's ruling on this writer's RULING NEEDED, 2026-10-02: option (a).** - openDox's standalone corpus default leaves out openDox's own settings documents. - The list is declared once, beside the path constants, by importing them, not by copying the strings. - A host's own adapter decides for itself, and 16.5's text gets no exception. - The holder asked that the exclusion live outside the single-writer files where possible, and that the tests compare the full corpus. - The holder named two mutants: one that drops the exclusion, and one that widens it to the directory. ## The test: one request, two postures The new section 6 of `tests/test_chat_model_configuration.py` starts two standalone `generate-and-open --local` children over the **same commit**, with sibling imports refused (`tests/standalone_child.py`): - **"no model"**: a fresh fixture repository, no binding, and no `omp` on the PATH. - **"a binding"**: a byte copy of that checkout, `.git` included, after `python -m opendox.cli model-binding add --repo-root <copy> ... -- a-broker` declared a binding (exit 0, nothing refused). That is how a standalone user configures a model. Each request goes to both children: - Each answer must be an HTTP response. - No sibling import may be refused while the request is made (`Child.refused()` is read before and after). - The two answers must be equal in status, content type and body. - A JSON body is compared as data. - Only `/capabilities` has values set aside, because they are per-process by design: its `console_token`, and the values of its `install.database_bundle` (`data_dir`, `socket_dir`, `pid`; plan 034 T073). Each `--local` child runs its own bundled server under its own state directory. The block's shape is still compared: the same keys, and a value on both sides or on neither. - Every other JSON surface is compared whole, so a key that differs between the postures there fails the case (`c6689c55`). | surface | requests | result | |---|---|---| | documents | `GET /snapshot.json`; `GET /source/<doc>`; the keyed `GET /source/fixture@main/<doc>`; the bare `GET /source` (refused alike); `POST /actions/edit` (select-to-edit, with `EDITOR=true`) | equal | | generation | `python -m opendox.cli generate` over each checkout, as a lone openDox, with the same no-`omp` PATH the servers started with. The two outputs must be byte-equal, and equal to what each posture serves | equal | | the views | `/index.html`, `/app.js`, `/styles.css`, `views/display.js`, `wheel.js`, `wheel-model.js`, `doc-wheel.js`, `lens.js`, `lens-model.js`, `/capabilities`; plus `buildWheelModel`, `buildLensModel` and `docSummaries`, run in node over each posture's served snapshot | equal | | sessions | `POST /actions/gate/share-session`, `abandon-session`, `open-pr`: refused alike, and neither checkout's HEAD or status changes | equal | | session reads (T084) | `GET /project-register.json` and `GET /workbench/thread?...` answer through openDox's seams | equal | | session controls (T084) | `/capabilities` reads `actions.gate` and `actions.refresh` false standalone (`5920216845` item 1) | equal | | saving | `POST /actions/gate/first-edit`, `edit-document`, `create-document`: refused alike, and nothing written (`5961651355`) | equal | | model settings (T084) | `GET /workbench/model-intake`: `offered: false`, with T084's `column_seams.GATE_RECORDS_REFUSAL`. `POST /actions/workbench/model-approval`: `approval_refused` with that reason, and nothing written. `POST /actions/workbench/document-abstract`: `model_capability_unavailable` (`5961364221` item 1) | equal | **The five cases that waited for T084.** Until #77 landed, five cases ran as `xfail(strict=True)` naming T084, because the request dropped the connection standalone or the capability still read true: - the two session reads; - the session controls; - model approval; - the document abstract. The merge of `e49b17c3` (`c002fac0`) removed the markers, and all five pass. The same merge: - set aside the `install.database_bundle` values above; - moved the intake reason to `GATE_RECORDS_REFUSAL`; - restored `import dataclasses`, which #77's hand-applied scope stand-in dropped from the file and which section 6 uses. A clean textual merge would have left a NameError. The scope stand-in is `main`'s, unchanged. **What these cases do not assert.** The gate verbs answer `404 unknown_action` in both postures. The sessions and saving cases assert only that each verb is refused alike and writes nothing. They do not assert a particular refusal code: T084 keeps the gate column a host's contribution, and its standalone answer is T084's to decide. ## The fix the cases needed: openDox's settings documents are not the user's documents **Measured at #74's head `9061b22a`.** `opendox model-binding add` writes its bindings document into the checkout, at `ideation/dashboard/model-provider-bindings.yaml`, where its operator can read and commit it. openDox's standalone corpus reads the working tree (`WorkingTreeCorpus`, ruled *"Working tree (Recommended)"*), so that document joined the corpus as a `source` document: - `GET /snapshot.json` had sha `52a09ac1` with no model and `45d01a78` with a binding. - The `generate` verb gave the same two digests. - The wheel and the lens built from the two snapshots differed with them. - Committing the file, as its own docstring intends, would list it all the same. **RULED (a), and placed outside the single-writer files:** - **The declaration.** `doxbench_intake.SETTINGS_DOCUMENTS` declares the two paths, in `doxbench_intake.py` beside `DEFAULT_DECLARATIONS_RELPATH`. It reads `binding_mod.DEFAULT_BINDINGS_RELPATH` and `DEFAULT_DECLARATIONS_RELPATH`, and copies neither string. - **The listing.** `WorkingTreeCorpus` (`runtime/local_git_adapter.py`) takes `excluded`, the exact keys its listing leaves out, and its default is that tuple. - It is applied after both listing branches, so tracked files, untracked files and a pinned revision all leave out the same paths. - A whole-corpus `check()` leaves them out too (Copilot r4170556938). A subject the caller names is still answered. That is all the filter does. Every other finding the parent reports stands as before, including a tracked path deleted from the working tree, which the listing omits and the parent's diff still reports. - `excluded=()` lists everything. - **One declaration, two layers (`7af4c999`).** T084's `default_columns.SETTINGS_DOCUMENTS`, which keeps the same documents out of every owned scope section, now builds its set from `doxbench_intake.SETTINGS_DOCUMENTS` instead of a second literal. It is a separate commit so that it can be reverted alone: it touches T084's `default_columns.py`. - **Every caller of `WorkingTreeCorpus` is openDox's standalone default.** In `src/`, the class is constructed only by the twin `_default_home_factory` in `cli.py` and `serve.py`. A GitHub code search of `opensoft` found it in no other repository's code, but that search covers default branches only. So the class default is that default's rule. - **Cost.** `local_git_adapter` still costs the standard library alone to import. It now also names `opendox.doxbench_intake`, itself stdlib-only. - **The source route.** A file left out of the listing is still a file, and `/source/<path>` still serves it by name. The rule is about what the corpus lists. - **The comments in `cli.py` and `serve.py` (Copilot r4174646638).** These are documentation-only changes in two single-writer files: - the twin `_default_home_factory` docstrings no longer say `check` is inherited unchanged; - `cli.py`'s import comment now names `opendox.doxbench_intake`. **The settings cases, over the full listing as written, with nothing subtracted:** - `test_openDoxs_settings_documents_are_declared_once` checks: - the two constants; - that `WorkingTreeCorpus`'s default is that very tuple (`is`); - that `default_columns.SETTINGS_DOCUMENTS` equals it as a set. - `test_the_standalone_corpus_lists_neither_settings_document`, through each entry point's own `_default_home_factory`: - the bindings document and the declarations document are not listed: untracked, then **committed**, then at that commit as a pinned revision; - a user's own `ideation/dashboard/notes.md` **is** listed; - so is an `ideation/dashboard/archive/model-provider-bindings.yaml` that has the bindings document's file name. - `test_a_corpus_told_to_leave_out_nothing_lists_every_file`. - `test_a_whole_corpus_check_names_only_what_the_corpus_lists`. ## Falsifier, failing before and passing after **Before.** The branch's test file was run over #74's head `9061b22a` sources. The result was `7 failed, 68 passed, 5 xfailed`. The failures were the four settings cases plus the snapshot, generation, and wheel-and-lens cases. The other surfaces already answered alike there, so their cases keep that property named. **After**, at `87a753a8`, at `a25606bb` and at the head, the named file has `83 passed`, with no xfails. ## Mutants Mutants were applied one at a time by a harness that restores each file and checks its digest. Each run used only the cases meant to kill it. **At the head `9948dc2b`, all 20 were killed in one run** (`tools/mutants-t082-full.json`), and each by the assertion it targets: - **K01**: `/capabilities` discloses whether a model is configured. - **K02b**: the intake surface offers intake where a binding is declared. K02 was re-anchored on T084's line. - **K03**: select-to-edit refuses where no model is configured. - **K04**: a document's source is served differently where a model is configured. - **K05**: an action no route claims answers 200 where a model is configured. - **K08**: the working-tree corpus ignores `excluded`. - **K09**: a pinned revision lists the settings documents. - **K10**: the declarations document is missing from the list. - **K11**: the exclusion drops the whole corpus. - **K13**: the standalone default leaves out nothing. This is the holder's first mutant: drop the exclusion. - **K14**: the exclusion is widened to the settings documents' directory. This is the holder's second mutant. - **K15**: the exclusion matches by file name. - **K16**: K13 again, run against the 16.5 HTTP cases alone. - **K17**: a whole-corpus check reports an excluded path. - **K18**: the check also leaves out a path the caller names. - **K19**: the scope default's settings set drifts from the one declaration. - **K20**: the intake surface's reason is the broker notice, not the gate seam's. - **K21**: model approval refuses under the intake code. - **K22**: `actions.gate` reads true standalone, so the session controls are shown. - **K23**: the intake surface carries a `console_token` key that differs by posture. It survived the earlier comparison, which dropped `console_token` from every JSON answer. It is killed by the `/capabilities`-only comparison (`c6689c55`). ## CI pins - **`EXPECT_SKIPPED` stays 11.** While the five T084 cases were strict xfails it read 16, because JUnit writes an xfail as a skip. The merge that removed them moved it back. - **The floors are re-pinned** by the workflow's own rule: three below the lower of two greens of one tree. - They were re-pinned last in `0b0f038e`, over the tree `a25606bb`, which is T082 with `main` at `0116293a`. - Run `37152269851` read `triple: selected=3980 passed=3969 skipped=11 failures=0 errors=0` on attempt 1 (job `111288409713`) and on its re-run (job `111296677742`). - So `MIN_SELECTED` is now 3977 and `MIN_PASSED` 3966. The re-pin before that, `c6703779` over `87a753a8`, read 3936/3925. - The floors had not moved since T037, so the declared three-test margin had grown to several hundred (Copilot r4170556888, r4174447721). - T082's later commits add no case. CI at `0b0f038e` read 3980/3969/11, a margin of 3. - At the head `9948dc2b`, CI (run `37159199209`, job `111308882309`) read `triple: selected=3987 passed=3976 skipped=11 failures=0 errors=0`. The margin of 10 is the 7 cases #85 brought in with `main`. A PR that lands later re-reads the floors by the same rule. ## The whole suite All local runs were in the foreground, with `LANG=C.UTF-8`, no `GIT_*` or `XF_*` variables, and no PostgreSQL service, so the runtime cases skip. - `87a753a8`: `3759 passed, 177 skipped`, which is 3936 selected, as CI reads. - `a25606bb`: `3803 passed, 177 skipped`, which is 3980 selected, as CI reads. - The head `9948dc2b`: `3810 passed, 177 skipped`, which is 3987 selected, as CI reads. ## Review rounds - Copilot at `947411fd`: two findings, both fixed (`92275fcd`, `dcdb7554`), answered and resolved. - Copilot at `dcdb7554` and `7332be52`: no open findings. - Copilot at `7af4c999`: r4174447721, floors not re-pinned after T084. Fixed in `c6703779`. - Copilot at `87a753a8`: r4174646638, the twin factory docstrings still listed `check` as inherited unchanged. Fixed in `7a022068`. - Copilot at `0b0f038e`: one open finding and two "previously missed" notes. - r4174697909: this description still described the stacked phase (xfails, `EXPECT_SKIPPED` 16, `NO_BROKER_NOTICE`). It is rewritten here. - The generation case ran its `generate` children on the ambient PATH, because the `postures` fixture restores PATH once its servers start. It now uses the same no-`omp` PATH (`47e8bb95`). This machine has no `omp`, so the output did not change here. - `check()`'s docstring said a whole-corpus check covers exactly what `list_documents` lists. That was broader than the code. The filter leaves out only the excluded settings documents. A tracked file deleted from the working tree is omitted from the listing, and the parent still reports it as a divergence. That reporting dates from before T082. `47e8bb95` narrows the docstring to what the code does, and does not change that behavior: dropping a real uncommitted deletion from the verdict would be a different rule from 16.5's. - Copilot at `47e8bb95`: Findings: None. It made one "previously missed" note: `comparable()` dropped a top-level `console_token` from every JSON answer. Fixed in `c6689c55`, which compares every surface but `/capabilities` whole (mutant K23). - Copilot at the head `9948dc2b`: "Needs a closer look", Findings: None. Its one note was that this description was stale, and this revision answers it. No review thread is open. ## Not in this PR - T083 (16.6, then F16.1 whole), which runs at landing. This writer's local check of it was green and was reported separately. - The holder's bookkeeping: the plan box for T082. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…amed failure, and the rail's thread read follows #85 (plan 034) Copilot review of openDox-code#75 at ec95f45: - r4175016672: the install-mode reason echoed the install block, so a token nested in it (`{"install": {"mode": "hosted", "console_token": …}}`) was printed, as only a top-level token was kept secret. Now no step-5 reason quotes a `/capabilities` payload: the install-mode reason is fixed, and a page that is not a JSON object is named by its type only. Every string under a key that names `console_token`, at any depth, is kept secret. And `serve_one` reads the opener's tokens as soon as the start answers, before step 5 quotes anything (`learn_opener_tokens`; T104 writes the opener before it serves), so a token under any other name prints nowhere either. The assertions keep their order, so CI's by-design failure keeps its id. - r4175016692: a body nested past the JSON parser's depth raised RecursionError past `fetch_object` and `check_catalog`, a harness ERROR (exit 2). `Answer.json()` now raises ValueError for it, so the snapshot, `/capabilities` and the catalog each fail by name. The holder's T096 dry run, F4, after openDox-code#85 (the T102 follow-on, c4b55cc; holder ruling F1 (i)): a standalone plane's chat rail sends no `/workbench/thread` read, as it reads one only through a branch-session column (`gate.workbench.session`). The harness no longer asks the thread read with a query it claimed the rail sends; it asks the bare route literal, as it asks every literal, and asserts the condition: `[chat rail reads no thread (no branch session)]`. Tests: 17 new cases (247 harness cases; 358 with deploy-shape). Mutants nv1, js1, fo1, im1, pk1 and rw1 (this review), and th1-th3 (F4), are each killed at the named cases. 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 #85 (the T102 follow-on: the chat rail reads a thread only with a branch session). No file this branch edits changed on main; the merge is clean. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…plan 034) (#82) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability **Plan 034 T100. #1144 box 16.3a: a served repository's bindings are trusted per machine.** RULED by Brett Heap on 2026-10-02 in openxFactory#656 comment `5962785556`, item 2: *"Trust per machine (Recommended)"*. The text this conforms to is T007 batch M (openxFactory#1219, merged as `cc775fea`): box 16.3a and F16.1's batch M block. What openxFactory registers when it hosts openDox was RULED on `5970369724`; that registration is T094's (below). Phase 3. The base is `main`. T100's After line (T080, openDox-code#64 and T084) is met: #64 landed as `8e377823`, and #77 (T084) as `e49b17c3`. - This branch merged #64's final pre-squash head `04bcb68e`. - It then merged `main` `8e377823` with `git merge-tree --merge-base 04bcb68`, both parents recorded. - It then merged `main` `e49b17c3` (#77), `390e2c28` (#80), `0116293a` (#81), `c4b55cc4` (#85) and `ca9e1bd5` (#76) with ordinary merge commits. The branch held none of #81's, #85's or #76's commits. Nothing was rebased. The diff against `main` is T100's 12 files only. ## The defect The entry points read the bindings document of the repository they SERVE (`declared_model_port_factory` over `bindings_path(checkout_root)`). A binding written into that file by hand, or arriving with a clone, needed no approval. The adversarial review of 2026-10-02 showed two things: - A committed `broker_argv` of `["/bin/sh", "-c", "id > $PWD/pwned"]` ran on the first chat turn. - A committed `env:` reference sent an unrelated secret of the operator's to the file's endpoint as a bearer token (`repo_binding_exfil.py`). The first two cases of `tests/test_model_binding_trust.py` are those two findings, run as the review ran them. On `main` `8e377823` without this change, they fail for the defect's own reasons: ``` AssertionError: the endpoint was contacted, and was sent ['Bearer cloud-secret-NOT-A-MODEL-KEY-7d41e9a2c0b85f36'] AssertionError: the repository's program ran: uid=1000(brett) gid=1000(brett) groups=1000(brett),1001(docker-host) ``` ## The rule, and where it is enforced A binding read from the served repository runs a broker, resolves a credential reference (`env:`, `keyring:` or a broker's), or contacts its endpoint ONLY after the operator has trusted THAT EXACT binding on THIS machine. The auth kind `none` is included, because it still sends chat content to the endpoint the file chose. - **`src/opendox/doxbench_trust.py` (new, standard library only).** - **The key** is (the repository root's resolved path, the binding id, `sha256` of the binding's canonical full record). The canonical record is `as_record()`, every field, sorted, compact, ASCII. So any edit untrusts the binding, and so does the same file under another root. - **The store** is one private file, `model-binding-trust.json`, in openDox's state directory (`OPENDOX_STATE_DIR`, through #69's `config.state_dir`). It holds no credential and is written owner-only, through an exclusive, no-follow temporary file and a rename. - **Its checks.** It is read only once it passes the checks #69's bundle makes of its own tree: no symbolic link, owned by this user, writable by no one else, and every directory above it this user's or root's, sticky where another user can write it. A store that fails a check trusts nothing, and the refusal names it. The store and its lock file are opened without waiting (`O_NONBLOCK`), so a FIFO in either place is refused by the type check on the opened descriptor rather than waited on. - **Its size.** The store writes nothing larger than it reads (`MAX_TRUST_STORE_BYTES`). A record that would outgrow it is refused before anything is replaced, so every trust already held stays held. - **Its lock.** Every record holds an exclusive lock on `model-binding-trust.lock`, beside the store, across its read, its change and its replace. So two processes recording at once keep both trusts, and neither restores a form the other replaced. The lock file gets the same link and permission checks as the store, and is then set to exactly 0600, whatever the umask. Where no lock can be taken, the record is refused by name and nothing is written. Readers take no lock: the replace is atomic. - A state directory equal to the served root, or nested under it, is refused naming `OPENDOX_STATE_DIR`, before anything is written. So is one that cannot be resolved, such as a link loop. - **A platform without the primitives the store needs** has nothing trusted, with a refusal that names the platform and the gaps (`unsupported_platform()`, following #69's form). The primitives are `os.getuid`, `O_DIRECTORY`, `O_NOFOLLOW`, `O_NONBLOCK`, `fchmod`, `mkdir` with `dir_fd`, and `fcntl.flock`. So every verdict reads untrusted rather than raising. - **A link to nothing** on the way to the store, whoever owns it, is refused by name. So is any `OSError` the store's tree raises that no check named: it becomes a `TrustStoreRefused` naming the store and the system's short word for it, in `record` and in `verdict`. - **A binding the model catalog refuses is never trusted** (`unservable_because`). Its id or its label is one the catalog cannot list, so no turn could use it. The check builds the same catalog the factory declares (`brokered_catalog`), and it runs BEFORE any policy is asked: - `verdict_for` refuses such a binding, even under a host policy that trusts every binding or with a store entry recorded earlier. The start declares the refusing port over an empty catalog and never fails. - `recorded_for` refuses such a binding, so `add`, `edit` and `trust` record nothing and write nothing. - Its refusals print no trust command, because trust cannot repair it. They name the actual remedy instead (`REMEDY_UNSERVABLE`): correct the binding with `opendox model-binding edit` (to keep the id) or `remove` then `add` (to change the id). Each of those records trust for the binding it writes. - **Every answer a policy gives is held to the binding AND the root asked about.** - `verdict_for` passes on a verdict for this root that admits exactly this binding, or an untrusted one for exactly this record at this root. Anything else becomes an untrusted verdict for THIS binding at THIS root: - a verdict for another binding, trusted or not; - another form of this one; - a verdict minted for another repository root; - a non-verdict; - a policy that raised. So every refusal names the right binding, root and command, and the per-repository key holds against a host policy too. - `recorded_for` refuses by name (`TrustNotRecorded`) a policy that declines to record, records another binding or root, or raises. It names what a policy raised, never its words. Only openDox's own store's `TrustStoreRefused` passes through as written, and only from exactly `MachineTrust`, not a host's subclass. A host policy's refusal of any class, `BindingRefused` included, is named by its class alone. - **`doxbench_install`.** The factory asks `verdict_for` about the first approved binding (`trust_gated_model_port_factory`). - Trusted: the brokered port, handed the verdict. - Untrusted: `UntrustedBindingPort`. Its catalog lists the binding `available: false`, every dispatch is refused by name, and the factory writes a notice on stderr naming the id and the command that trusts it. - A checkout with no bindings never asks the policy, so it never touches the state directory. - **In depth, `doxbench_provider`.** These all refuse a binding that no verdict covers: - every broker operation (`mint`, `hand_off_credential`, `revoke`, `list_references`), in `_broker_operation`, before the argv is built and before either runner's branch; - the built-in resolver, after its two route assertions and before its first read; - the port's `dispatch` and `catalog`. A verdict covers only the id and digest it was given for. - **`cli_model_binding`.** - `add` and `edit` record trust for the binding they write. They record it FIRST, so a store or a policy that refuses leaves nothing written and nothing printed as "trusted". - `trust <id>` (positional, no `--yes`) prints what it trusts, then records trust for exactly that record: the broker argv it would run, the endpoint, the auth kind and the credential REFERENCE, never the credential. It resolves no reference, spawns nothing and contacts nothing. - **The command every refusal, notice and `list` prints** is `opendox model-binding trust --repo-root ROOT [--bindings DOCUMENT] ID`. It is printed only from operands a POSIX shell reads back exactly (`trust_command`): - an id the catalog accepts: ASCII letters, digits, `.`, `_` and `-`, beginning with a letter or a digit, so no shell expands it and no option parser reads it as an option; - paths quoted by `shlex.quote`, and only where every character is printable. A root that is not printable is never printed: the command names it `.`, to be run from that repository's root. `--bindings` appears where the binding was read from a document given by one (`list --bindings`, or the factory's `bindings_path`). Run as printed, the command trusts exactly the binding it names. - `set-credential` is refused before its broker spawns when the binding is untrusted, and it leaves the binding untrusted. It re-trusts a TRUSTED binding after rewriting its reference. - `list` reads the bindings document ONCE, and derives everything it prints from that reading. It adds two lines per binding: - **trust:** trusted, or why not and the command that trusts it; - **console:** the one binding a console serving this repository declares, by the factory's own rule. A pending declaration is passed over, an unreadable declarations document declares nothing pending, and the console declares the first of the rest. Where `list` was given another document, the line says the console does not read it. - Every value a repository wrote is printed in a JSON string's form, by `list`, `trust`, the refusals and the notice. A newline or `\x1b[2J` in a field cannot forge or hide a line. - **`serve_workbench`.** - A turn on an untrusted binding is refused `model_unavailable` with a FIXED sentence (`UNTRUSTED_TURN_MESSAGE`) saying how to trust it. The catalog's shape is closed, so the reason travels in the refusal, the notice and `list`. Where the catalog cannot list the binding, the sentence is `UNSERVABLE_TURN_MESSAGE` instead, which names the remedy and no command that trusts (`turn_message_for`). Both fit within the released failure envelope's 500-character `message` bound. - **The console's model approval** answers `APPROVAL_NOTICE` ("becomes an available catalog entry") only where the registered trust policy admits the binding it approved. That is a governed host's approval, or a binding this machine trusts. Otherwise it answers `APPROVED_UNTRUSTED_NOTICE`, a fixed sentence saying the binding is not yet trusted and how to trust it. A binding the catalog cannot list is checked first, under any policy and without reading a store, and answers `APPROVED_UNSERVABLE_NOTICE`, which names the remedy and no command that trusts. Approval asks only a policy that is already registered, so where nothing is registered it registers nothing and reads no store. It reads the registration once, under the seam's lock (`registered_verdict_for`), so a host that unregisters meanwhile never has the default installed in its place. - **The console intake follows batch M.** Its hand-off runs a broker the served repository's `ideation/dashboard/model-declarations.yaml` names, and that broker belongs to no binding. So it asks the registered policy its OWN question, `intake_verdict_for`, which no binding's trust can answer. - openDox's strict default (`MachineTrust.intake_verdict`) always says no, refused by name (`intake_refused`, reason `INTAKE_BROKER_UNTRUSTED`, a fixed sentence). That happens before any byte of the body is read, and the body is drained unread. - A host admits the intake only through its own `intake_verdict`, an optional third callable on the seam. A policy without one admits no intake. - So a repository that declares a binding with the intake's exact fields, and gets it trusted, gains nothing. No command trusts an intake declaration's broker. - **The chat rail (`web/views/doxbench-chat.js`).** It has its own visible line, `UNTRUSTED_BINDING_REMEDY` (Python twin `doxbench_trust.UNTRUSTED_BINDING_REMEDY`), beside #74's no-model line. - It shows when the catalog has answered, is not empty, and offers nothing available, and it is announced once. - It names `"opendox model-binding list --repo-root <repository>"`, which shows whether each binding is trusted, and `"opendox model-binding trust --repo-root <repository> <id>"`. It also says that a binding already trusted, which a provider's refusal also leaves unavailable, is unavailable for the reason the console printed when its provider refused. - **`--repo-root` is in every quoted command.** `--repo-root` is required by every `model-binding` verb. Each command a fixed sentence quotes (the refused turn's, the rail's and the approval's) is parsed by the real parser in a test, with its placeholders filled in. - #74's no-model line stays hidden, because a model IS configured. - The sentence says "where it would connect" rather than naming a credential, because `test_doxbench_privacy.py` bans that word from both chat modules. - **Bindings stay committable.** The bindings document is unchanged, and no trust is ever read from it. ## Whose rule: the neutral default, and the seam `doxbench_trust` is a policy seam (`register` / `register_default` / `current` / `policy` / `unregister`), with the same window rule as `projection_seams`: the default is replaceable until it is read, a host over a host is refused, and the same registration again is a no-op. A policy carries `verdict` and `record`, and optionally `intake_verdict`. openDox's strict per-machine store is the NEUTRAL default. **Why the default is registered lazily, which departs from R1Q10 (a)'s entry-point registration** (accepted by the holder): the CONSUMERS register it, the first time one asks and only where nothing is registered yet. Those consumers are `declared_model_port_factory`, the `model-binding` verbs and the intake hand-off. The console's approval is not one of them: it asks only a policy that is already registered. So this PR adds no hunk to `cli.py` or `serve.py`, which stay out of the phase-3 single-writer order. It also fails closed: a bare process is held to the strict default too. A host's registration at process start wins. **How this relates to 4.2's seam tests.** Each seam module's tests enumerate and test that module's own seams: `tests/test_projection_seams.py`, `tests/test_doxbench_seams.py` (the doxBench validators and rail), and #77's `tests/test_column_seams.py` (`SEAMS = (cs.gate, cs.scope, cs.kickoff, cs.register)`). No test enumerates every seam of the package, so none needs this one added. `doxbench_trust.current()` refuses by naming its own seam and the registration call (`TrustPolicyNotRegistered`), as 4.2's discipline asks, and `tests/test_model_binding_trust.py` holds that. `tests/test_consumer_reach.py` derives its record over the whole package and passes with the new module, which imports no sibling. ## What T094 must register RULED by Brett Heap on openxFactory#656 comment `5970369724`, *"Governance approval (Recommended)"*: openxFactory registers its own policy, `GovernedBindingTrust`, as a sixth `seams()` entry with an undo. Under it: 1. A binding whose declaration the governance flow APPROVED is trusted. 2. A binding a repository declared that is still PENDING is refused. 3. A binding with NO declaration is trusted: the operator's own, or the console intake's new binding while its broker runs. 4. Where the declarations document cannot be read, nothing is admitted. 5. `record()` writes nothing. 6. **`intake_verdict` must answer too**, as the policy answers for a binding with no declaration. The console intake asks that question and no other, so without it the governed host's intake would be refused. With it, the intake stays as it is today, which the ruling keeps. `_GovernedHostPolicy` in `tests/test_model_binding_trust.py` is that policy as a test-local stand-in. The tests that compose it in process: - `test_a_governed_host_policy_keeps_the_governed_flow[approved|undeclared]`: the factory resolves the brokered port, and a turn reaches the listener, exactly as before this change. - `test_a_governed_host_policy_keeps_the_console_intake`: the governed intake runs its broker. - `test_a_governed_host_policy_refuses_a_pending_declaration`: the pending and unreadable cases, and `recorded_for` refusing a policy that declines. In the same checkout, the strict default refuses each of these until `trust`, and always refuses the intake. ## Evidence | Run | Tree | Result | |---|---|---| | Red: the final test file with no other change | `main` `8e377823` | 14 failed, 1 passed (`test_the_edits_cover_every_field_of_the_record`), 1 xfailed, 56 errors (`ImportError: cannot import name 'doxbench_trust'`). The two defect cases fail as quoted above. | | Red: each Copilot round's new cases, before their fixes | the code before each fix | Round 1: 8 failed. Round 2: 7 failed. Round 3: 3 failed. Round 4: the 4 new cases failed. One example: the two-process case kept only `first-binding`. | | Red: round 8's cases, before their fix | `42c98f9d` | 2 failed, 1 passed. The store's verdict waited on a FIFO, and the platform check did not name `O_NONBLOCK`. The lock file's FIFO case passes there too, because Linux opens a FIFO read-write without waiting. | | Red: round 7's cases, before their fix | `735d0c14` | 2 failed. Under umask 0777 the second record was refused ("the lock file cannot be opened"). The approval installed `MachineTrust` after a host's teardown and answered `APPROVAL_NOTICE` from its store. | | Red: round 6's cases, before their fix | `e05c475c` | 7 failed, 9 passed. Both sites the thread named told the operator to trust a binding past the catalog's bounds: the approval's availability, and a served turn's message. | | Red: round 5's cases and the self-pass's, before their fixes | `7012cda3` | 32 failed, 5 passed. Three hostile repository names made `CANARY` when `sh` ran their printed command: a newline, a terminal escape and a non-ASCII character, each followed by `$(touch CANARY)`. The factory raised `InvalidCatalogEntryError` on a trusted binding past the catalog's bounds, and `record` raised `FileNotFoundError` through a link to nothing. | | Green: the trust module, with the census, chat-configuration and deploy-shape modules | `735d0c14` (the merge's tree, run just before it was committed); the trust module alone again at `adb19f1e` | 336 passed, nothing skipped or xfailed; 135 passed at `adb19f1e` | | Whole suite (`tests` + `tests_runtime`), in a venv installed by CI's own command (`pip install --only-binary :all: -c constraints-cpython312-linux.txt -e ".[runtime,test]"`) | head `adb19f1e` | **3945 passed, 177 skipped, 0 failed.** The 177 are the database-backed runtime cases, which need CI's PostgreSQL service (`OPENDOX_TEST_DATABASE_URL`). #76 reads the same skips on its own local run. | | The 10 local reds of earlier rounds | `a6c2a844` (the 10 alone), then the head (in the whole suite above) | They were this machine's first venv: it was installed without the `test` extra, so it lacked the `local` extra's `pixeltable-pgserver`, and generate-and-open with `--local` refused ("the local install's PostgreSQL server is not installed"). In the CI-command venv, all 10 pass, with a short TMPDIR (`~/.local/state/t1`) and with the long one (`~/.local/state/t100-tmp`) alike. The socket-path length was not the cause here. | | Mutants (`mutate_t100.py`, one textual edit each) | head `adb19f1e` | **94 of 94 killed**, in one run (`mutants-run-17`). | | openxFactory's existing tests (`tests/ideation-dashboard -m "not postgres"`, in a clone named `openxFactory`, TMPDIR holding the basetemp) | openxFactory `main` `1f670bc3`, openDox code leg at `main` `8e377823`, then at T100 `7a04590d` | 4 failed, 1477 passed both times; the failure sets diff IDENTICAL (all 4 pre-existing). openxFactory injects its own `model_port_factory` and commits no bindings document, so its governed flow never reaches the gate before T094. The later rounds change only the store, how verdicts are held, and the rail's sentence. | The mutants killed: - **The digest:** each of the ten fields, each by its own hand-edit case. - **The root key:** ignored, or not resolved. - **The factory gate:** by-passed, or accepting a verdict for another binding. - **The in-depth refusals:** the broker operations, the resolver, `dispatch` and `catalog`; and a verdict admitting by id alone. - **The store:** - accepting a writable file; - following a link to its file; - skipping the link check or the tree check; - being written with mode 0666; - following a temporary-file link planted in the race window. - **The CLI:** - `add` and `edit` recording no trust, or writing before recording; - `set-credential` skipping the gate, or not re-trusting what it rewrote; - `trust` recording nothing or printing nothing; - `list`, the disclosure and the refusals printing raw values; - `list` saying nothing of trust. - **The intake and the state directory:** the intake gate; the state-directory check, skipped or blind to nesting. - **The default and the notice:** the lazy default never registered; a silent factory notice. - **The rail line:** shown beside an available model, for an empty catalog, or where a host offers intake; or never announced. - **Copilot round 1:** - recording without the cross-process lock, or never taking it; - the lock file opened through a link, or its owner and mode unchecked; - a declined record taken as trust; - a policy's failure to record escaping; - an untrusted verdict for another binding passed through; - the intake asking the binding question; - the per-machine store admitting the intake. - **Copilot rounds 2 and 3:** - the platform check skipped, or forgetting the file lock; - an unresolvable state directory escaping; - the rail line still claiming `list` says why; - a verdict for another root passed through; - a host policy's refusal text passing through, of either class; - openDox's own store's refusal sanitized too. - **Copilot round 4:** - a host's `MachineTrust` subclass passing its refusal text; - the writer outgrowing the read bound. - Round 4's "a dashed id printed where an option is read" is RETIRED with the `--` it mutated: no id the catalog accepts begins with `-`, and no command is printed for one that does. - **Copilot round 5 and the self-pass:** - a printed path not shell-quoted; - an unprintable path printed in a command; - an id the catalog refuses printed in a command; - a binding trust cannot repair told to trust; - a verdict trusting, or a record recording, a binding the catalog refuses; - servability judged by the id alone; - `list` judging a second reading; - `list`'s command, the factory's notice and the refusing port each omitting the document read; - a link to nothing gone through; - a system refusal escaping `record` or `verdict` raw; - the console line ignoring pending declarations or the document listed, or naming the last approved binding; - an approval always saying available, or registering and reading the default; - the turn message's `list` command or the rail's `trust` command dropping `--repo-root`. - **Copilot round 6:** - an approval of a binding the catalog refuses saying to trust it; - a turn on such a binding saying to trust it; - a refused turn naming no cause. - **Copilot round 7:** - the lock file keeping the umask's mode; - an approval reading the seam twice; - the registered verdict registering the default. - **Copilot round 8:** - the store waiting on a FIFO in its place; - the platform check forgetting the nonblocking open. The F16.1 batch M cases each serve a fresh `git init` with a fresh `OPENDOX_STATE_DIR`. They run over three bindings in turn: - a broker that writes a marker file; - an `env:` reference whose environment records every name read; - a `keyring:` reference whose stand-in backend records every lookup. The listener records every request. One test per case of the block. **`set-credential` on an `env:` or `keyring:` binding** is refused by the refusal it already had, "names no broker", which also comes before any read or spawn. Naming `trust` there would point at a command that cannot make the verb work. ## Review Every Copilot finding was accepted and fixed with a case that failed first and its mutant, then answered on its thread and resolved: - **Round 1, at `1ef4c71d`, fixed in `7a04590d`:** - `r4173513738`: a policy that declines to record; - `r4173513761`: the lock across processes; - `r4173513782`: the intake's own question; - `r4173513795`: a verdict for another binding. - **Round 2, at `7a04590d`, fixed in `e4b145d1`:** - `r4173876800`: a platform without the store's primitives; - `r4173876823`: an unresolvable state directory; - `r4173876849`: what the rail line says `list` shows. - **Round 3, at `8270dffc`, fixed in `e4b145d1`:** - `r4174310794`: a verdict for another root; - review `5402101086`'s "previously missed" item: a host policy's refusal text. - **Round 4, at `cb691b18`, fixed in `24a1c25e`:** - `r4174632006`: only the exact `MachineTrust` passes its refusal; - `r4174632060`: the printed trust command runs as printed; - `r4174632086`: the write bound. - **Round 5, at `7012cda3`, fixed in `db77c4ea`:** - `r4174783197`: a printed command a shell reads back exactly; - `r4174783250`: `list` reads its bindings once; - `r4174783280`: a binding the catalog refuses is never trusted, and never fails the start; - `r4174783301`: a link to nothing, and any unnamed `OSError`, refused by name. - **Round 6, at `e05c475c`, fixed in `a6c2a844`:** - `r4175203889`: a binding the catalog cannot list is told its remedy, at the approval and in a refused turn, and never to trust it. - **Round 7, at `735d0c14`, fixed in `42c98f9d`:** - `r4177946237`: the lock file is 0600 whatever the umask; - `r4177946288`: the approval reads the trust seam once. - **Round 8, at `42c98f9d`, fixed in `adb19f1e`:** - `r4178064601`: the store and its lock file are opened without waiting on a FIFO. **The adversarial self-pass, in the same commit.** It covered two things: - **Every command printed for a human to paste.** That is each refusal, the notice and `list`, plus each command a fixed sentence quotes. Hostile values in every interpolated operand were run through `sh`, `bash` and `zsh`. - **The trust-state machine,** for whether what is printed, what is stored and what is enforced agree: pending, approved, undeclared, unreadable declarations, and a stale record. It found three gaps, each now fixed with its case and mutant: - `list` did not say which binding a console declares; - an approval said "available" for a binding the strict default still refuses; - the fixed sentences quoted `model-binding` commands without the required `--repo-root`. **SonarCloud.** The quality gate's one failure was `python:S5332`: the trust disclosure spelled a plain-HTTP URL scheme, and now says "plain HTTP". The two functions over the cognitive-complexity bound (`_unsafe_because`, `_refuse_an_unsafe_tree`) are split into named parts, with the same rules. The gate passes from `7a04590d` on. ## CI's triple `EXPECT_SKIPPED` is 11, `main`'s own value. It moved to 14 for three strict-xfail cases, each waiting on a draft and naming it: - the store's default home (#69); - the rail's trust line (#74); - a served turn that reaches its model step standalone (#77). It stepped back by one, with its reason in the workflow, as each draft reached this branch (`1ef4c71d`, `32646871`, `78d1e904`). All three cases now run and pass. The floors are not moved. **The web census's class-A total is re-derived from the merged tree at every merge of `main`.** It is 18526 at the head: `main`'s 18486 plus this change's 40 lines in `views/doxbench-chat.js` (1890 to 1930). Round 5 changed that file within one line, so its count holds, and #76 touched no web file. **The floors** are #76's re-pin (3977 / 3966, T082). T100 moves neither, since it only adds cases. The merge kept both reasons for `EXPECT_SKIPPED` holding at 11, T082's and T100's. #84 (T104) also moves `EXPECT_SKIPPED` and class A. Whichever of the two lands second re-derives both values from its own merged tree. ## #77, and other notes - **#77 refuses the console intake standalone** when no gate-record writer is registered (`5961364221` item 1). So this file's intake cases use a stand-in host that registers a host gate at `opendox.column_seams.gate`, as #77's own tests do. - **#77's host fixture** runs `model-binding add` in a CHILD with a private `OPENDOX_STATE_DIR`. The parent's factory reads its own store, so a turn there still reads the binding untrusted until that store trusts it. - **#69's tree checks are DUPLICATED here, not imported.** `runtime/bundle.py`'s helpers take a different signature, raise `BundleRefused` with the bundle's wording, and the module imports heavier modules. They are kept in step by rule, and the tests hold both. ## Gates - `pyflakes` is clean over the changed modules. - `tests/test_provider_boundary.py` passes. No module outside `doxbench_provider` spells its banned needles. - No closing keyword appears in any commit message on this branch or in this body. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…(plan 034) (#84) Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Arc: neutral-product-standalone-operability Plan 034 (`specs/034-opendox-standalone-operation/`), phase 3: - **T104, the console token travels in the opened URL, not `/capabilities`.** RULED on openxFactory#656 in [`5963851934`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963851934) (Brett, 2026-10-03, *"Token via the opened URL (Recommended)"*). Its plan entry landed with openxFactory#1220 (`ec9308c8`). - **From adversarial review 2's M5 (pre-existing).** A standalone openDox served its per-serve console token from `/capabilities` to any loopback caller, other OS users on the same machine included. Nothing checks which local user connects. The token lets a page edit documents and run chat turns that spend the operator's model credential. - **The holder's ruling on openxFactory#1220's review** (Copilot `r4171166321`): the private copy refuses an `OPENDOX_STATE_DIR` that is, or lies inside, a served root. This mirrors T100's served-repository boundary. - **After:** T103 (#80, landed as `390e2c28`) and T102 (#81, landed as `0116293a`; its follow-on #85 landed as `c4b55cc4`). `serve.py`'s single-writer order is T084 (#77) → T103 (#80) → T104. Every After line is met. - **Base `main`.** The holder retargeted #84 when #80 landed, and the diff is still T104 alone. It was built on #77's `3387293e` and merged forward as its base moved, never rebased: - #80's `d0f1efcb` at `6db50b03`; - #80's final pre-squash head `b9025b6c` at `c1e7d8d8`; - main `390e2c28`, #80's squash, at `60bace00`. That merge's tree came from `git merge-tree --write-tree --merge-base b9025b6c`, with both parents recorded. The squash has `b9025b6c`'s tree, so the merge changed no file; - main `0116293a` (#81) at `d4b99436`, main `c4b55cc4` (#85) at `182cac76`, main `ca9e1bd5` (#76, T082) at `ddb26c34`, and main `38d3350e` (#82, T100) at `021944a6`. All are plain merges, because this branch held none of those PRs' own commits. Each time, the census's class-A total was re-derived from the rows of the merged tree, now 18621: #82 moved `doxbench-chat.js` 1890 → 1930, and T104 moves `notebook.js` 109 → 204. `validate.yml` merged cleanly each time; T104 adds no skip, so `EXPECT_SKIPPED` stays 11. The `ca9e1bd5` merge applies F2 from the holder's T096 dry run: #76's new postures fixture read a standalone child's token from `/capabilities`, and now reads the private copy (`child.console_token(base[1])`). - **Fix round 1** (`cb899546`, Copilot `r4173806506`, `r4173806552`, `r4173806590`, `r4173806621`): every served root is in the boundary, removal is exact under a race, a plain `kill` removes the copy, and the shutdown cases look before cleanup. See "Fix round 1" below. - **Fix round 2** (`c979747a`, Copilot `r4173889265`, `r4173889294`): the copy is judged by its own path, and no static link serves it. See "Fix round 2" below. - **The reverse boundary** (`36ff6103`): the holder's ruling on batch N's Copilot review, openxFactory#1222 [`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203). The state directory and every served root may not overlap in EITHER direction. See "The reverse boundary" below. - **Fix round 3** (`219d7cf9`, Copilot `r4174674625`, `r4174674702`): a directory's index page is judged, and a FIFO never blocks a read. See "Fix round 3" below. - **12.4a, clause by clause** (`a13857ad`): openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause is checked and its gaps are closed; the opener file's lifecycle is self-reviewed in the same commit. See "12.4a, clause by clause" below. - **One walk of the state directory** (fix round 4, `14285fbb`, Copilot `r4174785933`): every directory and link the walk passes through is judged, and the write is anchored to that walk. See "One walk of the state directory" below. - **Fix round 5** (`9f328892`, Copilot `r4175213798`, `r4175213842`, `r4175213864`, and `r4177975845` at `ddb26c34`): the snapshot files are served roots, publication and removal take one lock, and a stop is held through publication. See "Fix round 5" below. - **Fix round 6** (`9dcc9bb5`, Copilot `r4177975898`): an operating-system error during the walk is a refusal by name, for the writer and the reader. See "Fix round 6" below. - **Fix round 7** (`fb8a1cc4`, Copilot `r4178041022`): Ctrl-C is held like SIGTERM while a copy is written or removed. See "Fix round 7" below. - **Fix round 8** (`66cffdb8`, Copilot `r4178133814`, `r4178133842`): a running console's copy is reserved for its server's life, and every read that serves a file without the console check judges the file it opened. See "Fix round 8" below. - **The holder's adversarial review** (fix round 9, `45958bf3`; findings B1-B10 on `fb8a1cc4` and round 8): every standalone plane keeps the boundary, token or not; a platform without the POSIX primitives is refused by name; a browser that cannot open the copy is told how to move it (B3, ruled by Brett); a second spelling of a directory is judged by its identity; only the first stop is raised; `--no-serve` publishes nothing; and a publication sweeps the copies of consoles that died. See "The adversarial review" below. - **Fix round 10** (`af2a2efb`, Copilot `r4179091592`, `r4179091624`): the tokenless plane's guard walks the state directory once, as the writer does, and refuses an unsupported platform first. See "Fix round 10" below. - **Fix round 11** (`ed6e4769`, Copilot `r4179239380`, `r4179239411`, `r4179239424`, and a finding its review at `af2a2efb` lists as previously missed): a copy is known by what it holds, in any state directory and mid-removal; a check that cannot be made denies; an entry's in-memory payload comes before its guarded file. See "Fix round 11" below. - **Fix round 12** (`a2e36652`, Copilot `r4179793524`): a copy is written from a marker, so a part-written or growing copy is known by its first bytes; every reader judges the bytes it sends. See "Fix round 12" below. - **Fix round 13** (`c5fcdfa4`, Copilot `r4180089809`): the console page's URL is judged as a browser reads it: an exact loopback authority, and no backslash or control character. See "Fix round 13" below. Claimed on openxFactory#656 in [`5963979413`](https://github.com/opensoft/openxFactory/issues/656#issuecomment-5963979413). The holder posts READY, and the landers merge. ## What changes: the delivery only **Standalone means openDox's own default profile.** A plane is standalone when the profile `build_server` builds from IS `opendox.default_profile`, the one an entry point registers where no host has (`console_access.delivery_for`). - On a standalone plane, the token is minted exactly as before, and `/capabilities` no longer carries it. - A HOST's plane (openxFactory's `opendox_host.register_openxfactory()`, and this suite's own `_SuiteProfile`) keeps the `/capabilities` delivery, unchanged. See "The governed path" below. **`generate-and-open` and `python -m opendox.serve` write a private copy** at `<OPENDOX_STATE_DIR>/console/<port>.html`: - it is an HTML page that forwards to `http://127.0.0.1:<port>/index.html#console_token=<token>`. The token is in the **fragment**, never the query string, so it never reaches a request line, a server log or a `Referer`; - it begins with a marker comment (`COPY_MARKER`), before any byte of the token, so any part of a copy that holds a token byte is known for a copy (fix round 12); - it embeds the same record as JSON, escaped so that no value can end its `<script>` element; - **the browser is handed the copy's `file://` path, never the tokenized URL.** A URL given to `webbrowser.open` sits on a command line (`xdg-open`, the browser), and every user of the machine can read `/proc/<pid>/cmdline`. This is Jupyter's own redirect file, for the same reason; - the start prints the copy's path (` console file://…`) and **never the token**, with or without `--no-open`. To re-open the page, open that file again. Beside the path, one line with no token tells a user whose browser cannot open that file how to move the state directory (B3, ruled; see "Known limits"). There is no new verb, so the governed `--help` golden is unchanged; - the copy is removed when the server stops, BEFORE the listening socket closes. Only the file this process wrote is removed, so a later serve on the same port keeps its own. Another serve's copy is put back by a hard link, or by a rename where links fail. Every writer and remover holds the console directory's lock, so the put-back can never overwrite a newer copy. SIGTERM and SIGHUP are read as Ctrl-C from before the copy is written to after it is removed. So a plain `kill` or a closed terminal removes it too. A stop that arrives while the copy is written or removed, Ctrl-C included, is held until that is done, and only the first stop is raised (fix round 9). A SIGHUP the process was started ignoring (`nohup`), an ignored SIGINT, and a host's own handler are left as they were; - **a copy is reserved for its server's life** (fix round 8): the writer holds an exclusive `flock` on the file it wrote, on its own descriptor, until the copy is removed. A second console on the same port number and state directory (one on 127.0.0.1, one on ::1) is refused by name and never replaces it. A copy whose console died holds no lock, and the next publication sweeps it, whatever its port (fix round 9); - `--no-serve` writes no copy, opens none and prints no console line, since it closes the server before anything could use one (fix round 9); - if no safe copy can be written, the start is refused by name (`generate-and-open refused: …` / `serve refused: …`, exit 1), and the listening socket is closed. A platform without the POSIX primitives the copy rests on (Windows) is refused by name as well (fix round 9). An operating-system refusal, such as a directory this user cannot make or a full disk, is refused by name too. A write that fails part way leaves no partial file, and a copy whose read-back fails is removed. A console nobody can be handed is not served as if it could be. **The copy is checked as #69's bundle checks its tree, and by T100's trust-file rules.** `src/opendox/console_access.py` copies the rules (it does not import `runtime/bundle.py`'s private helpers): - the state directory and `console/` must be real directories, owned by this user and writable by no one else. `console/` must also be exactly 0700: its permission bits are judged, so a setgid bit inherited from a parent is accepted; - the state directory is resolved ONCE, by one walk. Every directory the walk passes through must be this user's or root's, and sticky if others can write it, including a directory reached only through a link's target. Every symbolic link it follows must be this user's or root's. The served-root boundary, the tree rules, the write and the read all work on the walked path, never on the configured one again; - a missing directory is made relative to its parent's descriptor, born 0700 under a 077 umask, and opened with `O_NOFOLLOW` before anything is made beneath it; - the file is created with `O_CREAT|O_EXCL|O_NOFOLLOW`, `fchmod`ed to 0600 on its descriptor, fsynced, then renamed into place; - if a name already at the target is anything other than this user's own regular file of mode 0600 with one link, it is **refused by name and never followed or replaced**, and the start is refused. That covers a link, a dangling link, a directory, a FIFO, another user's file, a hard link and a loosened copy; - **the served-root boundary** (the #1220 ruling, made two-way by the reverse ruling): the state directory and every root the plane serves may not overlap in either direction. A state directory that IS a served root, lies inside one, or HOLDS one is refused by name before anything is created (`OPENDOX_STATE_DIR (…) lies inside the served repository (…)`, or `… holds …, a root this plane serves`). The served roots (fix round 1) are: - the checkout; - the static bundle's `--web-dir`; - each declared `--source-root`; - each registry entry's root; - on a loopback plane, the sessions container; - the snapshot files `/snapshot.json` reads directly: the configured snapshot, each registered entry's, and, on a loopback plane, the session snapshots' container (fix round 5). Paths are compared after resolving, so a link from outside into a served root counts, and so does a served root named through a link into the state directory. Every directory on either path is also compared by its identity, `(st_dev, st_ino)`, so a second spelling of one directory, as a case-insensitive filesystem allows, is the same directory (fix round 9); - **every standalone plane keeps that boundary, token or not** (fix round 9). A plane with no git identity mints no token and writes no copy, but it shares the state directory with the planes that do. It still refuses a state directory that overlaps its served roots, and still marks the copies' directory private; - fix round 2 judged the copy's own path, so a served root that holds `console/` refused the write. The reverse rule covers that case and every other served root inside the state directory. That separate check is gone, and its case still passes; - the static handler never serves a copy: `publish` marks the private-copy directory on the server, and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it (fix round 2), by name or by the directory's identity (fix round 9). For a directory request, the index page the handler would serve (`index.html`, then `index.htm`) is judged too (fix round 3). The file it would serve is judged by its own identity before it is opened, and the file the handler opened is judged again, so a hard link or a link swapped in between is never sent (fix round 8). The body sent is bounded by the length judged, and its first bytes are judged as they are read (fix round 12). Links in `--web-dir` are still followed, since a governed host's composed web root is made of them; - `/source`, `/snapshot.json` and each registered entry's snapshot read through `serve.read_unless_private`, which judges the file it opened by its identity: a root re-pointed at the state directory after publication, or a hard link to the copy, is a 404 (fix round 8). Every such check also judges the opened file by what it holds, so a copy in another plane's state directory, or one removed mid-read, is refused, and a check that cannot be made denies (fix round 11). The bytes read are judged too, so a copy part-written or growing in another state directory is refused (fix round 12); - a read (`read_private_copy`, which the tests and T095's harness use) asks all of it again. The file is opened without blocking, so a FIFO is refused at once (fix round 3). It is checked on its descriptor: a regular file, this user's, exactly 0600, one link. **The page** (`web/views/notebook.js`, its only web file): - it takes `#console_token=` from `location.hash` at import, before the shell's first fetch; - it keeps the token in `sessionStorage` for this tab, or in memory where storage is blocked; - it strips the fragment with `history.replaceState(state, "", pathname + search)`, and a malformed token is stripped too; - `probeCapabilities()` fills the token into a payload that carries none, so every view keeps reading `caps.console_token` unchanged. A host's published token always wins. The query string is never read; - `app.js`, `edit.js` and the staging workbench's JS are untouched (T102 edits the workbench). `notebook.js` stays import-free, so its census row is re-measured (109 → 204) and class A's total is re-derived. **Every route that requires the token still requires it.** `_not_the_human_console`, the catalog, the thread read, the chat turn, the abstract, `/actions/edit` and the gate verbs are unchanged. ## Fix round 1 (`cb899546`) Copilot's review at `6db50b03`, four threads: - **`r4173806506`, served roots.** `build_server` reports every root the plane serves files from: the checkout; the static bundle's `--web-dir`, whose handler would serve a copy under it to anyone, 0600 notwithstanding, since the server reads it as its owner; each declared source root; each registry entry's root, which includes the bootstrapped session worktrees; and on a loopback plane the sessions container, where every later session worktree is made. Cases: a state directory under the static bundle and under the sessions container is refused and nothing is written; the reported set is asserted. - **`r4173806552`, a removal race.** `remove_private_copy` takes the name with an atomic rename to a name only this process uses, then judges what it took. Its own file is removed; anything else is linked back under the name, never over a still newer copy. Both entry points also remove the copy BEFORE closing the listening socket. Case: a replacement written at the instant of removal survives whole. - **`r4173806590`, SIGTERM.** While a copy exists, `python -m opendox.serve` and `generate-and-open` (local and hosted) read SIGTERM as Ctrl-C (`console_access.terminate_as_interrupt`) and restore the previous handler afterwards. A plane that wrote no copy keeps SIGTERM's default, so the governed and hosted images are unchanged. Cases: the context manager; a plain kill of each of the three entry points exits 0 with the copy gone. - **`r4173806621`, the shutdown assertions.** They now signal and wait (`_stop`), assert while the state directory still exists, and leave cleanup to the case's `finally`. ## Fix round 2 (`c979747a`) Copilot's review at `cb899546`, two threads. Each case failed first at `cb899546`, then passed: - **`r4173889265`, a served root holding `console/`.** The boundary judged the state directory only, so `--web-dir` equal to `<state>/console` (state directory outside every served root) let `GET /<port>.html` serve the copy. The copy's own resolved path is now judged as well: a served root that holds it refuses the write by name. Case: `test_a_served_root_equal_to_the_console_directory_is_refused` (it failed first: DID NOT RAISE). - **`r4173889294`, an outward link in the bundle.** The static handler follows links inside `--web-dir`, so `web/state-alias -> <state>` served the copy. Links are not refused wholesale: openxFactory's `scripts/ideation-dashboard-serve.py` composes its web root from links out of the bundle (`_composed_web_root`), and confining to the resolved root would 404 the governed bundle. Instead `publish` marks the private-copy directory (`httpd.private_roots`), and `DashboardHandler.send_head` answers 404 for any static target whose resolved path is that directory or inside it, for GET and HEAD, files and listings, every port's copy included. A server with no copy marks nothing. Case: `test_a_static_link_out_of_the_bundle_never_serves_a_private_copy` (it failed first: GET answered 200). ## The reverse boundary (`36ff6103`) The holder's ruling, from batch N's Copilot review ([`r4174345203`](https://github.com/opensoft/openxFactory/pull/1222#discussion_r4174345203)). `_refuse_a_served_state_dir` checked one direction only: a state directory in a served root. A served root INSIDE the state directory would let the plane serve what the state directory keeps, the copy among it. Such a root could be `<state>/console` itself, the bundled PostgreSQL's `postgres/run`, or any deeper path. Fix round 2's copy-path check caught only the root that holds `console/`. - **The fix.** The two may not overlap in either direction. A state directory equal to a served root, inside one, or holding one is refused by name before any write. - **Case:** `test_a_served_root_inside_the_state_directory_is_refused`, for `console`, `postgres/run`, `anything/else/deep` and `.` (the state directory itself). It failed first (DID NOT RAISE). At `cb899546`, 3 cases failed: `console`, `postgres/run` and `anything/else/deep`. At `c979747a`, 2 failed, because fix round 2 already covered `console`. - **Pin:** `test_a_source_link_into_the_state_directory_never_serves_the_copy`. A link inside a declared source root that points at the state directory gets 404 from `/source`, which never follows a link out of its root. It passed before the change. It is pinned here and held by mutant M13. ## Fix round 3 (`219d7cf9`) Copilot's review at `60bace00`, two threads. Each case failed first at `60bace00`: - **`r4174674625`, a directory's index page.** For `/sub/`, the stdlib handler serves the first of `index_pages` that is a file, and `send_head` judged only the directory. So `web/sub/index.html`, linked to a copy, was served at `/sub/` while `/sub/index.html` answered 404. The index page the handler would pick is judged too. The case is `test_a_directory_index_linked_to_a_private_copy_is_never_served`, run for `index.html` and `index.htm`. It covers GET and HEAD of `/sub/`, `/sub/<index>` and `/sub/?x=1`, and checks that the bare `/sub` redirect carries nothing. A directory whose index page is the bundle's own still answers 200. Before the fix, GET `/sub/` answered 200. - **`r4174674702`, a FIFO.** `read_private_copy`'s read-only `open` of a FIFO with no writer blocked forever, before the descriptor check could refuse it. It opens with `O_NONBLOCK` now. The case is `test_a_fifo_at_the_copy_is_refused_without_blocking`, which runs the read and the write on threads with a bounded join, so a failure fails and never hangs. Before the fix, "the read blocked on a FIFO". ## 12.4a, clause by clause (`a13857ad`) openxFactory#1222 (T007 batch N) landed as `bdd0f586`, and its amended 12.4a is the normative text for T104. Every clause was checked against `d4b99436`. Batch N's gaps (c), the non-blocking reader, and (d), the automatic index file, closed in fix round 3. This commit closes the rest. Each case failed first at `d4b99436` unless it is marked as a pin. - **(a) "Replaced only when it is this user's own regular file of mode 0600 with one link."** The writer checked the type, the owner and the link count, but not the mode, so a loosened own copy was replaced. It now applies the reader's own rule, so a loosened copy is refused by name and left as it is. This also answers Copilot's `r4174785965` at `d4b99436`. - **"In a directory of mode 0700."** A `console/` loosened after it was made (0755, 0750, 0711) was accepted wherever no one else could write it. The writer and the reader now refuse it by name. A setgid bit inherited from a parent is accepted (a pin). - **(b) The writer's and the entry points' regressions.** One is another user's file at the name (a pin). The other runs through `generate-and-open` and through `python -m opendox.serve`: each of a symbolic link, a directory, a FIFO, a hard-linked copy and a loosened copy refuses the START by name. That means exit 1, nothing printed that serves, no browser, the planted thing untouched and the socket closed. The loosened copy failed first; the other kinds are pins. - **(e) A served root named through a symbolic link** that leads to the state directory or into it (`console`, `postgres/run`, the directory itself) is judged where it leads. Through the entry point, the case is a `--web-dir` link to `<state>/console` (pins, held by mutant M24). **The opener file's lifecycle, self-reviewed** (write, replace, read, remove at stop, refuse before writing). Each finding below has a case that failed first at `d4b99436`, and a mutant: - an operating-system refusal on the way, such as a parent that will not let this user make the state directory or a full disk, escaped as a raw `OSError` with a traceback and no refusal by name. It is now a `ConsoleAccessRefused` naming the copy, through both entry points; - a write that fails part way removes its temporary file; - a copy whose read-back fails is removed with the refusal; - removal put another serve's copy back by a hard link only. Where links fail (EPERM: a filesystem without them, or a directory), that copy was deleted. It is now renamed back where the name is free; - SIGHUP, which a closed terminal sends, ended the process with the copy left behind. While a copy exists it is read as Ctrl-C, as SIGTERM is, unless the process was started ignoring it (`nohup`); - the signal handling covered only the serve loop. A SIGTERM while the browser opener ran, which can take seconds, took SIGTERM's default action on a hosted standalone plane and left the copy behind. It now covers the whole window from the write to the stop, in both entry points. ## One walk of the state directory (fix round 4, `14285fbb`) Copilot's review at `d4b99436`, `r4174785933`. Copilot's probe reproduced here. With `OPENDOX_STATE_DIR=alias/state`, `alias -> shared/hop` and `hop -> private`, the tree rules judged the configured path's components and the directories above the resolved path. `shared`, reached only through a link's target, was neither, so at mode 0777 and not sticky it went unjudged. The write also walked the configured path again after the served-root check. So a `hop` re-pointed in between put the token's copy in a served root, at `served/state/console/8080.html`, where `/source` serves it. `_walked` resolves the state directory once, component by component as the kernel walks it. Every directory it passes through is judged by the rule for directories above the state directory, and every link it follows by the rule for a link. The served-root boundary, the tree rules, the write and the read all work on the walked path. Both cases failed first at `182cac76`: - `test_a_directory_passed_through_by_an_intermediate_link_is_judged`: Copilot's layout with `shared` at 0777. The write and the read are both refused by name. Before the fix: DID NOT RAISE; - `test_a_link_swapped_after_the_checks_never_redirects_the_write`: `hop` is swapped to a served root right after the served-root check. The copy lands where the checks saw the state directory, and the served root gains nothing. Before the fix, the copy landed in the served root. ## Fix round 5 (`9f328892`) Copilot's review at `182cac76`, three threads. Each case failed first at `ddb26c34`: - **`r4175213798`, the snapshot files.** `/snapshot.json` reads its file directly, not through the static handler. So a `--snapshot` named at an earlier copy, `<state>/console/<port>.html`, would have been replaced by the new copy and served to anyone. The served roots now hold the configured snapshot, each registered entry's snapshot, and, on a loopback plane, the session snapshots' container. The reverse boundary therefore refuses that start by name, through a link as well. Cases: - `test_a_snapshot_inside_the_state_directory_refuses_the_start`, which before the fix reported "the server served"; - `test_the_plane_reports_its_snapshot_files_as_served_roots`. - **`r4175213842`, the rename-back race.** Where a hard link fails, another serve's copy is renamed back where the name is free. A newer copy published between that check and the rename was overwritten. Every writer and remover of `console/` now takes the directory's exclusive `flock`, so publication and removal are serialized. The case is `test_a_copy_published_during_a_rename_back_is_never_overwritten`: a third copy is published at exactly that moment, waits, and stands. Before the fix, "an older copy overwrote the newest". - **`r4175213864`, a stop during publication.** SIGTERM was read as Ctrl-C only after `publish()` returned. Both entry points now install the handler BEFORE publication, on any plane that writes a copy, and keep it through removal. A stop that arrives while the copy is written or removed is held (`deferred_termination`): publication raises it once the copy is in hand, and removal lets it go. Cases: - `test_a_stop_during_publication_removes_the_copy`, for both entry points; - `test_a_stop_during_removal_lets_the_removal_finish`; - `test_deferred_termination_holds_a_stop_until_the_block_ends`. Copilot's review at `ddb26c34` raised the same stop for `generate-and-open` (`r4177975845`), and `9f328892` answers it. ## Fix round 6 (`9dcc9bb5`) Copilot's review at `ddb26c34`, `r4177975898`. The walk and the served-root check ran outside the writer's conversion of `OSError`. So an overlong state-path component (ENAMETOOLONG) or an unsearchable parent (EACCES) escaped as a raw `OSError`, and both entry points ended in a traceback instead of a named refusal. Both are inside the conversion now, and the reader converts the same way. The cases failed first at `9f328892`: - `test_a_state_path_the_walk_cannot_take_is_refused_by_name`, for both kinds of path, through the writer and the reader; - `test_a_state_path_the_walk_cannot_take_refuses_the_start`, for both kinds through `serve` and through `generate-and-open`. Each exits 1 with the refusal named, prints nothing that serves, and closes the socket. ## Fix round 7 (`fb8a1cc4`) Copilot's review at `9f328892`, `r4178041022`. SIGINT kept Python's immediate handler, so `deferred_termination` never held it. A Ctrl-C just after publication's rename raised before the caller held the copy, and the copy was left behind. A second Ctrl-C just after a removal's take left a `.removing-*` file holding the token. `terminate_as_interrupt` now takes SIGINT too, still raised as a `KeyboardInterrupt`, but only where it has Python's own handler; an ignored SIGINT, or a host's own handler, is left as it was. The cases failed first at `9dcc9bb5`. Each one checks for the handler before it sends SIGINT, so a raw interrupt never aborts the test session: - `test_ctrl_c_just_after_the_copys_rename_leaves_no_copy`, for both entry points; - `test_a_second_ctrl_c_after_the_removal_rename_leaves_nothing`; - `test_terminate_as_interrupt_takes_ctrl_c_only_from_its_default`. ## Fix round 8 (`66cffdb8`) Copilot's review at `fb8a1cc4`, two threads. Each case failed first at `fb8a1cc4`: - **`r4178133814`, two consoles on one port number.** The copy's name is per port, so a console on 127.0.0.1 and one on ::1, sharing a state directory, replaced each other's copy, and the first console's printed path opened the second. The writer now takes an exclusive `flock` on the file it wrote, before the rename, on its own descriptor (`_Reservation`, held in `PrivateCopy.reservation`), and keeps it until the copy is removed. A later publication on that port asks for the lock without waiting. A held lock is a running console's, refused by name (`… belongs to a console that is still running (pid N)`), and its copy is left as it was. A free lock is a stale copy's, and it is replaced. Cases: - `test_two_consoles_on_one_port_number_never_share_a_copy`, with real IPv4 and IPv6 planes (failed first: DID NOT RAISE); - `test_a_running_consoles_copy_is_never_replaced` (failed first: DID NOT RAISE); - `test_a_copy_whose_console_died_is_replaced`, a pin: a subprocess writes a copy and exits, and the kernel releases its lock. - **`r4178133842`, a root retargeted after publication.** `/source` resolves a declared root again on every request, so a root re-pointed at the state directory after publication served the copy. Every read that serves a file to any caller without the console check now judges the file it OPENED, by `(st_dev, st_ino)`, against every name in the copies' directory (`console_access.is_private_file`). `/source` and `/snapshot.json` read through `serve.read_unless_private`. The static handler judges the file it would serve before the stdlib opens it, and judges what the stdlib opened; a file swapped in between is closed unsent and the connection closed. The identity also stops a hard link to the copy, which a path cannot tell apart. Cases, each answered 200 with the copy before the fix: - `test_a_source_root_retargeted_after_publication_never_serves_the_copy`, Copilot's layout; - `test_a_snapshot_retargeted_after_publication_never_serves_the_copy`; - `test_a_hard_link_to_the_copy_is_never_served`, for the static bundle and the served checkout; - `test_the_static_backstop_never_sends_a_copy_swapped_in_after_the_check`, which blinds the first check and reads the raw response: the copy's bytes are never sent. ## The adversarial review (fix round 9, `45958bf3`) The holder had an independent adversarial review of `fb8a1cc4` and of round 8 run ahead of Copilot: 1 high, 3 medium and 6 low findings. It confirmed that round 8 answers both of Copilot's threads, and it found what follows. The reviewer's own cases are kept as written, under their ids (`test_b1_…`, `test_b2_…`, `test_b3_…`, `test_b6a_…` to `test_b6c_…`). The cases that pin B1, B2, B3, B4, B5, B8 and B9 failed first at the merged pre-fix tree. - **B1 (high), a tokenless sibling plane.** A standalone plane that minted no token (no git identity, so no session verbs) wrote no copy, so it asked no boundary and marked no private root. A root of its that held the shared state directory served a sibling plane's copy, token and all, to any local caller. The delivery is now the plane's, token or not (`build_server`), and `publish` on every standalone plane asks the boundary and marks the copies' directory private (`console_access.guard_private_roots`). Only the writing still needs a token. Cases: the reviewer's two real planes, where the tokenless plane now refuses its start by name; the same refusal in the process; and a tokenless plane whose `--web-dir` links into the state directory and whose checkout holds a hard link to the sibling's copy, which answers 404 for the copy, the listing and `/source`. - **B2, a platform without the POSIX primitives.** On Windows the standalone start ended in an `AttributeError` traceback. `console_access.unsupported_platform()` names what is missing, and the writer and the reader refuse by name before anything else, as `bundle.unsupported_platform()` does for #69's bundle (holder's ruling). Cases: the reviewer's child with `os.getuid`, `O_NOFOLLOW` and `O_DIRECTORY` removed, which now exits 1 with `serve refused: …`; and the writer and the reader without `O_NOFOLLOW`, and without calls relative to a directory's descriptor. - **B3, a browser that cannot open the copy.** Ubuntu's default snap browser cannot read a file under a hidden directory such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. There was no way past it, because the token is never printed. RULED by Brett (2026-10-04, *"Hint line, accepted limit (Recommended)"*): beside the copy's path, the start prints ONE line with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again (`console_access.UNOPENABLE_HINT`). The case runs both entry points, finds the line once, right after the copy's path, and finds the token in no line printed. The README's side is openDox#17's (T076). See "Known limits" below. - **B4, a second spelling.** The served-root overlap and the static handler's guard compared spellings. On a case-insensitive filesystem (macOS's default), `<root>/STATE` is `<root>/state` and `/state-alias/CONSOLE/` lists `console/`, while resolving a path keeps the case it was given. Both checks now also compare the directories' `(st_dev, st_ino)`, the copies' directory's own included (`within_private_roots`). Linux cannot spell one directory two ways without root, so the cases simulate a case-insensitive filesystem by telling `os.stat` and `os.listdir` that the second spelling is the first: the boundary for the state directory, a root inside it and a root holding it, and the static listing. The reviewer's macOS reading is derived from the code, not observed on a Mac, and the same holds here. - **B5, a second stop.** A double Ctrl-C, or a SIGTERM and then a closing terminal's SIGHUP, could land its second signal after the first had unwound the serve loop and before the cleanup's hold. That second interrupt escaped the `finally` and left the copy. The first stop is now latched, and every later one is only recorded. Cases: the reviewer's child, which delivers the second stop at exactly that point and now exits 0 with no copy and no traceback; and the latch in the process. - **B6, three rules no case pinned.** The walk's link-owner check, the reader's re-judging of the tree, and the `fchmod` under a umask that strips owner write each had a mutant the suite let live. The reviewer's three cases kill them (M46, M47, M48). - **B7, browser history.** Accepted by the holder as a limit within the ruled design. See "Known limits" below. - **B8, `--no-serve`.** It opened a copy for a server it then closed, and deleted the copy on return. It now publishes, opens and prints no copy. The page's URL is still printed and opened, as before T104, and carries no token. The cases that read a copy now serve once and stop at a Ctrl-C (`stopped_once_serving`), and each refusal case asserts that it never served. - **B9, a copy left by a crash.** A SIGKILLed serve's copy stayed until a later serve took its port. A publication now sweeps, under the console directory's lock, every copy whose reservation is free, and every temporary or taken name that a dead writer or remover left. Nothing else is touched: not a running console's copy, not a loosened copy (which 12.4a refuses and never replaces), not a link, and no file of another name. Cases: the sweep's rules in the process, and the reviewer's SIGKILL across two real serves. - **B10, the body.** This body was rebuilt from the evidence at the head: the suite, every mutant, the browser and the governed run. Mutant run 16 at `fb8a1cc4` also left one mutant alive: M32, where the configured snapshot is not a served root. Every case named the snapshot at a copy that already existed, so the registered entry's own snapshot (M32b's line) refused it either way. `9fe57dff` adds a snapshot named at `<state>/console/<port>.html` before that copy exists, which only the configured snapshot's own served root refuses. Two follow-ups after round 9. `c4e6c01b` adds B3's hint line, once Brett had ruled it. `0539f8c0` answers mutant run 18 at `45958bf3`, whose one survivor was M36c: the removal never closed the descriptor that reserved the copy, and nothing asked about it. `test_a_removal_releases_the_copys_reservation` now asks that, after a removal and where the directory is gone already, no descriptor of this process is the copy's file. That case's first failure printed the copy's `repr`, and with it the token. So `opened_url` is kept out of `PrivateCopy`'s `repr`, and `test_a_copys_repr_never_carries_its_token` pins it. ## Fix round 10 (`af2a2efb`) Copilot's review at `0539f8c0`, two threads, both on round 9's tokenless guard. Each case failed first at `0539f8c0`: - **`r4179091592`, one walk for the tokenless plane.** The boundary check and the marking each resolved the configured state path for themselves. A link on that path re-pointed between the two left the boundary judging the real state directory and the marking naming a decoy, and an outward static link then served a sibling plane's copy. `guard_private_roots` now walks the state directory once (`_walked`), judging every directory and link on the way as the writer does, and the boundary and the marking both use that walk's path. Cases: - `test_a_tokenless_planes_state_link_retargeted_mid_guard_marks_the_real_directory`, Copilot's layout. The sibling's copy and the listing stay 404; - `test_a_tokenless_planes_unsafe_state_path_refuses_its_start`: a world-writable, non-sticky directory on the way refuses the tokenless start by name. - **`r4179091624`, the platform first.** A tokenless plane skipped the platform check, started, and marked a private root that its handlers then judged with the missing `O_NONBLOCK`. `publish` and the guard now refuse an unsupported platform by name before anything else, token or not. Cases: - `test_a_tokenless_plane_refuses_a_platform_without_the_primitives`, in the process; - `test_a_tokenless_start_without_the_posix_primitives_refuses_by_name`, the no-identity variant of B2's child. ## Fix round 11 (`ed6e4769`) Copilot's review at `af2a2efb`, three threads and one finding in the review's body. Each case failed first at `af2a2efb`: - **`r4179239380`, another state directory.** Two standalone planes of one user can have different `OPENDOX_STATE_DIR` values. The guard knew only its own plane's copies, so if plane A's web root linked to plane B's state directory, A served B's copy. `is_private_file` now first judges the file it was given by what that file holds. A regular file whose head carries a console record is a copy, wherever it lies (`_carries_a_console_record`); the head is read with `pread` from the descriptor already open. Case: `test_another_state_directorys_copy_is_never_served`, through a static link and through a hard link under `/source`. - **`r4179239411`, a removal mid-read.** A copy removed after a read opened it, and before the scan could stat its name, matched nothing in the directory. The open file still holds its record, so it is refused. Case: `test_a_copy_removed_during_the_scan_is_never_served`. - **`r4179239424`, a check that cannot be made.** These used to let the file through, and each now denies it: - a private directory that exists but cannot be listed (`EMFILE`, `EACCES`); - a name in it whose status cannot be read, for any reason but its removal; - a regular file whose head cannot be read. A private directory that does not exist still holds no copy. Cases: - `test_a_private_directory_that_cannot_be_scanned_denies_the_read`, with `EMFILE` simulated, and with `EACCES`; - `test_a_name_whose_status_cannot_be_read_denies_the_read`; - `test_a_file_whose_head_cannot_be_read_is_denied`. - **Previously missed, an entry's payload.** On a standalone plane, an entry with both an in-memory payload and a snapshot file served the file, and a 404 where the file was missing. That broke `SnapshotEntry.read_bytes`' payload-first contract. The payload comes first again, and only the file fallback is guarded. Case: `test_an_entrys_payload_comes_before_its_guarded_file`, with the file missing, present, and a private copy. ## Fix round 12 (`a2e36652`) Copilot's review at `1e114a19`, `r4179793524`. Round 11 recognized a copy in another state directory only by its whole record. So another plane's temporary file, part-written with the token in its meta refresh and no record yet, passed the static handler, `/source` and `/snapshot.json`. A file that grew after it was judged passed too, because the stdlib copies a static file to its end as it is when read. - **A marker first.** The writer now begins every copy with `COPY_MARKER`, an HTML comment, ahead of any byte of the token. A copy is written from its start, so any part of it that holds a byte of the token already holds the whole marker. `is_copy_bytes` recognizes a copy by that marker, or by its whole record. - **What is read is judged.** `read_unless_private` reads first, then judges what it read, as well as the file by its descriptor. - **What is sent is bounded and judged.** The static handler's `copyfile` sends at most the length the file had when it was judged. It reads the body's first bytes before sending anything and judges them, so a copy's bytes are never sent. Cases, each failing first at `1e114a19`: - `test_another_state_directorys_partial_copy_is_never_served`, Copilot's layout, through all three readers, GET and HEAD; - `test_a_file_that_grows_after_its_static_check_never_sends_a_token`; - `test_a_file_that_grows_after_its_read_check_never_returns_a_token`; - `test_a_copy_starts_with_its_marker_before_any_token_byte`; - `test_a_copy_replaced_after_its_read_is_never_returned`, which pins the read-first design. The identity match against this plane's own `console/` remains as defence in depth. `1e114a19` had pinned it with a part-written copy (mutant run 23 left M38 alive); with the marker such a copy is known by its bytes, so `test_a_file_in_the_copies_directory_is_refused_by_its_place` now holds a token-bearing file there that the marker cannot recognize. ## Fix round 13 (`c5fcdfa4`) Copilot's review at `a2e36652`, `r4180089809`. `urlsplit` reads `http://evil.example\@127.0.0.1:8080/index.html` as user information at `127.0.0.1`. A browser takes the backslash for a slash and navigates to `evil.example`, whose page could then read the token's fragment. The entry points build their own URLs, but `write_private_copy` and `opened_url` are public. `_refuse_page_url` now requires the authority to be exactly a loopback host, spelled as `serve.server_url` spells it, with an optional port of at most 65535. A backslash or a control character anywhere in the URL is refused. Cases: - `test_a_page_url_a_browser_reads_as_another_host_is_refused`, eight URLs, Copilot's first. Seven failed first at `a2e36652`; - `test_every_loopback_page_url_a_plane_announces_is_accepted`. ## No test server outlives its run (`d466c1d2`) The holder found orphaned `python -m opendox.serve` children of these cases on the machine, hours old. Each was plane B of the B1 case, left by a mutant run: where a mutant made B serve instead of refusing, the case failed, and its `finally` stopped plane A only. Every server child the cases start now runs in its own session, and is reaped with its process group at teardown, pass or fail: SIGTERM, a bounded wait, then SIGKILL. On Linux it also gets SIGTERM from the kernel if the test process dies first (`PR_SET_PDEATHSIG`), since a killed run runs no teardown. Cases: `test_a_server_left_running_is_reaped_with_its_group` and `test_a_server_outlives_no_killed_run`. Re-run under mutant M44, the B1 case fails as it must, and no server from that run survives. ## Known limits, accepted for release 1 After a serve restart, a tab opened against the old serve holds a stale token in its `sessionStorage`. The fixed "reload the page" messages (`doxbench-chat.js`, `staging-workbench-model.js`, T102's area) then name the wrong remedy on a standalone plane: a reload keeps the stale token. The remedy is the new tab the restart opened, or the new console file. The holder ACCEPTED this for release 1 and passes the copy change to T102's writer. **Some browsers cannot open the copy** (adversarial review B3). Ubuntu's default snap browser, and Flatpak browsers, are kept out of hidden directories such as `~/.local/state`, and a Windows browser under WSL may not open a Linux path. Brett ruled this an accepted limit for release 1, with a hint (*"Hint line, accepted limit (Recommended)"*, 2026-10-04): the start prints one line, with no token, saying to set `OPENDOX_STATE_DIR` to a folder that is not hidden and start again. The README's side is openDox#17's (T076). **The browser's persistent history keeps the fragment** (adversarial review B7). `history.replaceState` strips the token from the address bar and from the tab's session history, but the browser's own history store (Chromium's `Default/History`) has already recorded the opened URL, fragment included. The holder ruled this a limit within the ruled design: the history file is this same user's data, as the 0600 copy is, and it is not served or sent anywhere. ## Tests `tests/test_console_token_delivery.py` (new): - a standalone plane carries no token on `/capabilities`, under no key and in no byte; - a host's plane keeps it there and writes no copy; - **a second OS user cannot obtain it.** First, by asking: 102 GET and HEAD requests (51 paths: the whole bundle of 42 files, `/`, `/capabilities`, the snapshots, the project register, `/source`, the guarded reads without the token), and 8 POST routes. No body and no header carries it. Second, by the copy: the file is 0600 and the directories 0700, all this user's. A copy owned by another uid is refused by the reader (simulated through `getuid`); - the opened URL, the meta refresh and the link carry the token in the fragment only, with an empty query; a page URL that already has a query or a fragment, is not http, or is not loopback is refused; - the record cannot break out of its `<script>`: a hostile `</script><img …>` value stays inside, and the record round-trips; - `generate-and-open` hands the opener a `file://` path, prints the copy's path and never the token, and removes the copy; `--no-open` opens nothing and still prints the path. These cases serve once and stop at a Ctrl-C, since `--no-serve` writes no copy; - an unsafe state directory refuses the run, naming the directory; - the copy is born 0600 in a 0700 tree even under umask 0; - these are refused: a planted link, a dangling link (nothing is created at its target), a hard link, a loosened copy (0644), a planted directory, a linked `console/`, a group- or world-writable state directory, and a non-sticky shared parent (a sticky one is accepted); - a later copy replaces this user's earlier one, and removal is exact; - every guarded route refuses without the token, or with a wrong one, and passes the console check with the copy's token; - **the served-root boundary:** the state directory equal to the served root, under it, under a declared source root, through a link into it, and through `generate-and-open`. Each is refused by name, and nothing is written; - **the reverse boundary:** a served root that is `<state>/console`, `<state>/postgres/run`, a deeper path under the state directory, or the state directory itself. Each is refused by name, and nothing is written. Separately, a link in a source root that points at the state directory gets 404 from `/source`; - the plane reports its served roots: the checkout and each declared source root; - **fix round 3:** a directory's index page linked to a copy is never served, for either index name; a FIFO at the copy is refused at once by the reader and by the writer; - **12.4a:** the writer refuses a loosened own copy and another user's file, and leaves each as it was. Both entry points refuse their start by name for each of five planted kinds. A served root named through a link into the state directory is refused. A `console/` that is not 0700 is refused, and a setgid one is accepted; - **the lifecycle:** an unwritable state directory and a full disk are refusals by name, and no partial file is left. A failed read-back leaves no copy. Another serve's copy survives a removal where hard links fail. SIGHUP removes the copy at all three entry points, and a `nohup` SIGHUP stays ignored. A SIGTERM while the browser opens removes the copy, hosted and local; - **one walk:** a directory passed through by an intermediate link is judged, and a link swapped after the checks never redirects the write; - **fix round 5:** a snapshot inside the state directory refuses the start, through a link too. The snapshot files are reported as served roots. A copy published during a rename-back is never overwritten. A stop during publication, at either entry point, or during removal leaves no copy; - **fix round 6:** an overlong or unsearchable state path is a refusal by name, for the writer, the reader and both entry points; - **fix round 7:** Ctrl-C right after the copy's rename, or right after a removal's take, leaves nothing behind. Ctrl-C is taken only from Python's own handler; - **fix round 8:** a running console's copy is never replaced, by a second publication or by a real IPv6 plane on the same port number, and a dead console's copy is. A source root or a snapshot retargeted after publication, a hard link to the copy in the bundle or the checkout, and a link swapped after the static check never serve the copy; - **fix round 9 (the adversarial review):** a tokenless standalone plane refuses a state directory inside its checkout, as a real second plane and in the process, and never serves a sibling's copy through a link or a hard link. A platform without the POSIX primitives is refused by name, through `serve` and by the writer and the reader. A second spelling of the state directory, of a root inside it, of a root holding it, or of `console/` is judged by its identity. Only the first stop is raised, so a second one never leaves a copy. The walk's link-owner rule, the reader's re-judging of the tree and the `fchmod` under umask 0o277 are pinned. `--no-serve` writes, opens and prints no copy. A dead console's copy, temporary file or taken name is swept, and nothing else is. A snapshot named at a copy not yet written refuses the start. Both entry points print the hint line once, right after the copy's path, and no line they print carries the token. A removal releases the copy's reservation, and a copy's `repr` never carries its token; - **fix round 10:** a tokenless plane whose state link is re-pointed mid-guard marks the real directory, and the sibling's copy stays 404. A tokenless plane refuses an unsafe state path, and a platform without the POSIX primitives, by name, in the process and as a user starts it; - **fix round 11:** another state directory's copy is never served, through a static link or a hard link. A copy removed mid-read is still refused. A private directory that cannot be listed, a name whose status cannot be read, and a head that cannot be read each deny. An entry's payload comes before its guarded file; - **fix round 12:** a copy begins with its marker, before any token byte. Another state directory's part-written copy is refused by the static handler, `/source` and `/snapshot.json`. A file that grows after its checks, or is emptied after its read, never hands out a token.; - **fix round 13:** a page URL whose authority a browser reads as another host (a backslash, user information, a bad port) is refused, and nothing is written; every loopback spelling a plane announces is accepted; - **no test server outlives its run:** a server left running is reaped with its process group, and a child outlives no killed run; - end to end, as a user runs it: `python -m opendox.cli generate-and-open --local --no-open` and `python -m opendox.serve`, each in a child process with neither sibling importable. `tests/test_console_token_view.py` (new, node): fragment taken, kept and stripped; a host's token wins; the degraded probes; a reload keeps the token from storage; the query string is never read; a malformed token is stripped and not kept; blocked storage keeps the token in memory. These standalone children read the token from the private copy (`standalone_child.Child.console_token`), because they used to read it from `/capabilities`: - `test_capability_honesty.py`, its 4 standalone cases and the unknown-tile-kind thread read; - `test_neutral_turn_scope.py`; - `test_doxbench_defaults.py`; - `test_chat_model_configuration.py`'s standalone fixture; - T103's `test_loopback_host_gate.py` real local serve, which now asserts that no token is on `/capabilities` and that the copy exists exactly when a token is minted. **Mutants, 90 of 90 killed** (each applied alone, both new test files run, in a throwaway worktree of `c5fcdfa4`): | mutant | failed | |---|---| | M1 token back in /capabilities | 4 | | M2 query string instead of fragment (server) | 6 | | M2b page reads the query string instead of the fragment | 4 | | M3 the file at 0644 (one constant) | 8 | | M3b the file written 0644, reader unchanged | 89 | | M4 no link check at all | 18 | | M4b no planted-target check on write | 15 | | M4c the read follows a link | 1 | | M5 the page never strips the fragment | 3 | | M6 no served-root boundary | 19 | | M6b the boundary checks equality only | 8 | | M6c the plane reports no served root | 9 | | M7 removal puts no replacement back | 3 | | M8 a plain kill is not read as Ctrl-C | its own SIGTERM ended the run (rc -15), after 5 failures | | M9 the static bundle is not a served root | 3 | | M9b the sessions container is not a served root | 2 | | M11 the static handler serves a private copy | 4 | | M12 no reverse check (a served root inside the state dir) | 9 | | M13 /source follows a link out of its root | 1 | | M14 a directory request serves its index page unjudged | 2 | | M15 the read blocks on a FIFO | 1 | | M16 the writer replaces a loosened own copy (12.4a) | 3 | | M17 the console directory's mode is not judged (12.4a) | 3 | | M17b the setgid bit counts as a loosened mode | 1 | | M18 an operating-system refusal escapes raw | 8 | | M19 a failed read-back leaves the copy | 1 | | M20 no rename-back where a hard link fails | 2 | | M21 a hangup is not read as Ctrl-C | 4 | | M21b nohup's ignored hangup is overridden | 1 | | M22 cli: the handler covers only the serve loop | 3 | | M23 a partial temporary file is left | 1 | | M24 a served root is not judged where its link leads | 2 | | M25 the walk judges no directory it passes through | 2 | | M26 the write is not anchored to the walk | 1 | | M30 serve: the handler is installed only after publication | 5 | | M31 a stop is never held | 1 | | M32 the snapshot file is not a served root | 1 | | M32b a registered entry's snapshot is not a served root | 1 | | M32c the session snapshots' container is not a served root | 1 | | M33 a removal takes no lock | 1 | | M33b a publication takes no lock | 1 | | M34 the walk runs outside the writer's conversion of OS errors | 6 | | M34b the reader converts no OS error | 2 | | M35 Ctrl-C is not held | 5 | | M35b a host's own Ctrl-C handler is overridden | 1 | | M36 a running console's copy is replaced | 2 | | M36b the copy is never reserved | 3 | | M36c removal keeps the reservation | 1 | | M36d removal keeps the reservation where nothing is left to remove | 1 | | M37 /source reads unguarded | 5 | | M37b a registered snapshot reads unguarded | 3 | | M37c the static handler judges no identity before it opens | 4 | | M37d the static backstop sends what the stdlib opened | 1 | | M38 is_private_file matches no identity | 1 | | M39 the boundary ignores identity (B4) | 3 | | M40 the static guard ignores identity (B4) | 1 | | M41 the first stop is not latched (B5) | 2 | | M41b a held stop is raised after the first (B5) | 1 | | M42 no sweep (B9) | 2 | | M42b the sweep ignores reservations (B9) | 1 | | M42c the sweep removes what is not this user's own copy (B9) | 1 | | M43 --no-serve publishes (B8) | 1 | | M44 a tokenless plane guards nothing (B1) | 5 | | M44b the delivery depends on the token (B1) | 7 | | M44c a tokenless plane marks no private root (B1) | 2 | | M44d a tokenless plane asks no boundary (B1) | 3 | | M45 the writer refuses no platform (B2) | 1 | | M45b the reader refuses no platform (B2) | 2 | | M46 (reviewer m2) the walk never judges a link's owner (B6a) | 1 | | M47 (reviewer MA) the reader never re-judges the tree (B6b) | 1 | | M48 (reviewer m1) no fchmod (B6c) | 1 | | M49 (reviewer MC) the page keeps the token in localStorage | 2 | | M50 generate-and-open prints no hint line (B3) | 1 | | M50b serve prints no hint line (B3) | 1 | | M51 a copy's repr carries its token | 1 | | M52 the tokenless guard resolves the state path again to mark it | 1 | | M52b the tokenless guard does not walk | 1 | | M53 a tokenless plane skips the platform check | 2 | | M54 a copy is not known by what it holds | 3 | | M54b a head that cannot be read is allowed | 1 | | M55 a directory that cannot be listed allows | 2 | | M55b a name whose status cannot be read allows | 1 | | M56 an entry's file comes before its payload | 3 | | M57 a copy is written without its marker first | 4 | | M57b a copy's marker is not recognized | 4 | | M58 the reader judges none of the bytes it read | 1 | | M58b the reader judges the file before it reads it | 2 | | M59 the static body is copied as the stdlib copies it | 1 | | M61 the page URL's authority is not judged exactly | 4 | | M61b a backslash or a control character passes | 2 | M10 (the copy's own path not judged) is retired with the check it mutated: the reverse rule replaced that check, and M12 is the mutant that removes the reverse rule. **The browser, end to end.** Chromium (Playwright 1.61.0, the T096 prep harness's driver) ran `opendox generate-and-open --local --no-open` from a fresh install. The first run was on a LOCAL, never-pushed integration of this branch with #77 and `main`. It was re-run at `60bace00`, at `182cac76` and at `c5fcdfa4`, which carry both. 18 of 18 checks passed every time: - the private copy (0600) forwards to `/index.html`; - the address bar and the tab's session-history entry carry no fragment. The browser's persistent history still records the opened URL; see "Known limits" above; - `sessionStorage` holds the token, and `probeCapabilities` fills it; - the page's own `/capabilities` has none; - the catalog answers 200 with the token and `console_required` without it; - a reload keeps the token, and a new tab at the bare URL has none; - no request URL or `Referer` carries the token, and there was zero `pageerror`; - nothing the server printed carries the token; - the copy is gone after SIGTERM, and no bundled PostgreSQL is left. **The whole suite, locally** (`tests` and `tests_runtime`, with `--basetemp` and `TMPDIR` outside the tree): | commit | passed | skipped | |---|---|---| | `6db50b03` | 3415 | 177 | | `cb899546` | 3422 | 177 | | `c979747a` | 3424 | 177 | | `60bace00` | 3774 | 177 | | `d4b99436` | 3821 | 177 | | `a13857ad` | 3851 | 177 | | `182cac76` | 3858 | 177 | | `14285fbb` | 3860 | 177 | | `ddb26c34` | 3894 | 177 | | `9f328892` | 3901 | 177 | | `9dcc9bb5` | 3907 | 177 | | `fb8a1cc4` | 3911 | 177 | | `0539f8c0` | 4078 | 177 | | `af2a2efb` | 4082 | 177 | | `ed6e4769` | 4091 | 177 | | `d466c1d2` | 4093 | 177 | | `1e114a19` | 4094 | 177 | | `a2e36652` | 4099 | 177 | | `c5fcdfa4`, the head | 4111 | 177 | At the head, 0 failed. The count grew with the merges: #80's final head carried main's landings since `d0f1efcb` (#63, #69, #73, #64, #72 and #77), and then #81, #85, #76 and #82 landed. CI's own reading at `c5fcdfa4` is `selected=4288 passed=4277 skipped=11`, against `validate.yml`'s floors 3977 / 3966 and its exact 11. ## The governed path openxFactory's existing token-reading suites were run twice, locally only. First against the pinned openDox-code `047bb4fa`, then against `047bb4fa` with T104's commits applied. The last such run applied every server-side change through `c5fcdfa4`, at local `f18e3342`. That is the head's whole server side, rounds 8 to 13 included. `cli.py`'s hunks are left out there: the governed host serves through `opendox.serve`, and the pin's `cli.py` predates T084's refactor. The suites are `tests/ideation-dashboard/test_doxbench_routes.py`, `test_gate_routes.py`, `test_shared_identity.py` and `test_staging_seed.py`, and they read `caps["console_token"]` through `_console_token`. The result is identical: **239 passed, 1 failed at both**. The one failure is the same pre-existing case (`test_lens_add_as_cluster_lands_manifest_pending_and_record`, 409). Nothing in openxFactory changes, so there is nothing for T094. **For T086 (openXdox-code), recorded with the holder.** openXdox-code `6a3b93b9`'s route harnesses (`tests/doxbench_routes_harness.py:290`, `tests/gate_routes_harness.py:139-149`) build the server with no host profile registered, so by this PR's rule their plane is standalone, and they read `caps["console_token"]`. Composed with openxFactory's `scripts/` for `doc_health`, 90 cases there fail with `KeyError: 'console_token'`. All 90 are in 6 files that its `tests/declared_exclusion.yaml` already excludes (`doc_health`), so its CI does not run them. When its openDox pin passes this PR, those harnesses read `httpd.console_token`, or register openXdox's profile as a host. ## Batch N, and T095 T007's batch N landed in openxFactory#1222 (`bdd0f586`): #1144 12.4a's amendment is the normative text this PR realizes (see "12.4a, clause by clause" above). F10.1, F13.1, F16.1 and F12.x are unaffected. Plan 034's quickstart § 3 and § 4 step 1, and AT-R1 step 4, read the private copy. T095's harness (openDox-code#75, not edited here) reads the private copy at `<state_dir>/console/<port>.html` instead of `/capabilities`. The holder has its proposed helper and the three line changes. The helper never opens a copy that failed its checks, so a FIFO cannot block it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>



Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
Arc: neutral-product-standalone-operability
Plan 034 (
specs/034-opendox-standalone-operation/): a T102 follow-on, under no task of its own. T102 (#81) landed as0116293a, so its After line is met. Claimed on openxFactory#656 in5973692022. DRAFT. The holder posts READY, and the landers merge.The finding, F1 of the holder's T096 dry run:
The ruling,
5973854291on openxFactory#656:Option (ii), declaring the 403 in T096's oracle, was rejected.
The change
src/opendox/web/app.jsgainsdoxbenchThreadSeam(session, consoleTokenOf, injectedFetch), beside the thread loader it chooses. The doxBench bundle'sthreadis nowdoxbenchThreadSeam(workbenchGate.session, () => caps?.console_token). That is the sameworkbenchGate.sessionwhose presence picks the Save transport overrefusalTransport().createDoxBenchThreadLoader, unchanged: the same route, query, header, and reading of a refusal.nulland sends nothing.Why not an absent seam. The ruling's words, "wires its thread read only when…", could be read as leaving
threadoff the bundle. That would regress the rail.switchThread(views/doxbench-chat.js) reads an absentthreadas the pre-§11 rail, which returns early and keeps the previous document's transcript across a switch. Carrying one document's turns into another's next request is the defect the switch exists to close. So a plane with no session column still gets a seam, and it answers "no readable thread" itself. Itsnullis the answer the 403 produced, so the rail still adopts the empty transcript, without the request or its console error. No file other thanapp.jschanges in the product.Tests
tests/test_thread_read_by_session.py(new), 7 cases:app.js's own text. The module cannot be imported whole, because it boots the page at its last line. The harness takes the loader, the seam and the two constants they read, and runs them with an injected fetch.nullorundefined): a function that answersnulland sends nothing./workbench/thread?repository=fixture&ref=main&tile_kind=cluster&tile_id=g1&document=b.md, with the console-token header and the thread answered. A 403 still reads asnull.mount,expand, the DOM instrument) up to its first scenario. A document is loaded through its docs tile, then the rail's loaded-document selector (select.doxchat-loaded) is switched to every other entry once.editing by scope): each switch asks the seam, and no switch sends a thread request.app.jscomposes the seam fromworkbenchGate.session, the bare loader is no longer wired directly, and the rail still adopts the empty transcript on anullanswer.tests/test_workbench_edit_by_scope.py: the shell harness'smounttakes an optionalthread, and_stagecan write extra modules beside the views. Both are additive, and the module's 44 cases are unchanged.tests/fixtures/web_boundary_census.yaml: theapp.jsrow is re-measured (1725 -> 1752) with a provenance sentence, and the totals are re-derived.Mutants, at
c7b2635e. Each is applied toapp.jsin the committed tree, and an anchor that is not found aborts the run. Then the new module,test_doxbench_thread_switch.pyandtest_workbench_edit_by_scope.pyrun. All four are killed, each by a named failure.c7b2635eexists because the second mutant first ERRORED the seam cases (the harness called thenull) rather than failing them. The harness now records a seam that is not a function, and the switch's counter hands no seam down as none.The whole suite, locally, with
LANG=C.UTF-8 python -m pytest -qand the basetemp under~/.local/state, run under nohup and polled in the foreground:c7b2635e:3776 passed, 177 skipped, 0 failed;9959f89a: the same.The skip count does not move, and
validate.ymlis not edited.AT-R1, the browser half
At the head, with the T096 prep harness (
run-pass.shandt096_drive.py, as for #81). The integration is the head itself, because main0116293ais its base. The venv was reinstalled from it (117 installed files compared, 0 differ). Pass a (plain-documents) and pass b (plain notes, no front matter) each passed all 15 checks, withFAILED CHECKS: [].model_capability_unavailable, and both editors are typed.pageerror, nothing undeclared, and no 5xx. The pill readsediting by scope.That harness does not switch the rail's document. So F1 is measured with the dry run's own browser half, which does.
F1, before and after, with the dry run's own browser half. These are
run-at-r1.shandat_r1_browser.pyfrom the holder's t096-dry evidence, byte-identical copies (sha256b4b654ba697a…andb719aa17101d…), run withHALVES=browser. That browser half reads the console token from T104's private copy, so it ran on #84's head. Its driver switchesselect.doxchat-loadedthrough every held buffer, and does not declare the thread 403.d4b99436: #84's head, main without this fixd4b9943678816a4e:d4b99436plus this PR's four files, nothing else78816a4eSo, on a standalone plane, the browser logs 0 console errors on a switch.
Not in this PR
views/doxbench-chat.js, and this PR does not.78816a4ewith one conflict: the census's class-A total. It was re-derived. Whichever of the two lands second re-derives it the same way.Lane: openxfactory-4 (openXfactory-4-openDox_extraction)
🤖 Generated with Claude Code
Summary by Sourcery
Gate chat-rail thread reads on the presence of a branch session while retaining transcript clearing on standalone document switches.
Bug Fixes:
Enhancements:
Tests: