From e0298cf4b8fd5e9162265aa1c3a7c51263117f56 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:22:04 +0000 Subject: [PATCH 01/10] T085: openDox's own defaults for the two doxBench seams, and the workbench rules move into opendox.validator (plan 034) The standalone defaults for T027's two seams (R1Q10 (a), R1Q12 (a); openxFactory#656 comment 5850003126), in R1Q3 (a)'s pattern: - New src/opendox/doxbench_defaults.py. - The validators default is opendox.validator.doxbench_validators(): openDox's own validator over its packaged copies of xfactory-workbench-chat-turn and xfactory-workbench-model-catalog (T057), one validator per doxBench wire kind (DOXBENCH_KINDS). - The rail default is NO_STATUS_EXEMPTION: no source is exempt. It reports a document's own Status: line, raw, so the packet's label stays true. - register_defaults() registers both. - serve_wire and doxbench_packet gain register_default_*() and the projection seams' rules: - with nothing registered, each still refuses, naming the seam (4.2); - a host replaces a default until the default is read, and is refused after (R1Q3 (ii); RN-1 (a)); - a host registered first is kept. - The four entry points call doxbench_defaults.register_defaults(), beside T055's projection_seams.register_defaults(): cli.build_parser(), cli.main(), serve.build_server() and serve.main(). On the holder's ruling, these lines come BEFORE T084's in cli.py's and serve.py's single-writer order. T058's two workbench-manifest rules move from default_projection's WORKBENCH_RULES into opendox.validator (RULED 5920216845, item 2, "Move into openDox's validator (Recommended)"): - They are OWNED_RULES of the ideation-workbench copy, which carries no x-rules catalog, named pinned-keywords-are-checked and new-candidates-are-disjoint. - There are no aliases. default_projection checks neither. - A copy that carries a catalog cannot also own rules. The served model catalog now answers a standalone process. Measured at main 047bb4fa, GET /workbench/model-catalog answered 500 catalog_unavailable, and it now answers 200 with an envelope openDox's validator accepts. What the catalog offers (omp-local, declared available) is 16.4's, and T081's to change. Tests: - tests/test_doxbench_defaults.py is new, with 42 cases. - tests/test_post_render_validator.py has the new ids, 4 new cases, and the old ids only as RETIRED_RULE_IDS. - tests/test_doxbench_seams.py's fixture now restores a seam's records exactly. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/cli.py | 10 + src/opendox/contracts/__init__.py | 3 +- src/opendox/default_projection.py | 97 +----- src/opendox/doxbench_defaults.py | 120 +++++++ src/opendox/doxbench_packet.py | 168 +++++++--- src/opendox/serve.py | 11 + src/opendox/serve_wire.py | 170 +++++++--- src/opendox/validator.py | 170 +++++++++- tests/test_doxbench_defaults.py | 487 ++++++++++++++++++++++++++++ tests/test_doxbench_seams.py | 41 ++- tests/test_post_render_validator.py | 105 +++++- 11 files changed, 1186 insertions(+), 196 deletions(-) create mode 100644 src/opendox/doxbench_defaults.py create mode 100644 tests/test_doxbench_defaults.py diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 8bd0fe08..b16e2beb 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -97,6 +97,10 @@ # register the defaults where no host has. `is_rfc3339_datetime` is not a seam: # it is the neutral contract's own date-time rule, and openDox owns it. from opendox import projection_seams # noqa: E402 +# openDox's own defaults for the two doxBench seams (plan 034 T085), which +# the entry points below register the same way. Importing it registers +# nothing. +from opendox import doxbench_defaults # noqa: E402 from opendox.rfc3339 import is_rfc3339_datetime # noqa: E402 # THE COMPOSITION POINT, BOUND AT LAST (§ 4.3; RULED ASK-2 option (2), @@ -1113,6 +1117,10 @@ def build_parser(*, subcommand_extensions: tuple = ()) -> argparse.ArgumentParse # only where no host has registered its own. Registering reads nothing, so # a host that registers after this parser is built still replaces them. projection_seams.register_defaults() + # AND openDox's OWN doxBench validators and status-exemption rail (4.3, + # T085; R1Q10 (a) and R1Q12 (a), the same pattern), each only where no + # host has registered its own, and replaceable by a host until read. + doxbench_defaults.register_defaults() parser = argparse.ArgumentParser(prog="ideation-dashboard", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sub = parser.add_subparsers(dest="command", required=True) @@ -1220,6 +1228,8 @@ def main(argv: list[str] | None = None, *, generator_seam.register_default(default_generator.GENERATOR) # AND openDox's own projection defaults (5.5, T055), the same way. projection_seams.register_defaults() + # AND openDox's own doxBench defaults (4.3, T085), the same way. + doxbench_defaults.register_defaults() args = build_parser( subcommand_extensions=subcommand_extensions).parse_args(argv) try: diff --git a/src/opendox/contracts/__init__.py b/src/opendox/contracts/__init__.py index 823e7a30..e114eb50 100644 --- a/src/opendox/contracts/__init__.py +++ b/src/opendox/contracts/__init__.py @@ -9,7 +9,8 @@ puts them on disk beside the validator and the assembly root remains their source of truth for editing"*. So a code-leg checkout with no assembly root around it still has them, and so does an install. `opendox.validator` reads -them from here, and T085's doxBench validators will read the same copies. +them from here, and so do its doxBench validators +(`opendox.validator.doxbench_validators`, the default T085 registers). WHAT IS HERE. diff --git a/src/opendox/default_projection.py b/src/opendox/default_projection.py index 0849baa6..47864995 100644 --- a/src/opendox/default_projection.py +++ b/src/opendox/default_projection.py @@ -63,14 +63,15 @@ is searched for, and no path can make the validator reachable or not. No subprocess runs, so there is no dependency remedy (`dependency_remedy` is None). -* THE WORKBENCH MANIFEST'S TWO VALIDATOR RULES. Its schema says of two rules - that it cannot state them, and leaves them to the validator. The consumer's - script checked them in single-file mode, and they are carried here, under - its identifiers, so routing `validate_manifest` to openDox's validator drops - neither (the holder's decision, 2026-09-28). `workbench-pinned-not-checked`: - every `recipe.pinned` keyword is also in `recipe.checked`. - `workbench-candidate-overlap`: no `recipe.new_candidates` document is - already a member or excluded. +* THE WORKBENCH MANIFEST'S TWO VALIDATOR RULES ARE THE VALIDATOR'S. Its + schema says of two rules that it cannot state them, and leaves them to the + validator. T058 carried them here, under the consumer script's + identifiers. Since plan 034's T085 (RULED `openxFactory#656` comment + `5920216845`, item 2, *"Move into openDox's validator (Recommended)"*), + `opendox.validator` owns them, as `pinned-keywords-are-checked` and + `new-candidates-are-disjoint`, and they reach this adapter's report + through `validator_for(kind).violations()` like every other rule of the + kind. This module checks neither. IMPORT WEIGHT. `opendox.generator_seam`, `opendox.projection_seams` and the standard library. So this module imports with no extra installed and no @@ -97,7 +98,7 @@ from opendox import generator_seam, projection_seams __all__ = ["CORPUS_ROOT", "CorpusRoot", "OWN_KINDS", "OwnValidator", - "NUMBER_RULE", "SYNTAX_RULE", "SnapshotNotWritable", "VALIDATORS", "WORKBENCH_RULES", + "NUMBER_RULE", "SYNTAX_RULE", "SnapshotNotWritable", "VALIDATORS", "WRITER", "Writer"] #: The workbench manifest's kind, `opendox.workbench.KIND`, restated because @@ -110,7 +111,8 @@ #: generate verb writes with openDox's own generator, and the workbench #: manifest `workbench.save()` validates. `opendox.validator` validates the #: doxBench wire kinds too, and they reach it through their own seam -#: (`serve_wire.register_doxbench_validators`, T085), not through this one. +#: (`serve_wire`'s doxBench-validators seam, where the entry points register +#: `opendox.doxbench_defaults`'s default, T085), not through this one. OWN_KINDS: tuple[str, ...] = (generator_seam.NEUTRAL_SNAPSHOT_KIND, WORKBENCH_KIND) #: How each own kind is written, and so how its document is read. @@ -129,11 +131,6 @@ #: (Copilot at openDox-code#68 c7768ed5, r4146428769). NUMBER_RULE = "document-number" -#: The workbench manifest's two validator rules, which its schema leaves to -#: the validator, under the identifiers the consumer's script gave them. -WORKBENCH_RULES: tuple[str, ...] = ("workbench-pinned-not-checked", - "workbench-candidate-overlap") - class CorpusRoot: """openDox's own corpus-root predicate: a git repository's root.""" @@ -346,48 +343,6 @@ def _refuse_repeated_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]: return document -def _names(value: Any) -> list[str]: - """A list's string entries, once each, in order. Anything else answers - none: its shape is the schema's to judge, and these rules only compare. - - Linear: the schema bounds none of the three lists, so membership is a - set's, and the list only keeps the order (Copilot at openDox-code#68 - 09cd1e8a, r4139734444).""" - if not isinstance(value, list): - return [] - names: list[str] = [] - seen: set[str] = set() - for item in value: - if isinstance(item, str) and item not in seen: - seen.add(item) - names.append(item) - return names - - -#: How many names a rule's detail quotes before it says how many more, and -#: how much of each name it quotes. -_QUOTED = 10 -_NAME_CHARS = 80 - - -def _quoted(names: list[str]) -> str: - """`names` as a detail quotes them: the first few, each cut to a readable - length, and a count of the rest, so one violation stays one readable line. - The schema bounds neither the lists nor their strings (Copilot at - openDox-code#68 21e4723f, r4139769791).""" - shown = repr([name if len(name) <= _NAME_CHARS else name[:_NAME_CHARS - 1] + "…" - for name in names[:_QUOTED]]) - return shown if len(names) <= _QUOTED else f"{shown[:-1]}, and {len(names) - _QUOTED} more]" - - -def _documents(entries: Any) -> set[str]: - """The `document` of each entry of a members or excluded list.""" - if not isinstance(entries, list): - return set() - return {entry["document"] for entry in entries - if isinstance(entry, dict) and isinstance(entry.get("document"), str)} - - class OwnValidator: """openDox's own validator for ONE of its kinds, behind the validator lookup's protocol (plan 034's T058; this module's docstring).""" @@ -434,32 +389,6 @@ def construct_float(loader, node): except yaml.YAMLError as exc: raise ValueError(" ".join(str(exc).split())) from exc - @staticmethod - def _workbench_rules(document: Any) -> list: - """The manifest's two validator rules (this module's docstring).""" - from opendox.validator import Violation - - if not isinstance(document, dict) or not isinstance(document.get("recipe"), dict): - return [] - recipe = document["recipe"] - found = [] - checked = set(_names(recipe.get("checked"))) - stray = [name for name in _names(recipe.get("pinned")) if name not in checked] - if stray: - found.append(Violation( - WORKBENCH_RULES[0], ("recipe", "pinned"), "workbench-rule", - f"pinned keyword(s) {_quoted(stray)} are not in checked: every pinned " - "keyword MUST also be checked")) - placed = _documents(document.get("members")) | _documents(document.get("excluded")) - overlap = [name for name in _names(recipe.get("new_candidates")) if name in placed] - if overlap: - found.append(Violation( - WORKBENCH_RULES[1], ("recipe", "new_candidates"), "workbench-rule", - f"new_candidates {_quoted(overlap)} already appear in members or " - "excluded: a new candidate is a document the set has not " - "placed yet")) - return found - def validate(self, path: Path | str, *, strict: bool = False, search_from: tuple = ()) -> projection_seams.ValidationResult: """Validate the document at `path` as this validator's kind. `strict` @@ -505,8 +434,6 @@ def validate(self, path: Path | str, *, strict: bool = False, f"{' '.join(str(exc).split()) or type(exc).__name__}")] else: violations = kind_validator.violations(document) - if self.kind == WORKBENCH_KIND: - violations += self._workbench_rules(document) if not violations: return projection_seams.ValidationResult( True, 0, f"{self.kind}: 0 violations, by {ran}\n", "", ran) diff --git a/src/opendox/doxbench_defaults.py b/src/opendox/doxbench_defaults.py new file mode 100644 index 00000000..36546a61 --- /dev/null +++ b/src/opendox/doxbench_defaults.py @@ -0,0 +1,120 @@ +"""openDox's OWN defaults for the two doxBench seams: the schema validators and +the status-exemption rail (plan 034's T085; #1144's 4.3 as T007's batch G +amends it, and 16.4 in part; R1Q10 (a) and R1Q12 (a), `openxFactory#656` +comment `5850003126`). + +WHY THIS FILE EXISTS. T027 made two of the eight reaches into openxFactory +seams: `serve_wire.py:1369`, which imported openxFactory's doxBench contract +validators, and `doxbench_packet.py:177`, which imported its `Status:` reader. +Each refuses, naming itself, when nothing is registered, and openxFactory +registers its own (T046). Standalone, nothing did, so the served model catalog +answered `catalog_unavailable` and no context packet could be assembled. Batch +G's addendum to 4.3 names the standalone defaults: *"openDox's default +validators run over its spec leg's two chat schemas, +`xfactory-workbench-chat-turn` and `xfactory-workbench-model-catalog`, and +there is no status exemption by default."* This module holds them. + +THE ENTRY POINTS REGISTER THEM WHERE NO HOST HAS (R1Q3 (a), comment +`5817152735`). `cli.build_parser()`, `cli.main()`, `serve.build_server()` and +`serve.main()` call `register_defaults()`, beside their registrations of the +default profile, home corpus, generator and projection defaults. Each seam +keeps the projection seams' rules: + +* a process that runs none of them still refuses, naming the seam and its + call (4.2's discipline). A default is a registration an entry point makes, + never a fallback inside a seam; +* a host's registration made before the default has been read replaces it; +* after the default has been read, a host's registration is refused (R1Q3 + (ii); RN-1 (a)), so one process never verifies its model routes against two + factories, or marks its packets by two readers; +* the same registration again is a no-op. + +THE TWO DEFAULTS. + +* THE VALIDATORS: `opendox.validator.doxbench_validators`, openDox's own + validator over its packaged copies of the two doxBench schemas (T057, + R1Q12 (a)), one validator per wire kind. The seam calls it once per request, + and each call proves every copy against its recorded digest before it is + read, so a changed byte is refused on the request that reads it. +* THE RAIL: `NO_STATUS_EXEMPTION`. It marks NO source exempt from aggressive + compression, whatever its status says. openDox has no lifecycle vocabulary + of its own, and "exempt" is a word only such a vocabulary gives a status. + It still REPORTS a document's own `Status:` line, the raw value, as the + packet labels each source with it. Reporting none would label a document + that carries one as having "no Status: header", which would be false. It + reads the line the way openxFactory's reader does: the first `Status:` line + among the first `STATUS_SCAN_LINES` real lines, where only CR, LF and CRLF + end a line. + +IMPORT WEIGHT. The standard library only. `register_defaults()` imports the +two seam modules and `opendox.validator` when it is CALLED, so importing this +module registers nothing and names no sibling. + +A CREATED FILE: it has no row in openxFactory's +`docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES +openxFactory and never what a destination assembles (RULED OQ-C). +""" + +from __future__ import annotations + +import re + +__all__ = ["NO_STATUS_EXEMPTION", "NoStatusExemption", "register_defaults"] + +#: The real line separators: CR, LF and CRLF, and nothing else, so an exotic +#: separator (a form feed, U+2028) cannot move a `Status:` line into the +#: window or out of it. +_REAL_LINE_END = re.compile(r"\r\n|\r|\n") + + +class NoStatusExemption: + """openDox's own status-exemption rail: no source is exempt. + + It carries `doxbench_packet.STATUS_EXEMPTION_REQUIRED`, and the two + constants a reader of the rail's names may ask for on + `opendox.doxbench_packet`.""" + + #: The statuses whose content is exempt from aggressive compression: none. + EXEMPT_STATUSES: frozenset[str] = frozenset() + + #: How many of a document's first real lines are read for its `Status:`. + STATUS_SCAN_LINES = 15 + + _STATUS_RE = re.compile(r"^Status:\s*(.+?)\s*$") + + def lifecycle_status(self, text: str) -> str | None: + """The document's own declared `Status:`, raw, or None when it + declares none in its first `STATUS_SCAN_LINES` real lines.""" + if not isinstance(text, str): + return None + for line in _REAL_LINE_END.split(text)[:self.STATUS_SCAN_LINES]: + match = self._STATUS_RE.match(line) + if match: + return match.group(1) + return None + + def is_compression_exempt(self, text: str) -> bool: + """Never: there is no status exemption by default (batch G's addendum + to #1144's 4.3).""" + return False + + def __repr__(self) -> str: + return "" + + +#: The one rail an entry point registers. +NO_STATUS_EXEMPTION = NoStatusExemption() + + +def register_defaults() -> None: + """Register openDox's OWN default at each of the two doxBench seams, where + no host has registered one (R1Q10 (a), in R1Q3 (a)'s pattern). + + The entry points call this beside their other default registrations. It + registers nothing over a host, and reads nothing, so a host registration + made afterwards, and before any request reads a default, still replaces + it. Importing this module registers nothing.""" + from opendox import doxbench_packet, serve_wire, validator + + serve_wire.register_default_doxbench_validators(validator.doxbench_validators) + doxbench_packet.register_default_status_exemption(NO_STATUS_EXEMPTION) diff --git a/src/opendox/doxbench_packet.py b/src/opendox/doxbench_packet.py index a896dd58..94a25ced 100644 --- a/src/opendox/doxbench_packet.py +++ b/src/opendox/doxbench_packet.py @@ -36,8 +36,10 @@ ``Status:`` vocabulary is that corpus's, and it is what made this file un-carveable; the MARKING, which is generic, stayed here. The read is now the one a HOST registers at the status-exemption seam - (``register_status_exemption``), and with none registered the assembler - refuses rather than marking nothing. Nothing about the rail's position + (``register_status_exemption``), or openDox's own, which marks nothing + exempt and which the entry points register where no host has (plan + 034's T085). With none registered the assembler refuses rather than + marking nothing. Nothing about the rail's position in the pipeline changed: it still runs inside the assembler, before the packet exists. 4. **THE BOUNDS CHECK** — refuse with the MEASURED DIMENSION. No truncation, @@ -72,6 +74,7 @@ from __future__ import annotations import dataclasses +import threading import time import unicodedata from collections.abc import Callable, Mapping, Sequence @@ -156,6 +159,18 @@ class PacketExpired(PacketRejected): # (`StatusExemptionNotRegistered`, 4.2's discipline): a packet assembled with # its sources unmarked would carry the exemption silently switched off. # +# openDox's OWN DEFAULT IS NO EXEMPTION, REGISTERED BY AN ENTRY POINT (plan +# 034's T085; R1Q10 (a), `openxFactory#656` comment `5850003126`, in R1Q3 (a)'s +# pattern). `opendox.doxbench_defaults.NO_STATUS_EXEMPTION` marks no source +# exempt, and `cli.build_parser()`, `cli.main()`, `serve.build_server()` and +# `serve.main()` register it where no host has, through +# `opendox.doxbench_defaults.register_defaults()`, with +# `register_default_status_exemption(rail)`. A process that runs none of them +# still refuses, as above: the default is a registration and never a fallback +# here. A host's rail registered BEFORE the default has been read replaces it, +# and one registered AFTER is refused (R1Q3 (ii); RN-1 (a)), so one process +# never marks its packets by two readers. +# # WHY A MODULE `__getattr__` AND NOT A TOP-LEVEL RE-EXPORT. The names are read # off this module by existing callers, and a registration happens after this # module is imported, so nothing can bind them at import time. Resolved lazily, @@ -196,7 +211,11 @@ class PacketExpired(PacketRejected): STATUS_EXEMPTION_NOT_REGISTERED = ( "no status-exemption rail is registered at openDox's status-exemption " "seam (opendox.doxbench_packet), so no source can be marked and no packet " - "is assembled. A host registers its rail at process start with " + "is assembled. openDox's own rail, no exemption, is a registration an " + "ENTRY POINT makes where no host has " + "(opendox.doxbench_defaults.register_defaults(), which cli.build_parser(), " + "cli.main(), serve.build_server() and serve.main() call), and never a " + "fallback here. A host registers its rail at process start with " + STATUS_EXEMPTION_REGISTRATION_CALL + ".") @@ -217,11 +236,44 @@ class StatusExemptionAlreadyRegistered(RuntimeError): ONE registration: a process whose packets are marked by two readers, depending on which registration an assembly happened to reach, is the failure one registration exists to prevent. Registering the SAME rail - again is not refused. `unregister_status_exemption()` makes a deliberate - swap explicit.""" + again is not refused, and neither is a host's rail over openDox's own + default before anything has read it. `unregister_status_exemption()` + makes a deliberate swap explicit.""" _status_exemption_rail: object | None = None +#: Whether the rail is openDox's own default (an entry point's), and whether +#: an assembly has read that default since it was registered. The second +#: closes the window in which a host's rail replaces it. +_status_exemption_is_default = False +_status_exemption_default_read = False +_STATUS_EXEMPTION_LOCK = threading.Lock() + + +def _require_a_rail(rail: object, call: str, whose: str) -> None: + """Refuse, with `TypeError`, a rail that does not carry + `STATUS_EXEMPTION_REQUIRED` as callables, or cannot hand one over.""" + missing: list[str] = [] + probe_failure: Exception | None = None + for name in STATUS_EXEMPTION_REQUIRED: + try: + member = getattr(rail, name) + except Exception as exc: # noqa: BLE001 - a name it cannot hand over is a name it lacks + probe_failure = probe_failure or exc + member = None + if not callable(member): + missing.append(name) + if rail is None or missing: + refusal = TypeError( + f"{call} takes {whose} status-exemption " + "rail, which must carry callable " + f"{', '.join(STATUS_EXEMPTION_REQUIRED)}; " + f"{type(rail).__name__} lacks {', '.join(missing) or 'them'}. A " + "host with no rail does not register one: it leaves the seam " + "empty, and the assembler refuses, naming this call.") + if probe_failure is None: + raise refusal + raise refusal from probe_failure def register_status_exemption(rail: object) -> object: @@ -243,50 +295,82 @@ def register_status_exemption(rail: object) -> object: The SAME rail again is a no-op, and it is not probed a second time: it was validated when it was registered, and a lazy rail whose later lookup fails must not turn an idempotent host start into a refusal. Only a NEW - registration is validated.""" - global _status_exemption_rail + registration is validated. + + A different rail over a host's is refused. Over openDox's own default it + REPLACES the default until an assembly has read the default, and it is + refused once one has (R1Q3 (ii)).""" + global _status_exemption_rail, _status_exemption_is_default + global _status_exemption_default_read if rail is not None and rail is _status_exemption_rail: return rail - missing: list[str] = [] - probe_failure: Exception | None = None - for name in STATUS_EXEMPTION_REQUIRED: - try: - member = getattr(rail, name) - except Exception as exc: # noqa: BLE001 - a name it cannot hand over is a name it lacks - probe_failure = probe_failure or exc - member = None - if not callable(member): - missing.append(name) - if rail is None or missing: - refusal = TypeError( - "register_status_exemption() takes the host's status-exemption " - "rail, which must carry callable " - f"{', '.join(STATUS_EXEMPTION_REQUIRED)}; " - f"{type(rail).__name__} lacks {', '.join(missing) or 'them'}. A " - "host with no rail does not register one: it leaves the seam " - "empty, and the assembler refuses, naming this call.") - if probe_failure is None: - raise refusal - raise refusal from probe_failure - if _status_exemption_rail is not None and _status_exemption_rail is not rail: + _require_a_rail(rail, "register_status_exemption()", "the host's") + with _STATUS_EXEMPTION_LOCK: + held = _status_exemption_rail + if held is rail: + return rail + if held is None or (_status_exemption_is_default + and not _status_exemption_default_read): + _status_exemption_rail = rail + _status_exemption_is_default = False + _status_exemption_default_read = False + return rail + over_a_host = not _status_exemption_is_default + if over_a_host: raise StatusExemptionAlreadyRegistered( "a status-exemption rail is already registered at openDox's " "status-exemption seam, and a different one would replace it. " "Registration happens once, at process start. Call " "opendox.doxbench_packet.unregister_status_exemption() first if " "the swap is deliberate.") - _status_exemption_rail = rail - return rail + raise StatusExemptionAlreadyRegistered( + "openDox's own status-exemption rail (no exemption) is registered at " + "openDox's status-exemption seam, because an entry point registered it " + "where no host had, and an assembly has already read it, so the " + "host's rail cannot replace it now. A swap would leave one process " + "marking its packets by two readers, as a host's profile after a " + "build would (R1Q3 (ii), openxFactory#656 comment 5817152735; RN-1 " + "(a), comment 5850003126). Register the host's own at process start, " + "ahead of cli.build_parser(), cli.main(), serve.build_server() and " + "serve.main(). Call opendox.doxbench_packet.unregister_status_exemption() " + "first if the swap is deliberate.") + + +def register_default_status_exemption(rail: object) -> object: + """AN ENTRY POINT's registration of openDox's own default rail (R1Q10 (a)). + + Registers `rail` ONLY where nothing is registered, and leaves a host's + rail, or a default already registered, exactly as it is. Returns whatever + is registered afterwards. It is NOT for hosts. A default that could not + mark a source is refused whether or not anything is registered, because + it is openDox's own defect.""" + global _status_exemption_rail, _status_exemption_is_default + global _status_exemption_default_read + if rail is not None and rail is _status_exemption_rail: + return rail + _require_a_rail(rail, "register_default_status_exemption()", "openDox's own") + with _STATUS_EXEMPTION_LOCK: + if _status_exemption_rail is None: + _status_exemption_rail = rail + _status_exemption_is_default = True + _status_exemption_default_read = False + return _status_exemption_rail def unregister_status_exemption() -> None: - """Drop the registration. For test isolation and for a host tearing down.""" - global _status_exemption_rail - _status_exemption_rail = None + """Drop the registration, a host's or the default, and its records. For + test isolation and for a host tearing down.""" + global _status_exemption_rail, _status_exemption_is_default + global _status_exemption_default_read + with _STATUS_EXEMPTION_LOCK: + _status_exemption_rail = None + _status_exemption_is_default = False + _status_exemption_default_read = False def status_exemption_registered() -> bool: - """Is a rail registered, without resolving anything or refusing?""" + """Is a rail registered, a host's or the entry point's default, without + resolving anything or refusing?""" return _status_exemption_rail is not None @@ -298,8 +382,15 @@ def _status_exemption(): call, `__getattr__` once per name — so the dependence on the host's rail sits at one readable point, as it did when this function imported the publisher's module by name. + + Reading openDox's own default here is what closes its window: from this + call on, a host's rail over it is refused. """ - rail = _status_exemption_rail + global _status_exemption_default_read + with _STATUS_EXEMPTION_LOCK: + rail = _status_exemption_rail + if rail is not None and _status_exemption_is_default: + _status_exemption_default_read = True if rail is None: raise StatusExemptionNotRegistered(STATUS_EXEMPTION_NOT_REGISTERED) return rail @@ -1529,8 +1620,9 @@ def __getattr__(name: str): With no rail registered these names raise `AttributeError` naming the missing attribute and the seam — a loud failure at the first assembly rather than a packet quietly assembled with every source unmarked. - openDox's own neutral default is a later act (plan 034's T085), and until - then there is none to fall back to. + openDox's own neutral default, no exemption, is not a fallback here either: + it is a registration the entry points make where no host has (plan 034's + T085), and these names then answer as that rail's own objects. THE SEAM'S REFUSAL IS TRANSLATED, NOT LET THROUGH RAW. `_status_exemption()` refuses with `StatusExemptionNotRegistered`, a `PacketError`, which diff --git a/src/opendox/serve.py b/src/opendox/serve.py index c6420917..378244ad 100644 --- a/src/opendox/serve.py +++ b/src/opendox/serve.py @@ -151,6 +151,10 @@ from opendox import consumer_reach # noqa: E402 from opendox import defaults # noqa: E402 from opendox import projection_seams # noqa: E402 +# openDox's own defaults for the two doxBench seams (plan 034 T085), which +# `build_server()` and `main()` register where no host has. Importing it +# registers nothing. +from opendox import doxbench_defaults # noqa: E402 # § 3.4 slice S5: `build_server()` publishes the VIEW MANIFEST on # `/capabilities`, the one line slice S3 built both ends of and left for the # slice at which a contribution first exists to deliver. @@ -1801,6 +1805,11 @@ def build_server( # from the registered registry, which is what lets a server be BUILT with # nothing else installed (plan 034, research R7). projection_seams.register_defaults() + # AND openDox's OWN doxBench validators and status-exemption rail (4.3, + # T085; R1Q10 (a) and R1Q12 (a), the same pattern), each only where no host + # has registered its own. So the served model catalog answers standalone, + # validated by openDox's own validator over its packaged copies. + doxbench_defaults.register_defaults() from opendox import doxbench_turns # Imported HERE rather than at module scope, for the reason that is @@ -2313,6 +2322,8 @@ def main(argv: list[str] | None = None) -> int: # BEFORE the parser: its option defaults below read the registered # registry (`registry_mod.DEFAULT_REF` and the data source's defaults). projection_seams.register_defaults() + # AND openDox's own doxBench defaults (4.3, T085), the same way. + doxbench_defaults.register_defaults() parser = argparse.ArgumentParser(prog="ideation-dashboard-serve", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--web-dir", default=str(Path(__file__).resolve().parent / "web"), diff --git a/src/opendox/serve_wire.py b/src/opendox/serve_wire.py index b062a81f..4674de67 100644 --- a/src/opendox/serve_wire.py +++ b/src/opendox/serve_wire.py @@ -85,6 +85,7 @@ from __future__ import annotations import json +import threading from opendox import doxbench_knowledge from opendox import doxbench_packet @@ -1368,13 +1369,30 @@ def doxbench_turn_v2_success_body(*, client_turn_id: str, assistant_turn_id: str # (`opendox.domain_profile.register`). openDox names no implementation, so # nothing here imports the host's package. # -# REFUSAL, NOT A DEFAULT. With nothing registered the factory refuses, naming -# this seam and the registration call (`DoxbenchValidatorsNotRegistered`, -# 4.2's discipline), and the two model routes refuse with their fixed codes, -# because `_doxbench_validators` turns any factory failure into `None`. -# openDox's own validators for its own copies of the doxBench schemas are a -# later act (plan 034's T085); until then there is nothing for this seam to -# fall back to, and "no validator" is never an implicit pass. +# openDox's OWN DEFAULT, REGISTERED BY AN ENTRY POINT (plan 034's T085; R1Q10 +# (a), `openxFactory#656` comment `5850003126`, in R1Q3 (a)'s pattern, +# `5817152735`). openDox's own validators over its packaged copies of the two +# doxBench schemas (`opendox.validator.doxbench_validators`, R1Q12 (a)) are the +# standalone default. `cli.build_parser()`, `cli.main()`, +# `serve.build_server()` and `serve.main()` register it where no host has, +# through `opendox.doxbench_defaults.register_defaults()`, with +# `register_default_doxbench_validators(factory)`. The rules are the +# projection seams' (`opendox.projection_seams`) and the profile's: +# +# * a process that runs none of them still REFUSES, naming this seam and its +# registration call (`DoxbenchValidatorsNotRegistered`, 4.2's discipline): +# the default is a registration an entry point makes, never a fallback +# inside this seam; +# * a host's registration made BEFORE the default has been read replaces it; +# * AFTER a request has read the default, a host's registration is refused, +# as a host's profile after a build is (R1Q3 (ii); RN-1 (a)): one process +# would otherwise verify its model routes against two factories; +# * the same registration again is a no-op, and +# `unregister_doxbench_validators()` makes a deliberate swap explicit. +# +# With nothing registered the two model routes refuse with their fixed codes, +# because `_doxbench_validators` turns any factory failure into `None`, and +# "no validator" is never an implicit pass. #: The ONE call a host makes, quoted verbatim in the refusal so the refusal #: names its remedy rather than its symptom. @@ -1387,19 +1405,23 @@ def doxbench_turn_v2_success_body(*, client_turn_id: str, assistant_turn_id: str DOXBENCH_VALIDATORS_NOT_REGISTERED = ( "no doxBench schema validators are registered at openDox's " "doxBench-validators seam (opendox.serve_wire), so no model route can " - "verify what it would answer, and each refuses. A host registers its " - "validators factory at process start with " - + DOXBENCH_VALIDATORS_REGISTRATION_CALL + ".") + "verify what it would answer, and each refuses. openDox's own validators " + "are a registration an ENTRY POINT makes where no host has " + "(opendox.doxbench_defaults.register_defaults(), which cli.build_parser(), " + "cli.main(), serve.build_server() and serve.main() call), and never a " + "fallback here. A host registers its validators factory at process start " + "with " + DOXBENCH_VALIDATORS_REGISTRATION_CALL + ".") class DoxbenchValidatorsNotRegistered(RuntimeError): """The doxBench-validators seam has no registered factory. Raised by `default_doxbench_validators`, and so by every request that - reaches `build_server`'s default seam in a process no host registered. - `_doxbench_validators` swallows it into `None`, like every other factory - failure, so its text never reaches the wire: the routes answer their fixed - codes, and a direct caller reads the seam and the call it names.""" + reaches `build_server`'s default seam in a process where neither a host nor + an entry point registered one. `_doxbench_validators` swallows it into + `None`, like every other factory failure, so its text never reaches the + wire: the routes answer their fixed codes, and a direct caller reads the + seam and the call it names.""" class DoxbenchValidatorsAlreadyRegistered(RuntimeError): @@ -1408,11 +1430,27 @@ class DoxbenchValidatorsAlreadyRegistered(RuntimeError): ONE registration: a process whose model routes verify against two factories, depending on which registration a request happened to reach, is the failure one registration exists to prevent. Registering the SAME - factory again is not refused. `unregister_doxbench_validators()` makes a - deliberate swap explicit.""" + factory again is not refused, and neither is a host's factory over + openDox's own default before anything has read it. + `unregister_doxbench_validators()` makes a deliberate swap explicit.""" _doxbench_validators_factory = None +#: Whether the registration is openDox's own default (an entry point's), and +#: whether a request has read that default since it was registered. The +#: second closes the window in which a host's registration replaces it. +_doxbench_validators_is_default = False +_doxbench_validators_default_read = False +_DOXBENCH_VALIDATORS_LOCK = threading.Lock() + + +def _require_a_factory(factory, call: str, whose: str) -> None: + if not callable(factory): + raise TypeError( + f"{call} takes {whose} validators factory, a callable, not " + f"{type(factory).__name__}. A host with no validators does not " + "register one: it leaves the seam empty, and the model routes " + "refuse, naming this call.") def register_doxbench_validators(factory): @@ -1422,34 +1460,80 @@ def register_doxbench_validators(factory): validators, a `dict` of wire kind to validator, as `ideation_dashboard.doxbench_contracts.validators` does for openxFactory. It is called PER REQUEST, never cached here, so a factory that re-verifies - its pin on every call keeps a mid-run repin observable.""" - global _doxbench_validators_factory - if not callable(factory): - raise TypeError( - "register_doxbench_validators() takes the host's validators " - f"factory, a callable, not {type(factory).__name__}. A host with no " - "validators does not register one: it leaves the seam empty, and " - "the model routes refuse, naming this call.") - if (_doxbench_validators_factory is not None - and _doxbench_validators_factory is not factory): + its pin on every call keeps a mid-run repin observable. + + The SAME factory again is a no-op. A different one over a host's is + refused. Over openDox's own default it REPLACES the default until a + request has read the default, and it is refused once one has.""" + global _doxbench_validators_factory, _doxbench_validators_is_default + global _doxbench_validators_default_read + _require_a_factory(factory, "register_doxbench_validators()", "the host's") + with _DOXBENCH_VALIDATORS_LOCK: + held = _doxbench_validators_factory + if held is factory: + return factory + if held is None or (_doxbench_validators_is_default + and not _doxbench_validators_default_read): + _doxbench_validators_factory = factory + _doxbench_validators_is_default = False + _doxbench_validators_default_read = False + return factory + over_a_host = not _doxbench_validators_is_default + if over_a_host: raise DoxbenchValidatorsAlreadyRegistered( "a validators factory is already registered at openDox's " "doxBench-validators seam, and a different one would replace it. " "Registration happens once, at process start. Call " "opendox.serve_wire.unregister_doxbench_validators() first if the " "swap is deliberate.") - _doxbench_validators_factory = factory - return factory + raise DoxbenchValidatorsAlreadyRegistered( + "openDox's own validators are registered at openDox's " + "doxBench-validators seam, because an entry point registered them " + "where no host had, and a request has already read them, so the " + "host's factory cannot replace them now. A swap would leave one " + "process verifying its model routes against two factories, as a " + "host's profile after a build would (R1Q3 (ii), openxFactory#656 " + "comment 5817152735; RN-1 (a), comment 5850003126). Register the " + "host's own at process start, ahead of cli.build_parser(), " + "cli.main(), serve.build_server() and serve.main(). Call " + "opendox.serve_wire.unregister_doxbench_validators() first if the " + "swap is deliberate.") + + +def register_default_doxbench_validators(factory): + """AN ENTRY POINT's registration of openDox's own default (R1Q10 (a)). + + Registers `factory` ONLY where nothing is registered, and leaves a host's + registration, or a default already registered, exactly as it is. Returns + whatever is registered afterwards. It is NOT for hosts. A default that is + not callable is refused whether or not anything is registered, because it + is openDox's own defect.""" + global _doxbench_validators_factory, _doxbench_validators_is_default + global _doxbench_validators_default_read + _require_a_factory(factory, "register_default_doxbench_validators()", + "openDox's own") + with _DOXBENCH_VALIDATORS_LOCK: + if _doxbench_validators_factory is None: + _doxbench_validators_factory = factory + _doxbench_validators_is_default = True + _doxbench_validators_default_read = False + return _doxbench_validators_factory def unregister_doxbench_validators() -> None: - """Drop the registration. For test isolation and for a host tearing down.""" - global _doxbench_validators_factory - _doxbench_validators_factory = None + """Drop the registration, a host's or the default, and its records. For + test isolation and for a host tearing down.""" + global _doxbench_validators_factory, _doxbench_validators_is_default + global _doxbench_validators_default_read + with _DOXBENCH_VALIDATORS_LOCK: + _doxbench_validators_factory = None + _doxbench_validators_is_default = False + _doxbench_validators_default_read = False def doxbench_validators_registered() -> bool: - """Is a validators factory registered, without calling it or refusing?""" + """Is a validators factory registered, a host's or the entry point's + default, without calling it or refusing?""" return _doxbench_validators_factory is not None @@ -1460,18 +1544,26 @@ def default_doxbench_validators() -> dict: This is `build_server`'s default for the `schema_validator_factory` seam. It is a function, not an eager module-level load, so a server can be built on a plane with no registered factory and simply refuse the two model - routes rather than failing to start — and so the host's factory runs per - request rather than being cached at import, which is what makes a mid-run - repin observable. + routes rather than failing to start — and so the registered factory runs + per request rather than being cached at import, which is what makes a + mid-run repin observable. Refuses with `DoxbenchValidatorsNotRegistered`, naming the seam and its registration call, when nothing is registered. Otherwise raises whatever the factory raises (openxFactory's `ContractPinError` on an unreachable checkout, an absent schema, a digest mismatch, a manifest disagreement, or - a drifted `stack.yaml` ref). The caller (`_doxbench_validators`) turns any - of those into a fail-closed route refusal: "I could not read the contract" - is never an implicit pass.""" - factory = _doxbench_validators_factory + a drifted `stack.yaml` ref; openDox's own `ValidatorUnavailable` for a + packaged copy that fails its proof). The caller (`_doxbench_validators`) + turns any of those into a fail-closed route refusal: "I could not read the + contract" is never an implicit pass. + + Reading openDox's own default here is what closes its window: from this + call on, a host's registration over it is refused.""" + global _doxbench_validators_default_read + with _DOXBENCH_VALIDATORS_LOCK: + factory = _doxbench_validators_factory + if factory is not None and _doxbench_validators_is_default: + _doxbench_validators_default_read = True if factory is None: raise DoxbenchValidatorsNotRegistered(DOXBENCH_VALIDATORS_NOT_REGISTERED) return factory() diff --git a/src/opendox/validator.py b/src/opendox/validator.py index 16c97c56..a542b705 100644 --- a/src/opendox/validator.py +++ b/src/opendox/validator.py @@ -107,16 +107,33 @@ class in `x-rules`. A snapshot validator is REFUSED when that catalog names a never under a name or a time. So a changed byte is refused on the very call that sees it, as openxFactory's `doxbench_contracts.validators()` refuses one. +THE WORKBENCH MANIFEST'S TWO VALIDATOR RULES (plan 034's T085; RULED +`openxFactory#656` comment `5920216845`, item 2, *"Move into openDox's +validator (Recommended)"*). The `ideation-workbench` copy states two rules in +its own text and leaves them to its validator, because no JSON Schema keyword +states either: `recipe.pinned` says *"every pinned keyword MUST also appear in +`checked` -- this schema does not encode the subset constraint"*, and a +`recipe.new_candidates` entry is a document the set has not placed yet. The +copy carries no `x-rules` catalog, so this module OWNS the two rules +(`OWNED_RULES`), under ids it gives them, stating what must hold as its other +rules do: `pinned-keywords-are-checked` and `new-candidates-are-disjoint`. +T058 carried them in `default_projection`, under the consumer script's +identifiers. They live here now, and nothing answers to the old identifiers. +A copy that DOES carry a catalog has its rules from the catalog alone, so a +copy that carries one and is also owned rules here is refused when its +validator is built: one rule is enforced from one place. + jsonschema's SHAPE, FOR THE doxBench SEAM. `KindValidator.iter_errors()` yields violations whose `validator` (the failed keyword) and `absolute_path` read as a `jsonschema` error's do. Those are the two fields `serve_workbench`'s readers of the doxBench-validators seam read. -`validators()` answers one validator per wire kind: the model catalog's whole -document, and each chat-turn envelope's own `$defs` entry, as openxFactory's -`doxbench_contracts` builds them. So T085 can register it -(`serve_wire.register_doxbench_validators`). The doxBench kinds' semantic -rules are T085's. Here they are validated structurally, as openxFactory's -`validators()` validates them. +`doxbench_validators()` answers one validator per doxBench wire kind +(`DOXBENCH_KINDS`): the model catalog's whole document, and each chat-turn +envelope's own `$defs` entry, as openxFactory's `doxbench_contracts` builds +them. It is openDox's own default at that seam, which the entry points +register where no host has (plan 034's T085, R1Q10 (a); +`opendox.doxbench_defaults`). The doxBench kinds are validated structurally +here, as openxFactory's `validators()` validates them. IMPORT WEIGHT. The standard library, and `opendox.contracts`, whose record and copies are read with PyYAML only when a validator is built. It names no @@ -139,16 +156,19 @@ class in `x-rules`. A snapshot validator is REFUSED when that catalog names a __all__ = [ "DEPTH_RULE", "DIALECT", + "DOXBENCH_KINDS", "FORMATS", "KEYWORDS", "KINDS", "KIND_ENTRIES", "KindValidator", + "OWNED_RULES", "REFERENCE_RULES", "SchemaNotEvaluable", "UnknownKind", "ValidatorUnavailable", "Violation", + "doxbench_validators", "report", "validate", "validator_for", @@ -185,6 +205,16 @@ class in `x-rules`. A snapshot validator is REFUSED when that catalog names a #: The instance kinds, sorted. KINDS: tuple[str, ...] = tuple(sorted(KIND_ENTRIES)) +#: The doxBench WIRE kinds, sorted: the model catalog and the three chat-turn +#: envelopes, which the two model routes validate through the +#: doxBench-validators seam (`serve_wire`), as openxFactory's +#: `doxbench_contracts.WIRE_KINDS` names them. Each is held in openDox's +#: packaged copy of `xfactory-workbench-model-catalog` or +#: `xfactory-workbench-chat-turn` (R1Q12 (a)). +DOXBENCH_KINDS: tuple[str, ...] = tuple(sorted( + kind for kind, (copy_id, _pointer) in KIND_ENTRIES.items() + if copy_id in ("xfactory-workbench-chat-turn", "xfactory-workbench-model-catalog"))) + #: The keywords that can FAIL, and that this module evaluates. _ASSERTING = frozenset({ "const", "dependentRequired", "enum", "format", "maxItems", "maxLength", @@ -639,6 +669,98 @@ def _keyword_index_matches_topics(snap: Any) -> Iterator[Violation]: } +# --------------------------------------------------------------------------- +# the workbench manifest's two validator rules (plan 034's T085) +# --------------------------------------------------------------------------- + +def _names(value: Any) -> list[str]: + """A list's string entries, once each, in order. Anything else answers + none: its shape is the schema's to judge, and these rules only compare. + + Linear: the schema bounds none of the three lists, so membership is a + set's, and the list only keeps the order (Copilot at openDox-code#68 + 09cd1e8a, r4139734444).""" + if not isinstance(value, list): + return [] + names: list[str] = [] + seen: set[str] = set() + for item in value: + if isinstance(item, str) and item not in seen: + seen.add(item) + names.append(item) + return names + + +#: How many names a rule's detail quotes before it says how many more, and +#: how much of each name it quotes. +_QUOTED = 10 +_NAME_CHARS = 80 + + +def _quoted(names: list[str]) -> str: + """`names` as a detail quotes them: the first few, each cut to a readable + length, and a count of the rest, so one violation stays one readable line. + The schema bounds neither the lists nor their strings (Copilot at + openDox-code#68 21e4723f, r4139769791).""" + shown = repr([name if len(name) <= _NAME_CHARS else name[:_NAME_CHARS - 1] + "…" + for name in names[:_QUOTED]]) + return shown if len(names) <= _QUOTED else f"{shown[:-1]}, and {len(names) - _QUOTED} more]" + + +def _placed(entries: Any) -> set[str]: + """The `document` of each entry of a members or excluded list.""" + if not isinstance(entries, list): + return set() + return {entry["document"] for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("document"), str)} + + +def _recipe(manifest: Any) -> dict[str, Any] | None: + recipe = manifest.get("recipe") if isinstance(manifest, dict) else None + return recipe if isinstance(recipe, dict) else None + + +def _pinned_keywords_are_checked(manifest: Any) -> Iterator[Violation]: + """Every `recipe.pinned` keyword is also in `recipe.checked`: a keyword + is pinned (required) only among the keywords that stratify the set.""" + recipe = _recipe(manifest) + if recipe is None: + return + checked = set(_names(recipe.get("checked"))) + stray = [name for name in _names(recipe.get("pinned")) if name not in checked] + if stray: + yield _broken("pinned-keywords-are-checked", ("recipe", "pinned"), + f"pinned keyword(s) {_quoted(stray)} are not in checked: " + "every pinned keyword MUST also be checked") + + +def _new_candidates_are_disjoint(manifest: Any) -> Iterator[Violation]: + """No `recipe.new_candidates` document is already a member or excluded: a + new candidate is a document the set has not placed yet.""" + recipe = _recipe(manifest) + if recipe is None: + return + placed = _placed(manifest.get("members")) | _placed(manifest.get("excluded")) + overlap = [name for name in _names(recipe.get("new_candidates")) if name in placed] + if overlap: + yield _broken("new-candidates-are-disjoint", ("recipe", "new_candidates"), + f"new_candidates {_quoted(overlap)} already appear in members " + "or excluded: a new candidate is a document the set has not " + "placed yet") + + +#: The rules this module OWNS, per packaged copy: rules a copy states in its own +#: text and leaves to its validator, because no JSON Schema keyword states them, +#: for a copy that carries no `x-rules` catalog (this module's docstring). Only +#: the workbench manifest's copy has any (RULED `5920216845`, item 2). +OWNED_RULES: Mapping[str, Mapping[str, Callable[[Any], Iterator[Violation]]]] = { + "ideation-workbench": { + "pinned-keywords-are-checked": _pinned_keywords_are_checked, + "new-candidates-are-disjoint": _new_candidates_are_disjoint, + }, +} + + # --------------------------------------------------------------------------- # a copy, compiled # --------------------------------------------------------------------------- @@ -772,9 +894,10 @@ class KindValidator: """The validator of one kind, built over one proved copy. `iter_errors(instance)` yields every `Violation`: the schema's, in the - order the schema is walked, and then the reference rules', in the - contract's catalog order. `violations()` lists them and `is_valid()` asks - whether there are none.""" + order the schema is walked, then the reference rules', in the contract's + catalog order, and then the rules this module owns for the copy + (`OWNED_RULES`), in their order. `violations()` lists them and + `is_valid()` asks whether there are none.""" def __init__(self, kind: str, copy_id: str, pointer: str, document: Any, digest: str) -> None: @@ -787,6 +910,7 @@ def __init__(self, kind: str, copy_id: str, pointer: str, document: Any, self._refuse_what_is_not_evaluated(document, pointer) self._entry = _at_pointer(document, pointer) # resolved, and a schema self._reference = self._reference_rules(document) + self._owned = self._owned_rules(document) # -- building ----------------------------------------------------------- @@ -929,6 +1053,20 @@ def _reference_rules(self, document: dict[str, Any] "is enforced only when both name it") return tuple(implemented[rule] for rule in declared) + def _owned_rules(self, document: dict[str, Any] + ) -> tuple[Callable[[Any], Iterator[Violation]], ...]: + """The rules this module owns for the copy (`OWNED_RULES`), in their + order. Refused for a copy that carries an `x-rules` catalog: the + catalog then states every rule the copy has, and a rule this module + also owned would be enforced from two places.""" + owned = OWNED_RULES.get(self.copy_id, {}) + if owned and "x-rules" in document: + raise self._not_evaluable( + f"it carries an x-rules catalog, and this module also owns the " + f"rules {sorted(owned)} for it; a copy with a catalog has its " + "rules from the catalog alone") + return tuple(owned.values()) + # -- evaluating --------------------------------------------------------- def iter_errors(self, instance: Any) -> Iterator[Violation]: @@ -948,6 +1086,8 @@ def iter_errors(self, instance: Any) -> Iterator[Violation]: "it is not judged valid") for check in self._reference: yield from check(instance) + for check in self._owned: + yield from check(instance) def violations(self, instance: Any) -> list[Violation]: return list(self.iter_errors(instance)) @@ -1217,6 +1357,18 @@ def validators() -> dict[str, KindValidator]: return {kind: validator_for(kind) for kind in KINDS} +def doxbench_validators() -> dict[str, KindValidator]: + """openDox's own validators for the doxBench wire kinds (`DOXBENCH_KINDS`), + every copy proved on this call: the factory openDox registers, as its + default, at the doxBench-validators seam (`serve_wire`; plan 034's T085, + R1Q10 (a) and R1Q12 (a)). The seam calls it once per request, so a copy + that changes is refused on the very request that reads it. A copy that + fails its proof raises `ValidatorUnavailable`, and the seam's reader turns + that into no validators, so both model routes refuse: no verdict is never + a pass. A fresh dict on every call.""" + return {kind: validator_for(kind) for kind in DOXBENCH_KINDS} + + def validate(instance: Any, *, kind: str | None = None) -> list[Violation]: """Every rule of `kind`'s contract that `instance` breaks. diff --git a/tests/test_doxbench_defaults.py b/tests/test_doxbench_defaults.py new file mode 100644 index 00000000..f492ceec --- /dev/null +++ b/tests/test_doxbench_defaults.py @@ -0,0 +1,487 @@ +"""openDox's own defaults for the two doxBench seams: plan 034's T085 (#1144's +4.3 as T007's batch G amends it, and 16.4 in part; R1Q10 (a) and R1Q12 (a), +`openxFactory#656` comment `5850003126`). + +T027 made two reaches into openxFactory seams that refuse, naming themselves, +when nothing is registered: the doxBench schema validators +(`serve_wire.register_doxbench_validators`) and the status-exemption rail +(`doxbench_packet.register_status_exemption`). Standalone, nothing registered +either, so a standalone server's `GET /workbench/model-catalog` answered `500 +catalog_unavailable` (measured at openDox-code `047bb4fa`). T085 gives each seam +an openDox default, `opendox.doxbench_defaults`, which the four entry points +register where no host has. This file holds: + +1. THE DEFAULTS THEMSELVES. The validators are `opendox.validator`'s own over + its packaged copies of the two doxBench schemas, one per wire kind, and they + meet the jsonschema-shaped contract the seam's readers in `serve_workbench` + read: openDox-spec's own examples conform, its negative ones do not, and + the buffers-floor reader tells the floor from any other fault. The rail + exempts nothing, and reports a document's own `Status:` line, raw. +2. THE SEAM RULES, for both seams. Nothing registered still refuses, naming + the seam and both calls (4.2). A default registers only where nothing is + registered. A host registered first is kept. A host registered after the + default and before it is read replaces it. After a read, a host is + refused, and the default stays. A malformed default is refused anyway. +3. THE ENTRY POINTS. `cli.build_parser()`, `cli.main()`, + `serve.build_server()` and `serve.main()` each register both defaults, and + each keeps a host registered first. Each runs in a fresh interpreter, so + no other case's registrations reach it. +4. THE FALSIFIER: THE SERVED CATALOG ROUTE ANSWERS STANDALONE. A + `python -m opendox.cli generate-and-open` child and a `python -m + opendox.serve` child, with neither sibling importable + (`tests/standalone_child.py`), each answer `GET /workbench/model-catalog` + 200 with an envelope openDox's own validator accepts. What that catalog + OFFERS is 16.4's and T081's: at this task's head it is the harness + declaration, `omp-local`, declared available, and T081 asserts that no + entry is available with no model configured. Plan 034's T085 box is ticked + when both hold, at T081's landing (the holder's ruling, 2026-10-02). + +Every in-process case starts with nothing registered at either seam, and puts +back exactly what it found, records included. Against a tree WITHOUT T085, +`opendox.doxbench_defaults` does not import and each case fails on its own +assertion rather than the file failing to collect, which keeps the red run +legible. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import json +import re +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +from opendox import contracts +from opendox import doxbench_model +from opendox import doxbench_packet as pk +from opendox import serve_wire +from opendox import validator as own +from opendox.serve_workbench import WorkbenchRoutes +from session_fixtures import GATE_TEST_PRINCIPALS +from standalone_child import Child, fresh_repository + +try: + from opendox import doxbench_defaults as defaults +except ImportError: # a tree without T085: each case fails on its own line + defaults = None + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = ROOT / "tests" / "fixtures" / "spec-examples" +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" + +#: The doxBench wire kinds, as `serve_wire` spells them. +WIRE_KINDS = (serve_wire.DOXBENCH_MODEL_CATALOG_KIND, + serve_wire.DOXBENCH_CHAT_TURN_V2_KIND, + serve_wire.DOXBENCH_CHAT_TURN_V2_SUCCESS_KIND, + serve_wire.DOXBENCH_CHAT_TURN_V2_FAILURE_KIND) + +#: The packaged copies the four kinds are read from (R1Q12 (a)). +CHAT_COPIES = ("xfactory-workbench-chat-turn", "xfactory-workbench-model-catalog") + +#: Each seam's slots and calls, read through `getattr` so that against a tree +#: without T085 each case fails on its own assertion. +SEAMS = { + "validators": dict( + module=serve_wire, + slots=("_doxbench_validators_factory", "_doxbench_validators_is_default", + "_doxbench_validators_default_read"), + register="register_doxbench_validators", + register_default="register_default_doxbench_validators", + unregister="unregister_doxbench_validators", + registered="doxbench_validators_registered", + read=lambda: serve_wire.default_doxbench_validators(), + not_registered=serve_wire.DoxbenchValidatorsNotRegistered, + already=serve_wire.DoxbenchValidatorsAlreadyRegistered, + default=lambda: own.doxbench_validators, + host=lambda: (lambda: {"stand-in": True}), + malformed="not callable"), + "rail": dict( + module=pk, + slots=("_status_exemption_rail", "_status_exemption_is_default", + "_status_exemption_default_read"), + register="register_status_exemption", + register_default="register_default_status_exemption", + unregister="unregister_status_exemption", + registered="status_exemption_registered", + read=lambda: pk._status_exemption(), + not_registered=pk.StatusExemptionNotRegistered, + already=pk.StatusExemptionAlreadyRegistered, + default=lambda: defaults.NO_STATUS_EXEMPTION, + host=lambda: _HostRail(), + malformed=object()), +} + + +class _HostRail: + """A host's stand-in rail, exempting everything.""" + + def lifecycle_status(self, text): + return "host" + + def is_compression_exempt(self, text): + return True + + +@pytest.fixture(autouse=True) +def _empty_seams(): + """Nothing registered at either seam, and each PUT BACK exactly, records + included, so an entry point's default is never handed back as a host's.""" + held = {name: [getattr(seam["module"], slot, None) for slot in seam["slots"]] + for name, seam in SEAMS.items()} + for seam in SEAMS.values(): + getattr(seam["module"], seam["unregister"])() + yield + for name, seam in SEAMS.items(): + for slot, value in zip(seam["slots"], held[name]): + if hasattr(seam["module"], slot): + setattr(seam["module"], slot, value) + + +def _example(name: str): + return yaml.safe_load((EXAMPLES / name).read_text(encoding="utf-8")) + + +# --------------------------------------------------------------------------- +# 1 — the defaults themselves +# --------------------------------------------------------------------------- + +def test_the_validators_are_openDoxs_own_one_per_wire_kind() -> None: + assert own.DOXBENCH_KINDS == tuple(sorted(WIRE_KINDS)) + built = own.doxbench_validators() + assert sorted(built) == sorted(WIRE_KINDS) + pins = contracts.record() + for kind, validator in built.items(): + assert isinstance(validator, own.KindValidator), kind + assert validator.copy_id in CHAT_COPIES, kind + assert validator.digest == pins.copy(validator.copy_id).sha256, kind + assert own.doxbench_validators() is not built, "a fresh dict per call" + + +@pytest.mark.parametrize("name", sorted( + path.name for path in EXAMPLES.glob("workbench-*.example.yaml"))) +def test_openDox_specs_own_examples_conform_through_the_seams_reader(name) -> None: + """The reader the model routes call (`_doxbench_wire_conforms`) accepts + every doxBench example openDox-spec ships, judged by openDox's own + validators.""" + instance = _example(name) + assert WorkbenchRoutes._doxbench_wire_conforms( + own.doxbench_validators(), instance["kind"], instance), name + + +def test_a_malformed_envelope_does_not_conform_and_an_unknown_kind_has_no_verdict() -> None: + validators = own.doxbench_validators() + catalog = doxbench_model.catalog_wire_envelope(doxbench_model.EMPTY_CATALOG) + assert WorkbenchRoutes._doxbench_wire_conforms( + validators, serve_wire.DOXBENCH_MODEL_CATALOG_KIND, catalog) + assert not WorkbenchRoutes._doxbench_wire_conforms( + validators, serve_wire.DOXBENCH_MODEL_CATALOG_KIND, {**catalog, "models": "none"}) + assert not WorkbenchRoutes._doxbench_wire_conforms( + validators, "ideation-workbench", {"kind": "ideation-workbench"}) + + +def test_the_buffers_floor_reader_reads_openDoxs_violations() -> None: + """`_doxbench_violation_beside_the_buffers_floor` reads a violation's + `validator` keyword and `absolute_path`. Over openDox's validators, a + request whose only fault is an empty `buffers` reads as the floor alone, + and one with a second fault does not.""" + validators = own.doxbench_validators() + kind = serve_wire.DOXBENCH_CHAT_TURN_V2_KIND + request = _example("workbench-chat-turn-v2-loaded-set.example.yaml") + floor_only = {**request, "buffers": []} + assert not WorkbenchRoutes._doxbench_wire_conforms(validators, kind, floor_only) + assert WorkbenchRoutes._doxbench_violation_beside_the_buffers_floor( + validators, kind, floor_only) is False + assert WorkbenchRoutes._doxbench_violation_beside_the_buffers_floor( + validators, kind, {**floor_only, "client_turn_id": 7}) is True + + +def test_the_default_rail_exempts_nothing() -> None: + """There is no status exemption by default (batch G's addendum to 4.3), + whatever the document's status says.""" + rail = defaults.NO_STATUS_EXEMPTION + for status in ("ratified", "approved", "standard", "draft"): + assert rail.is_compression_exempt(f"# A doc\n\nStatus: {status}\n") is False + assert rail.EXEMPT_STATUSES == frozenset() + + +def test_the_default_rail_reports_a_documents_own_status_raw() -> None: + """So the packet's label (`Status: `, or `no Status: header`) stays + true of the document.""" + rail = defaults.NO_STATUS_EXEMPTION + assert rail.lifecycle_status("# T\n\nStatus: ratified (by a change) \n") == \ + "ratified (by a change)" + assert rail.lifecycle_status("# T\r\nStatus: draft\r\n") == "draft" + assert rail.lifecycle_status("# T\rStatus: draft\r") == "draft" + assert rail.lifecycle_status("# A doc with no status\n\nbody\n") is None + assert rail.lifecycle_status("x\n" * 15 + "Status: late\n") is None + assert rail.lifecycle_status("x\n" * 14 + "Status: last\n") == "last" + # only CR, LF and CRLF end a line, so a form feed or U+2028 does not + # start one + assert rail.lifecycle_status("# T\fStatus: hidden\n") is None + assert rail.lifecycle_status("# T
Status: hidden\n") is None + assert rail.lifecycle_status(None) is None + + +def test_the_assembler_marks_every_source_with_the_default_rail() -> None: + pk.register_default_status_exemption(defaults.NO_STATUS_EXEMPTION) + sources = [ + pk.PacketSource(ref="notes/a.md", kind=pk.SOURCE_EVIDENCE, + text="Status: ratified\n\nbody\n", status=None, + compression_exempt=True), + pk.PacketSource(ref="notes/b.md", kind=pk.SOURCE_EVIDENCE, + text="no header\n", status="stale", compression_exempt=True), + ] + marked = pk.exemption_rail(sources) + assert [(s.status, s.compression_exempt) for s in marked] == [ + ("ratified", False), (None, False)] + assert pk.EXEMPT_STATUSES == frozenset() + + +# --------------------------------------------------------------------------- +# 2 — the seam rules, for both seams +# --------------------------------------------------------------------------- + +def _call(seam, name, *args): + return getattr(seam["module"], seam[name])(*args) + + +def _held(seam): + return [getattr(seam["module"], slot) for slot in seam["slots"]] + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_nothing_registered_refuses_naming_the_seam_and_both_calls(name) -> None: + seam = SEAMS[name] + assert _call(seam, "registered") is False + with pytest.raises(seam["not_registered"]) as refused: + seam["read"]() + text = str(refused.value) + assert "opendox.doxbench_defaults.register_defaults()" in text + assert f"{seam['module'].__name__}.{seam['register']}(" in text + for entry_point in ("cli.build_parser()", "cli.main()", "serve.build_server()", + "serve.main()"): + assert entry_point in text, entry_point + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_register_defaults_registers_each_default_only_where_nothing_is(name) -> None: + seam = SEAMS[name] + defaults.register_defaults() + assert _held(seam) == [seam["default"](), True, False] + defaults.register_defaults() + assert _held(seam) == [seam["default"](), True, False], "a second call is a no-op" + assert _call(seam, "registered") is True + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_a_host_registered_first_is_kept(name) -> None: + seam = SEAMS[name] + host = seam["host"]() + _call(seam, "register", host) + defaults.register_defaults() + assert _held(seam) == [host, False, False] + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_a_host_replaces_the_default_until_it_is_read(name) -> None: + seam = SEAMS[name] + defaults.register_defaults() + host = seam["host"]() + assert _call(seam, "register", host) is host + assert _held(seam) == [host, False, False] + assert _call(seam, "register", host) is host, "the same host again is a no-op" + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_after_the_default_is_read_a_host_is_refused_and_the_default_stays(name) -> None: + seam = SEAMS[name] + defaults.register_defaults() + seam["read"]() + assert _held(seam) == [seam["default"](), True, True] + with pytest.raises(seam["already"], match=r"R1Q3 \(ii\)"): + _call(seam, "register", seam["host"]()) + assert _held(seam) == [seam["default"](), True, True] + _call(seam, "unregister") + assert _held(seam) == [None, False, False] + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_a_second_host_over_a_host_is_refused(name) -> None: + seam = SEAMS[name] + first = seam["host"]() + _call(seam, "register", first) + with pytest.raises(seam["already"]): + _call(seam, "register", seam["host"]()) + assert _held(seam)[0] is first + + +@pytest.mark.parametrize("name", sorted(SEAMS)) +def test_a_malformed_default_is_refused_whether_or_not_anything_is_registered(name) -> None: + seam = SEAMS[name] + with pytest.raises(TypeError, match="openDox's own"): + _call(seam, "register_default", seam["malformed"]) + _call(seam, "register", seam["host"]()) + with pytest.raises(TypeError, match="openDox's own"): + _call(seam, "register_default", seam["malformed"]) + + +def test_the_registered_default_validators_answer_through_the_seam() -> None: + defaults.register_defaults() + answered = serve_wire.default_doxbench_validators() + assert sorted(answered) == sorted(WIRE_KINDS) + assert all(validator.copy_id in CHAT_COPIES for validator in answered.values()) + + +# --------------------------------------------------------------------------- +# 3 — the entry points register both, and keep a host's +# --------------------------------------------------------------------------- + +_ENTRY_POINT = textwrap.dedent(""" + import json, sys + from pathlib import Path + from opendox import doxbench_defaults, doxbench_packet, serve_wire, validator + entry, host_first = sys.argv[1], sys.argv[2] == "host" + host_factory = lambda: {} + class HostRail: + def lifecycle_status(self, text): return None + def is_compression_exempt(self, text): return True + host_rail = HostRail() + if host_first: + serve_wire.register_doxbench_validators(host_factory) + doxbench_packet.register_status_exemption(host_rail) + from opendox import cli, serve + if entry == "cli.build_parser": + cli.build_parser() + elif entry in ("cli.main", "serve.main"): + try: + (cli.main if entry == "cli.main" else serve.main)(["--help"]) + except SystemExit: + pass + elif entry == "serve.build_server": + tmp = Path(sys.argv[3]) + (tmp / "web").mkdir() + (tmp / "snapshot.json").write_text("{}") + serve.build_server(tmp / "web", tmp / "snapshot.json", tmp, port=0).server_close() + factory = serve_wire._doxbench_validators_factory + rail = doxbench_packet._status_exemption_rail + print(json.dumps({ + "validators": ("host" if factory is host_factory else + "default" if factory is validator.doxbench_validators else repr(factory)), + "validators_is_default": serve_wire._doxbench_validators_is_default, + "rail": ("host" if rail is host_rail else + "default" if rail is doxbench_defaults.NO_STATUS_EXEMPTION else repr(rail)), + "rail_is_default": doxbench_packet._status_exemption_is_default, + })) + """) + +ENTRY_POINTS = ("cli.build_parser", "cli.main", "serve.build_server", "serve.main") + + +def _entry_point(tmp_path: Path, entry: str, host_first: bool) -> dict: + done = subprocess.run( + [sys.executable, "-c", _ENTRY_POINT, entry, "host" if host_first else "none", + str(tmp_path)], + capture_output=True, text=True, cwd=ROOT, timeout=120) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout.strip().splitlines()[-1]) + + +@pytest.mark.parametrize("entry", ENTRY_POINTS) +def test_each_entry_point_registers_both_defaults_where_no_host_has(tmp_path, entry) -> None: + assert _entry_point(tmp_path, entry, host_first=False) == { + "validators": "default", "validators_is_default": True, + "rail": "default", "rail_is_default": True} + + +@pytest.mark.parametrize("entry", ENTRY_POINTS) +def test_each_entry_point_keeps_a_host_registered_first(tmp_path, entry) -> None: + assert _entry_point(tmp_path, entry, host_first=True) == { + "validators": "host", "validators_is_default": False, + "rail": "host", "rail_is_default": False} + + +# --------------------------------------------------------------------------- +# 4 — THE FALSIFIER: the served catalog route answers standalone +# --------------------------------------------------------------------------- + +_URL = re.compile(r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +#: The console's human: one of the principals this suite declares +#: (`session_fixtures.GATE_TEST_PRINCIPALS`, the `XF_GATE_PRINCIPALS` roster the +#: child inherits), claimed with `--actor`, so the served console resolves it +#: and offers its session routes. +ACTOR = "tester" + + +def _repository(tmp_path: Path) -> Path: + """T050's fixture in a fresh repository (#1144's preamble).""" + return fresh_repository(PLAIN, tmp_path) + + +def _get(base: tuple[str, int], path: str, headers: dict | None = None): + connection = http.client.HTTPConnection(*base, timeout=30) + try: + connection.request("GET", path, headers=headers or {}) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + +def served_catalog(child: Child) -> dict: + """The served model catalog, read as the chat rail reads it: the console + token from `/capabilities`, then `GET /workbench/model-catalog`. Asserts + the route ANSWERS, with an envelope openDox's own validator accepts.""" + assert ACTOR in GATE_TEST_PRINCIPALS + match = child.wait_for_line(_URL) + base = (match.group(2), int(match.group(3))) + status, body = _get(base, "/capabilities") + assert status == 200, status + capabilities = json.loads(body) + assert capabilities["actions"]["session"] is True, capabilities + status, body = _get(base, "/workbench/model-catalog", + {"X-XF-Console-Token": capabilities["console_token"]}) + assert status == 200, (status, body) + envelope = json.loads(body) + assert envelope["kind"] == serve_wire.DOXBENCH_MODEL_CATALOG_KIND + assert own.validate(envelope) == [], own.report(own.validate(envelope)) + assert child.interrupt() == 0, child.stderr_text() + assert child.refused() == [], child.refused() + return envelope + + +def test_the_served_catalog_route_answers_from_generate_and_open(tmp_path) -> None: + """`python -m opendox.cli generate-and-open`, the documented command, with + neither sibling importable. At `047bb4fa` it answered `500 + catalog_unavailable`.""" + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(_repository(tmp_path)), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(tmp_path / "run"), + "--actor", ACTOR) + try: + served_catalog(child) + finally: + child.kill() + + +def test_the_served_catalog_route_answers_from_serve_main(tmp_path) -> None: + """The server's own entry point, over a snapshot `generate` wrote.""" + repo = _repository(tmp_path) + out = tmp_path / "out" / "snapshot.json" + generated = Child(tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out)) + assert generated.wait() == 0, generated.stderr_text() + child = Child(tmp_path, "opendox.serve", "--snapshot", str(out), + "--checkout-root", str(repo), "--port", "0", "--actor", ACTOR) + try: + served_catalog(child) + finally: + child.kill() diff --git a/tests/test_doxbench_seams.py b/tests/test_doxbench_seams.py index b715ad35..e0228d2c 100644 --- a/tests/test_doxbench_seams.py +++ b/tests/test_doxbench_seams.py @@ -72,10 +72,12 @@ #: Each seam's own functions, and the readers that resolve through it. VALIDATOR_SEAM_FUNCTIONS = ( - "register_doxbench_validators", "unregister_doxbench_validators", - "doxbench_validators_registered", "default_doxbench_validators") + "register_doxbench_validators", "register_default_doxbench_validators", + "unregister_doxbench_validators", "doxbench_validators_registered", + "default_doxbench_validators") RAIL_SEAM_FUNCTIONS = ( - "register_status_exemption", "unregister_status_exemption", + "register_status_exemption", "register_default_status_exemption", + "_require_a_rail", "unregister_status_exemption", "status_exemption_registered", "_status_exemption", "exemption_rail", "__getattr__") @@ -93,31 +95,38 @@ @pytest.fixture(autouse=True) def _empty_seams(): """Every test starts with NOTHING registered at either seam, and puts back - what it found. + EXACTLY what it found, records included. Both seams are process-global by design (ONE registration each, for the process), so a teardown that only unregistered would strip a registration - some other part of the process made at its start. + some other part of the process made at its start. And since plan 034's + T085 a seam's registration may be openDox's own DEFAULT, which an entry + point registered and a request may already have read, so the records go + back with it (`setattr`, never a host's `register()`): a default restored + as a host's would no longer give way to one. Read through `getattr` with a default, so that against a tree WITHOUT the seams each test fails on its own assertion rather than all of them erroring here, which is what makes this file's red run legible.""" found = { - "validators": (serve_wire, "_doxbench_validators_factory", - "unregister_doxbench_validators", - "register_doxbench_validators"), - "rail": (pk, "_status_exemption_rail", "unregister_status_exemption", - "register_status_exemption"), + "validators": (serve_wire, ("_doxbench_validators_factory", + "_doxbench_validators_is_default", + "_doxbench_validators_default_read"), + "unregister_doxbench_validators"), + "rail": (pk, ("_status_exemption_rail", "_status_exemption_is_default", + "_status_exemption_default_read"), + "unregister_status_exemption"), } - previous = {key: getattr(module, slot, None) - for key, (module, slot, _u, _r) in found.items()} - for module, _slot, unregister, _register in found.values(): + previous = {key: [(slot, getattr(module, slot)) for slot in slots + if hasattr(module, slot)] + for key, (module, slots, _u) in found.items()} + for module, _slots, unregister in found.values(): getattr(module, unregister, lambda: None)() yield - for key, (module, _slot, unregister, register) in found.items(): + for key, (module, _slots, unregister) in found.items(): getattr(module, unregister, lambda: None)() - if previous[key] is not None: - getattr(module, register)(previous[key]) + for slot, value in previous[key]: + setattr(module, slot, value) class _TrapModule(types.ModuleType): diff --git a/tests/test_post_render_validator.py b/tests/test_post_render_validator.py index f2772edf..3f8e2265 100644 --- a/tests/test_post_render_validator.py +++ b/tests/test_post_render_validator.py @@ -22,7 +22,12 @@ `search_from` change nothing. 4. The workbench manifest, read as YAML, with the two validator rules its schema leaves to the validator, and `workbench.save(validate=True)` over - them. + them. Since plan 034's T085 (RULED `openxFactory#656` comment + `5920216845`, item 2), the rules are `opendox.validator`'s own, as + `pinned-keywords-are-checked` and `new-candidates-are-disjoint`, and + `default_projection` checks neither. The identifiers T058 gave them appear + here only as the BEFORE-STATE (`RETIRED_RULE_IDS`), which nothing may + report. Every case starts with nothing registered at the four projection seams and puts back what it found. @@ -55,6 +60,17 @@ NEUTRAL = "opendox-snapshot" NOW = "2026-09-27T12:00:00Z" +#: The workbench manifest's two validator rules, as `opendox.validator` owns +#: them (T085). +PINNED_RULE = "pinned-keywords-are-checked" +CANDIDATE_RULE = "new-candidates-are-disjoint" + +#: THE BEFORE-STATE: the identifiers T058 carried the same two rules under, in +#: `default_projection.WORKBENCH_RULES` at openDox-code `047bb4fa`, as the +#: consumer's script named them. T085 keeps neither as an alias, so no report +#: may name either. +RETIRED_RULE_IDS = ("workbench-pinned-not-checked", "workbench-candidate-overlap") + _SINGLE_SEAMS = (ps.registry, ps.corpus_root, ps.writer) @@ -368,7 +384,7 @@ def test_the_rules_read_a_long_list_in_linear_time() -> None: names = [f"keyword-{index}" for index in range(50_000)] started = time.monotonic() - read = default_projection._names(names + names + [7, None]) + read = own._names(names + names + [7, None]) elapsed = time.monotonic() - started assert read == names assert elapsed < 5, f"{elapsed:.1f}s to read 100,002 entries" @@ -445,7 +461,7 @@ def test_a_pinned_keyword_that_is_not_checked_breaks_its_rule(tmp_path) -> None: _manifest(tmp_path, document)) assert result.outcome == ps.NOT_CONFORMANT assert result.stdout.splitlines() == [ - "[workbench-pinned-not-checked] /recipe/pinned: pinned keyword(s) ['worms'] " + f"[{PINNED_RULE}] /recipe/pinned: pinned keyword(s) ['worms'] " "are not in checked: every pinned keyword MUST also be checked", "1 violation(s) of the ideation-workbench contract, by " f"{result.validator}"] @@ -457,7 +473,7 @@ def test_a_rules_detail_quotes_a_few_names_and_counts_the_rest(tmp_path) -> None result = default_projection.VALIDATORS[workbench.KIND].validate( _manifest(tmp_path, document)) first = result.stdout.splitlines()[0] - assert first.startswith("[workbench-pinned-not-checked] /recipe/pinned: pinned " + assert first.startswith(f"[{PINNED_RULE}] /recipe/pinned: pinned " "keyword(s) ['k00', 'k01', ") assert "'k09', and 15 more] are not in checked" in first and "'k10'" not in first @@ -479,7 +495,7 @@ def test_a_new_candidate_already_placed_breaks_its_rule(tmp_path) -> None: _manifest(tmp_path, document)) assert result.outcome == ps.NOT_CONFORMANT assert result.stdout.splitlines()[0] == ( - "[workbench-candidate-overlap] /recipe/new_candidates: new_candidates " + f"[{CANDIDATE_RULE}] /recipe/new_candidates: new_candidates " "['notes/out.md', 'notes/in.md'] already appear in members or excluded: a " "new candidate is a document the set has not placed yet") @@ -492,8 +508,8 @@ def test_the_two_rules_are_judged_beside_the_schema_and_never_crash(tmp_path) -> result = default_projection.VALIDATORS[workbench.KIND].validate( _manifest(tmp_path, document)) rules = [line.split("]")[0][1:] for line in result.stdout.splitlines()[:-1]] - assert "workbench-pinned-not-checked" in rules - assert "workbench-candidate-overlap" not in rules + assert PINNED_RULE in rules + assert CANDIDATE_RULE not in rules assert {"type"} <= set(rules), rules @@ -516,5 +532,78 @@ def test_save_with_validate_keeps_a_valid_manifest_and_unwinds_a_broken_one(tmp_ broken.data["recipe"]["pinned"] = ["worms"] with pytest.raises(workbench.ManifestInvalid) as refused: workbench.save(broken, boundary, validate=True) - assert "[workbench-pinned-not-checked] /recipe/pinned:" in str(refused.value) + assert f"[{PINNED_RULE}] /recipe/pinned:" in str(refused.value) assert not (tmp_path / workbench.manifest_relpath("a broken set")).exists() + + +# --------------------------------------------------------------------------- +# 4a — the two rules are the validator's, under their new ids (T085) +# --------------------------------------------------------------------------- + +def _breaking_both() -> dict: + """A manifest that breaks each of the two rules once, and nothing else.""" + document = yaml.safe_load(_recipe_set().render()) + document["recipe"]["pinned"] = ["soil", "worms"] + document["recipe"]["new_candidates"] = ["notes/fresh.md", "notes/out.md"] + return document + + +def test_openDoxs_validator_reports_both_rules_under_their_new_ids() -> None: + """RULED `5920216845`, item 2: the rules are `opendox.validator`'s own, + reported by the validator itself, with no adapter in between.""" + assert tuple(own.OWNED_RULES[workbench.KIND]) == (PINNED_RULE, CANDIDATE_RULE) + found = own.validator_for(workbench.KIND).violations(_breaking_both()) + assert [(v.rule, v.path, v.keyword) for v in found] == [ + (PINNED_RULE, ("recipe", "pinned"), "reference"), + (CANDIDATE_RULE, ("recipe", "new_candidates"), "reference")] + assert own.validate(_breaking_both()) == found + assert own.report(found) == [ + f"[{PINNED_RULE}] /recipe/pinned: pinned keyword(s) ['worms'] are not in " + "checked: every pinned keyword MUST also be checked", + f"[{CANDIDATE_RULE}] /recipe/new_candidates: new_candidates " + "['notes/out.md'] already appear in members or excluded: a new candidate " + "is a document the set has not placed yet"] + assert own.validator_for(workbench.KIND).is_valid( + yaml.safe_load(_recipe_set().render())) + + +def test_no_report_names_a_retired_id_and_none_is_an_alias(tmp_path) -> None: + """The BEFORE-STATE: at `047bb4fa` the adapter reported these two rules as + `RETIRED_RULE_IDS`. Neither is an id any longer, nor an alias of one.""" + result = default_projection.VALIDATORS[workbench.KIND].validate( + _manifest(tmp_path, _breaking_both())) + assert result.outcome == ps.NOT_CONFORMANT + for retired in RETIRED_RULE_IDS: + assert retired not in result.stdout, result.stdout + assert retired not in own.OWNED_RULES[workbench.KIND] + assert all(retired not in rules for rules in own.REFERENCE_RULES.values()) + + +def test_default_projection_checks_neither_rule(tmp_path, monkeypatch) -> None: + """`default_projection` carries no copy of either rule: with the + validator's two taken away, its adapter reports nothing for a manifest + that breaks both. With them in place, it reports each exactly once, so an + adapter that checked them again beside the validator fails here too.""" + assert not hasattr(default_projection, "WORKBENCH_RULES") + assert not hasattr(default_projection.OwnValidator, "_workbench_rules") + path = _manifest(tmp_path, _breaking_both()) + lines = default_projection.VALIDATORS[workbench.KIND].validate(path).stdout.splitlines() + rules = [line.split("]")[0][1:] for line in lines[:-1]] + assert rules == [PINNED_RULE, CANDIDATE_RULE], lines + assert lines[-1].startswith("2 violation(s) of the ideation-workbench contract") + monkeypatch.setattr(own, "OWNED_RULES", {}) + monkeypatch.setattr(own, "_CACHE", {}) + bare = default_projection.VALIDATORS[workbench.KIND].validate(path) + assert (bare.ok, bare.outcome) == (True, ps.VALIDATED), bare.stdout + + +def test_a_copy_with_a_catalog_cannot_also_have_owned_rules() -> None: + """One rule is enforced from one place: a copy that carries an `x-rules` + catalog has its rules from it alone, so owned rules beside it are refused + when the validator is built, never enforced twice.""" + document = contracts.load(workbench.KIND) + copy_id, pointer = own.KIND_ENTRIES[workbench.KIND] + own.KindValidator(workbench.KIND, copy_id, pointer, document, "digest") + with pytest.raises(own.SchemaNotEvaluable, match="x-rules catalog"): + own.KindValidator(workbench.KIND, copy_id, pointer, + {**document, "x-rules": []}, "digest") From 83213eb2f6aa24ea45eefd700c05efe5effd7494 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:29:28 +0000 Subject: [PATCH 02/10] T085: answer the same rail under the status-exemption lock (Copilot r4169543783) register_status_exemption() and register_default_status_exemption() compared the rail with the registered one outside _STATUS_EXEMPTION_LOCK, so a teardown between the comparison and the return let a call report success over an emptied seam. Both now compare under the lock, as projection_seams._Seam.register() does (projection_seams.py:271-275), and probe a new rail outside it. The validators seam already compared under its lock. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_packet.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/opendox/doxbench_packet.py b/src/opendox/doxbench_packet.py index 94a25ced..cd0b5849 100644 --- a/src/opendox/doxbench_packet.py +++ b/src/opendox/doxbench_packet.py @@ -302,8 +302,12 @@ def register_status_exemption(rail: object) -> object: refused once one has (R1Q3 (ii)).""" global _status_exemption_rail, _status_exemption_is_default global _status_exemption_default_read - if rail is not None and rail is _status_exemption_rail: - return rail + # The SAME rail is answered under the lock, as `projection_seams._Seam` + # answers it, so a teardown cannot empty the seam between the comparison + # and the return (Copilot at openDox-code#71 e0298cf4, r4169543783). + with _STATUS_EXEMPTION_LOCK: + if rail is not None and rail is _status_exemption_rail: + return rail _require_a_rail(rail, "register_status_exemption()", "the host's") with _STATUS_EXEMPTION_LOCK: held = _status_exemption_rail @@ -346,8 +350,9 @@ def register_default_status_exemption(rail: object) -> object: it is openDox's own defect.""" global _status_exemption_rail, _status_exemption_is_default global _status_exemption_default_read - if rail is not None and rail is _status_exemption_rail: - return rail + with _STATUS_EXEMPTION_LOCK: # as in `register_status_exemption()` + if rail is not None and rail is _status_exemption_rail: + return rail _require_a_rail(rail, "register_default_status_exemption()", "openDox's own") with _STATUS_EXEMPTION_LOCK: if _status_exemption_rail is None: From ef0c5546bcb090dc2b5f95c51181f3fdeece49e5 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:36:10 +0000 Subject: [PATCH 03/10] T081, 16.4: "no model configured" is a state, shown before any turn (plan 034) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With no approved binding and no harness, the install now declares no model. Before this, declared_model_port_factory resolved the harness bridge whether or not omp existed, and its catalog offered omp-local as available. - doxbench_model.py: NO_MODEL_CONFIGURED, the one no-model port. Its catalog is EMPTY_CATALOG, and its dispatch refuses without spawning or contacting anything. NO_MODEL_CONFIGURED_REMEDY says how to configure a model. - doxbench_install.py: harness_installed() (omp on PATH) and no_model_port_factory(). declared_model_port_factory(harness_present=...) answers the harness bridge where the harness is installed, so the harness route stays, and the no-model port where it is not. The binding states are unchanged: a hand- or CLI-declared binding is present, pending is suppressed, approved is present, and an unreadable document is read as declaring none. The unreadable-document notice no longer claims a harness fallback. - serve_workbench._workbench_model_port() answers the no-model port, by identity, as no port. So the catalog route serves the editor-only posture, and a turn or an abstract that reaches its model step is refused model_capability_unavailable (holder Q3). - doxbench-chat.js: a new visible line, doxchat-no-model, shown only once the catalog has answered with no available entry, no failure recorded and no intake option rendered. It says "No model configured." and how to configure one (NO_MODEL_CONFIGURED_REMEDY). The send button's configured-none sentence stays byte for byte (add-doxchat-model-intake §1). The web census moves doxbench-chat.js loc from 1828 to 1864 (class A total from 18073 to 18109). - tests/test_chat_model_configuration.py (new): F16.1's catalog block as written, each binding state, the harness route, the port and the accessor, the served catalog standalone with no available entry (T085's falsifier, whole), and the rail over node. - tests/test_model_provider_broker.py: three cases that pinned the harness fallback now say the harness is present (harness_present=lambda: True), and keep their assertions. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/doxbench_install.py | 62 ++- src/opendox/doxbench_model.py | 60 +++ src/opendox/serve_workbench.py | 16 +- src/opendox/web/views/doxbench-chat.js | 40 +- tests/fixtures/web_boundary_census.yaml | 4 +- tests/test_chat_model_configuration.py | 496 ++++++++++++++++++++++++ tests/test_model_provider_broker.py | 17 +- 7 files changed, 670 insertions(+), 25 deletions(-) create mode 100644 tests/test_chat_model_configuration.py diff --git a/src/opendox/doxbench_install.py b/src/opendox/doxbench_install.py index fef18092..7cb15c1e 100644 --- a/src/opendox/doxbench_install.py +++ b/src/opendox/doxbench_install.py @@ -48,12 +48,19 @@ CHOICE between two declarations, which is exactly the kind of install-time fact this module exists to make readable in one place. -THE UNCONFIGURED POSTURE IS UNCHANGED, BYTE FOR BYTE. A checkout with no -bindings document, or one declaring no bindings, resolves the SAME -`model_port_factory(session_root)` the entrypoints have always resolved, so an -install that never heard of a broker behaves precisely as it did before this -change — and a plane with no factory at all still refuses -`model_capability_unavailable` exactly as it always has. +THE UNCONFIGURED POSTURE IS A STATE (#1144's 16.4; plan 034's T081). A checkout +with no approved binding resolves the SAME `model_port_factory(session_root)` +the entrypoints have always resolved WHERE THE HARNESS IS INSTALLED, so the +harness route stays for an install that has it. Where it is not (`omp`, +`doxbench_bridge.HARNESS_COMMAND`, is not on the PATH: `harness_installed()`), +there is no model, and the declaration says so: it resolves +`doxbench_model.NO_MODEL_CONFIGURED`, whose catalog offers no available entry, +and which `serve_workbench`'s accessor answers as no port at all. So the served +catalog is the editor-only posture before any turn, and a turn is refused +`model_capability_unavailable` before any process is spawned or any endpoint is +contacted. Until T081, the harness declaration answered here whether or not +`omp` existed, and its catalog offered `omp-local` as available: an install +with no model read as one with a model until a turn failed. ONE INSTANCE PER PROCESS, and that is a requirement rather than an optimisation. `_workbench_model_port` is called PER REQUEST, and `OmpHarnessBridge` is @@ -74,11 +81,13 @@ from __future__ import annotations +import shutil import sys import threading from pathlib import Path from opendox import doxbench_bridge as bridge_mod +from opendox import doxbench_model from opendox.doxbench_model import ModelCatalog, ModelCatalogEntry # -------------------------------------------------------------------------- @@ -188,6 +197,22 @@ def resolve() -> bridge_mod.OmpHarnessBridge: return resolve +def harness_installed() -> bool: + """Is the harness installed: is `doxbench_bridge.HARNESS_COMMAND` on the + PATH this process resolves commands from? + + The same question #1144's F16.1 asks as its precondition (`command -v omp`), + asked without starting anything: it reads the PATH and spawns no process.""" + return shutil.which(bridge_mod.HARNESS_COMMAND) is not None + + +def no_model_port_factory() -> doxbench_model.NoModelConfigured: + """The ZERO-ARGUMENT factory for an install with no model: it answers + `doxbench_model.NO_MODEL_CONFIGURED`, the one no-model port, and builds, + spawns and contacts nothing.""" + return doxbench_model.NO_MODEL_CONFIGURED + + # -------------------------------------------------------------------------- # the BROKERED declaration (add-model-provider-broker tasks 2.2/2.5) # -------------------------------------------------------------------------- @@ -275,7 +300,8 @@ def resolve(): def declared_model_port_factory(session_root: Path | str, *, checkout_root: Path | str, bindings_path: Path | str | None = None, - spawn=None): + spawn=None, + harness_present=None): """THE declaration both entrypoints make (task 2.5). ONE rule, in one place, so `cli.cmd_generate_and_open` and `serve.serve()` @@ -284,13 +310,17 @@ def declared_model_port_factory(session_root: Path | str, *, * a checkout declaring a model-provider BINDING resolves the brokered port for the FIRST declared binding, and every provider endpoint and every minted token it needs lives inside `doxbench_provider`; - * a checkout declaring NONE resolves exactly what these entrypoints have - always resolved — the harness bridge — so the unconfigured posture is - unchanged byte for byte; + * a checkout declaring NONE resolves the harness bridge where the harness + is installed, exactly as these entrypoints always have, and + `doxbench_model.NO_MODEL_CONFIGURED` where it is not (#1144's 16.4; + the module docstring). `harness_present` is that question, a + zero-argument callable, `harness_installed` by default, so a caller + that exercises a harness turn through `spawn` can say the harness is + there; * a bindings document that will not READ (malformed YAML, a wrong kind, a - record naming an unknown key) resolves the harness declaration too, and - says so on stderr. Refusing to serve at all would make one bad line in - an operator's settings file take the whole console down, and silently + record naming an unknown key) is read as declaring none, and says so + on stderr. Refusing to serve at all would make one bad line in an + operator's settings file take the whole console down, and silently serving a DIFFERENT provider than the one declared would be worse than either. @@ -319,7 +349,7 @@ def declared_model_port_factory(session_root: Path | str, *, except binding_mod.BindingRefused as error: sys.stderr.write( f"[model-provider] the bindings document could not be read " - f"({error}); serving the local harness declaration instead\n") + f"({error}); reading it as declaring no binding\n") declared = () pending = intake_mod.pending_binding_ids(checkout_root) approved = tuple(binding for binding in declared @@ -335,5 +365,7 @@ def declared_model_port_factory(session_root: Path | str, *, "human approval and contribute no available model; approve them " "from the console's model intake flow\n") if not approved: - return model_port_factory(Path(session_root), spawn=spawn) + if (harness_present or harness_installed)(): + return model_port_factory(Path(session_root), spawn=spawn) + return no_model_port_factory return brokered_model_port_factory(approved[0]) diff --git a/src/opendox/doxbench_model.py b/src/opendox/doxbench_model.py index 13d22621..8ec3973e 100644 --- a/src/opendox/doxbench_model.py +++ b/src/opendox/doxbench_model.py @@ -1062,6 +1062,66 @@ def catalog(self) -> ModelCatalog: ... def dispatch(self, prompt_envelope: object) -> object: ... +# --------------------------------------------------------------------------- +# "no model configured" (#1144's 16.4; plan 034's T081) +# --------------------------------------------------------------------------- + +#: How to configure a model, said where the rail says that none is. The chat +#: rail (`web/views/doxbench-chat.js`) restates it verbatim, and +#: `tests/test_chat_model_configuration.py` holds the two spellings together. +NO_MODEL_CONFIGURED_REMEDY = ( + "No model configured. To configure one, declare a model binding with " + "\"opendox model-binding add\" (\"--help\" lists its fields), or put the " + "local harness \"omp\" on PATH, then restart this console.") + + +class NoModelConfiguredError(RuntimeError): + """A turn was handed to the port an install declares when it has no model. + Nothing was spawned and nothing was contacted.""" + + +class NoModelConfigured: + """THE PORT AN INSTALL DECLARES WHEN IT HAS NO MODEL (#1144's 16.4). + + 16.4 measured the gap: with no binding, the entrypoints resolved the + harness declaration, whose catalog offers `omp-local` as AVAILABLE with no + `omp` on the PATH, so an install with no model read as one with a model + until a turn failed. `doxbench_install.declared_model_port_factory` + answers THIS port instead when no approved binding is declared and the + harness is absent, and every reader sees the state before any turn: + + * `catalog()` is `EMPTY_CATALOG`, so the catalog offers no available + entry; + * `serve_workbench`'s model-port accessor answers this port as NO port, so + the served catalog is the editor-only posture and a turn or an abstract + is refused `model_capability_unavailable`, the fixed code a plane with + no model capability has always given; + * `dispatch()` refuses without spawning or contacting anything, for a + caller that reaches it directly. + + One instance, `NO_MODEL_CONFIGURED`, which the accessor recognises by + identity, so no adapter can claim the posture by imitation.""" + + __slots__ = () + + @property + def timeout_seconds(self) -> float: + return 1.0 + + def catalog(self) -> ModelCatalog: + return EMPTY_CATALOG + + def dispatch(self, prompt_envelope: object) -> object: + raise NoModelConfiguredError(NO_MODEL_CONFIGURED_REMEDY) + + def __repr__(self) -> str: + return "" + + +#: The one no-model port. +NO_MODEL_CONFIGURED = NoModelConfigured() + + def validated_timeout_seconds(value: object) -> float: """Pure validator: accepts a real, non-bool number strictly greater than zero and no greater than ``MAX_ADAPTER_TIMEOUT_SECONDS``; refuses every diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 037ed2fb..384579ad 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -159,15 +159,27 @@ def _workbench_model_port(self): adapter is stateful -- the harness bridge the entrypoints declare holds per-document-thread sessions -- the factory returns ONE instance for the life of the process and this accessor hands back that same object - on every request. Nothing here may assume a per-request adapter.""" + on every request. Nothing here may assume a per-request adapter. + + "NO MODEL CONFIGURED" IS ABSENCE TOO (#1144's 16.4; plan 034's T081). + An install with no approved binding and no harness declares + `doxbench_model.NO_MODEL_CONFIGURED`, and this accessor answers that + one port, recognised by identity, as no port: so the catalog route + serves the editor-only posture and a turn or an abstract is refused + `model_capability_unavailable` before anything is spawned or + contacted.""" if not self.capabilities.get("actions", {}).get("session"): return None if self.model_port_factory is None: return None try: - return self.model_port_factory() + port = self.model_port_factory() except Exception: # noqa: BLE001 - absence is a capability verdict return None + from opendox import doxbench_model + if port is doxbench_model.NO_MODEL_CONFIGURED: + return None + return port # The largest corpus one tile's index is built from. A bound, not a # policy: a tile's staged set is a topic folder, and an index that grew diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index 6a496f98..add3321f 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -309,6 +309,34 @@ function unavailabilityNote(stateValue) { return CHAT_UNAVAILABLE_NOTE; } +// "NO MODEL CONFIGURED" IS A STATE, SHOWN BEFORE ANY TURN, WITH HOW TO +// CONFIGURE ONE (#1144's 16.4; plan 034's T081). The configured-none sentence +// above stays byte for byte: add-doxchat-model-intake §1 keeps "the rail's +// existing sentence" stating that no approved model is configured, as the +// send button's stated reason. What it never said is HOW (plan 034's research +// R15), and an install with no model and no intake flow, which is every +// standalone one, had no other place that said it. So this is a SEPARATE, +// VISIBLE line with the remedy, and it shows only when that is the state: +// the catalog has ANSWERED, it offers no available entry, no catalog failure +// is recorded (a stale token or an unreadable answer has its own remedy), and +// no intake affordance is rendered (where intake is offered, the selector's +// first option is the remedy's home, and a second statement of it would be the +// "second, weaker statement" the intake requirement refuses). The Python twin +// is `doxbench_model.NO_MODEL_CONFIGURED_REMEDY`, which +// tests/test_chat_model_configuration.py holds to this spelling. +export const NO_MODEL_CONFIGURED_REMEDY = + "No model configured. To configure one, declare a model binding with \"opendox model-binding add\" (\"--help\" lists its fields), or put the local harness \"omp\" on PATH, then restart this console."; + +export function noModelConfiguredRemedy(stateValue) { + if (stateValue.catalogFailure) return null; + if (stateValue.models === null) return null; + if ((stateValue.models || []).some((entry) => entry.available === true)) { + return null; + } + if (stateValue.intakeOffered === true) return null; + return NO_MODEL_CONFIGURED_REMEDY; +} + // T104 F10-2/4: the over-bound paste, refused VISIBLY. The pure model // refuses by returning the IDENTICAL state (refused, never truncated) and // render()'s unconditional value reassignment reverts the DOM — correct, but @@ -1098,6 +1126,10 @@ export function mountDoxBenchChatRail(host, options = {}) { // unavailabilityNote derives from the state's own facts. const unavailableNote = el("div", "doxchat-unavailable doxchat-sronly", CHAT_CATALOG_LOADING_NOTE); + // 16.4's visible line (`noModelConfiguredRemedy`): hidden until the catalog + // has answered with nothing available. + const noModelNote = el("div", "doxchat-no-model"); + noModelNote.hidden = true; const transcriptList = el("ul", "doxchat-transcript"); transcriptList.setAttribute("aria-label", "chat transcript"); const failureNote = el("div", "doxchat-failure"); @@ -1154,8 +1186,9 @@ export function mountDoxBenchChatRail(host, options = {}) { sendBtn.setAttribute("aria-describedby", unavailableNote.id); host.append(loadedSelect, loadedNote, loadedEmpty, loadedFull, subjectInput, - unavailableNote, transcriptList, contextNote, retryNote, - cardsHost, announce, failureNote, composer, disclosure, sendrow); + unavailableNote, noModelNote, transcriptList, contextNote, + retryNote, cardsHost, announce, failureNote, composer, + disclosure, sendrow); // SELECTING IS IMMEDIATE, and it is not a state authority: the seam owns the // move, `state.active_buffer` remains the one answer, and this handler only @@ -1467,6 +1500,9 @@ export function mountDoxBenchChatRail(host, options = {}) { // would trade a statement of posture for a call to action, and the posture is // the fact the human needs. unavailableNote.textContent = selectable ? "" : unavailabilityNote(state); + const remedy = noModelConfiguredRemedy(state); + noModelNote.textContent = remedy || ""; + noModelNote.hidden = !remedy; selector.value = defaultSelectorValue(state); transcriptList.textContent = ""; for (const turn of transcriptWindow(state)) { diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index e0c8b0bc..dbfcd6d3 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18073} + A: {files: 26, loc: 18109} B: {files: 1, loc: 73} C: {files: 14, loc: 12587} "?": {files: 1, loc: 1495} @@ -278,7 +278,7 @@ files: - path: views/doxbench-chat.js class: A - loc: 1828 + loc: 1864 note: "doxBench chat rail; imports only the pure chat model" - path: views/doxbench-editor.js diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py new file mode 100644 index 00000000..95b31e54 --- /dev/null +++ b/tests/test_chat_model_configuration.py @@ -0,0 +1,496 @@ +"""Chat's model configuration, with NO MODEL CONFIGURED: #1144's 16.4, which +plan 034's T081 realizes (requirement 17's third scenario, *"No model is +configured"*). + +16.4 measured the gap at `1e4a57fb`. With no binding, +`declared_model_port_factory(...)()` resolved the harness declaration, and its +catalog offered `omp-local` as AVAILABLE with no `omp` on the PATH. So an install +with no model read as one with a model until a turn failed. This file holds the +state 16.4 asks for: + +1. F16.1'S CATALOG BLOCK, AS WRITTEN. With no binding and no harness, the + catalog offers no available entry. +2. THE DECLARATION, STATE BY STATE. No approved binding and no harness gives + openDox's no-model port. A harness on the PATH gives the harness bridge, so + the harness route stays, and resolving it spawns nothing. The binding + states the port reads are each pinned: + - a hand- or CLI-declared binding, which the intake document says nothing + about, makes the port present; + - a `pending` intake declaration is suppressed; + - an `approved` one makes the port present; + - an unreadable bindings document is read as declaring none. +3. THE PORT AND THE ACCESSOR. The no-model port is a `WorkbenchModelPort`, + its catalog is empty, and its `dispatch` refuses without spawning or + contacting anything. `serve_workbench`'s accessor answers it, and only it, + as no port. +4. THE SERVED ROUTES, STANDALONE. A `generate-and-open` child with neither + sibling importable (`tests/standalone_child.py`) answers the catalog route + 200 with no available entry. This is T085's falsifier's second half, and + it needs T085's validators. +5. THE CHAT RAIL. Mounted over an empty catalog with no intake flow, the rail + shows "No model configured" and how to configure one, visibly and before + any turn, while the send button keeps its existing sentence byte for byte. + The line shows in that state only. Its spelling is held to the Python + twin's. + +`omp` is `doxbench_bridge.HARNESS_COMMAND`. A case that means "no harness" +says so with `harness_present=lambda: False`, or with a PATH holding no `omp`, +and asserts it. + +Plan 034's T082 adds 16.5 here (every other surface, with no model), and +T078–T080 add the configured turn over a stand-in OpenAI-compatible server. +F16.1's last line runs this file whole once all of them have landed (T083). + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import json +import os +import re +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest +import yaml + +from opendox import doxbench_binding as binding_mod +from opendox import doxbench_bridge as bridge_mod +from opendox import doxbench_install as inst +from opendox import doxbench_intake as intake_mod +from opendox import doxbench_model +from opendox import serve_wire +from opendox import validator as own +from opendox.serve_workbench import WorkbenchRoutes +from session_fixtures import GATE_TEST_PRINCIPALS +from standalone_child import Child, fresh_repository + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +EXAMPLES = ROOT / "tests" / "fixtures" / "spec-examples" +CHAT_VIEW_JS = ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat.js" +CHAT_MODEL_JS = ROOT / "src" / "opendox" / "web" / "views" / "doxbench-chat-model.js" +NODE = shutil.which("node") + +#: The console's human, one of the suite's declared principals. +ACTOR = "tester" + +#: The rail's existing configured-none sentence, which add-doxchat-model-intake +#: §1 keeps byte for byte. +CONFIGURED_NONE = ("chat is unavailable — no approved model is configured; both " + "editors remain fully usable.") + + +def _no_omp_path(tmp_path: Path) -> str: + """This process's PATH with every directory holding `omp` left out.""" + kept = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) + if entry and shutil.which(bridge_mod.HARNESS_COMMAND, path=entry) is None] + path = os.pathsep.join(kept) + assert shutil.which(bridge_mod.HARNESS_COMMAND, path=path) is None + return path + + +def _fake_omp(tmp_path: Path) -> Path: + """A directory holding an executable named `omp`, which is never run.""" + bin_dir = tmp_path / "harness-bin" + bin_dir.mkdir() + omp = bin_dir / bridge_mod.HARNESS_COMMAND + omp.write_text("#!/bin/sh\necho 'a fake harness must never run' >&2\nexit 97\n", + encoding="utf-8") + omp.chmod(0o755) + return bin_dir + + +def _binding(**overrides) -> binding_mod.ModelProviderBinding: + fields = dict(id="a-provider", label="A provider", provider="a-provider", + credential_ref="opref-0000000000000000", auth_kind="api_key", + approved_by="tester", endpoint="https://provider.invalid/turn", + dialect=binding_mod.DIALECT_XFACTORY_PROMPT_V1, + broker_argv=("a-broker",)) + fields.update(overrides) + return binding_mod.ModelProviderBinding(**fields) + + +def _declare(checkout: Path, binding=None) -> binding_mod.ModelProviderBinding: + """A binding declared in the checkout's bindings document, as + `opendox model-binding add` declares one.""" + binding = binding or _binding() + binding_mod.BindingStore(binding_mod.bindings_path(checkout)).add(binding) + return binding + + +def _resolve(tmp_path: Path, checkout: Path, *, harness: bool, spawn=None): + return inst.declared_model_port_factory( + tmp_path / "sessions", checkout_root=checkout, spawn=spawn, + harness_present=lambda: harness)() + + +@pytest.fixture +def checkout(tmp_path) -> Path: + root = tmp_path / "checkout" + root.mkdir() + return root + + +class _NoSpawn: + """A `spawn` seam that records any call. Resolving a port must not call it.""" + + def __init__(self): + self.calls = [] + + def __call__(self, *args, **kwargs): + self.calls.append((args, kwargs)) + raise AssertionError("a child process was spawned") + + +# --------------------------------------------------------------------------- +# 1 — F16.1's catalog block, as written +# --------------------------------------------------------------------------- + +_F16_1_CATALOG_BLOCK = textwrap.dedent(''' + import pathlib, sys + from opendox import doxbench_install as inst + root = pathlib.Path(sys.argv[1]) / "no-model" + root.mkdir() + port = inst.declared_model_port_factory(root / "sessions", checkout_root=root)() + offered = [e.model_id for e in port.catalog().available_entries()] + assert not offered, f"no model is configured, yet the catalog offers {offered}" + print("no model configured: the catalog offers nothing") + ''') + + +def test_F16_1s_catalog_block_with_no_binding_and_no_harness(tmp_path) -> None: + """#1144's F16.1, its 16.4 block word for word, in a process whose PATH + holds no `omp` (F16.1's own precondition). At `047bb4fa` it failed with + `['omp-local']`.""" + done = subprocess.run( + [sys.executable, "-", str(tmp_path)], input=_F16_1_CATALOG_BLOCK, + capture_output=True, text=True, cwd=ROOT, timeout=120, + env=dict(os.environ, PATH=_no_omp_path(tmp_path))) + assert done.returncode == 0, done.stderr + assert done.stdout.strip() == "no model configured: the catalog offers nothing" + + +# --------------------------------------------------------------------------- +# 2 — the declaration, state by state +# --------------------------------------------------------------------------- + +def test_no_binding_and_no_harness_is_the_no_model_port(tmp_path, checkout) -> None: + spawn = _NoSpawn() + port = _resolve(tmp_path, checkout, harness=False, spawn=spawn) + assert port is doxbench_model.NO_MODEL_CONFIGURED + assert port.catalog().available_entries() == () + assert spawn.calls == [] + + +def test_the_harness_route_stays_where_the_harness_is_installed( + tmp_path, checkout, monkeypatch) -> None: + """With `omp` on the PATH, the default probe finds it, and the harness + bridge answers exactly as it always has. Resolving it spawns nothing.""" + monkeypatch.setenv("PATH", str(_fake_omp(tmp_path)) + os.pathsep + + _no_omp_path(tmp_path)) + assert inst.harness_installed() is True + spawn = _NoSpawn() + port = inst.declared_model_port_factory( + tmp_path / "sessions", checkout_root=checkout, spawn=spawn)() + assert isinstance(port, bridge_mod.OmpHarnessBridge) + assert [entry.model_id for entry in port.catalog().available_entries()] == \ + [inst.HARNESS_MODEL_ID] + assert spawn.calls == [] + + +def test_the_default_probe_reads_the_path(tmp_path, monkeypatch) -> None: + monkeypatch.setenv("PATH", _no_omp_path(tmp_path)) + assert inst.harness_installed() is False + monkeypatch.setenv("PATH", str(_fake_omp(tmp_path))) + assert inst.harness_installed() is True + + +def test_a_hand_declared_binding_makes_the_port_present(tmp_path, checkout) -> None: + """A binding `opendox model-binding add` declares writes the bindings + document alone, and the intake document says nothing about it, so it is + never `pending`. That is how a standalone install configures a model + without the console's approval route.""" + binding = _declare(checkout) + from opendox import doxbench_provider as provider_mod + port = _resolve(tmp_path, checkout, harness=False) + assert isinstance(port, provider_mod.BrokeredProviderPort) + assert [e.model_id for e in port.catalog().available_entries()] == [binding.id] + + +def _intake(checkout: Path) -> intake_mod.DeclarationStore: + return intake_mod.DeclarationStore(intake_mod.declarations_path(checkout)) + + +def _pending(binding_id: str) -> intake_mod.ModelDeclaration: + return intake_mod.ModelDeclaration( + binding_id=binding_id, status=intake_mod.STATUS_PENDING, + install_posture=intake_mod.POSTURE_SINGLE_OPERATOR, proposed_by="tester", + proposed_at="2026-10-02T00:00:00Z") + + +def test_a_pending_declaration_is_suppressed(tmp_path, checkout, capsys) -> None: + binding = _declare(checkout) + _intake(checkout).propose(_pending(binding.id)) + assert _resolve(tmp_path, checkout, harness=False) is \ + doxbench_model.NO_MODEL_CONFIGURED + assert "pending human approval" in capsys.readouterr().err + + +def test_an_approved_declaration_makes_the_port_present(tmp_path, checkout) -> None: + binding = _declare(checkout) + store = _intake(checkout) + store.propose(_pending(binding.id)) + store.approve(binding.id, issued_by="tester", approved_by="tester", + expires_at="2027-01-01T00:00:00Z", audit_ref="audit-0001") + from opendox import doxbench_provider as provider_mod + assert isinstance(_resolve(tmp_path, checkout, harness=False), + provider_mod.BrokeredProviderPort) + + +def test_an_unreadable_bindings_document_is_read_as_declaring_none( + tmp_path, checkout, capsys) -> None: + path = binding_mod.bindings_path(checkout) + path.parent.mkdir(parents=True) + path.write_text("schema_version: 9\nkind: something-else\n", encoding="utf-8") + assert _resolve(tmp_path, checkout, harness=False) is \ + doxbench_model.NO_MODEL_CONFIGURED + assert "reading it as declaring no binding" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# 3 — the port, and the accessor that answers it as no port +# --------------------------------------------------------------------------- + +def test_the_no_model_port_is_a_port_that_offers_nothing_and_refuses(monkeypatch) -> None: + port = doxbench_model.NO_MODEL_CONFIGURED + assert isinstance(port, doxbench_model.WorkbenchModelPort) + assert port.catalog() is doxbench_model.EMPTY_CATALOG + doxbench_model.validated_timeout_seconds(port.timeout_seconds) + + def refused(*args, **kwargs): + raise AssertionError("dispatch reached a process or the network") + + import socket + monkeypatch.setattr(subprocess, "Popen", refused) + monkeypatch.setattr(socket, "create_connection", refused) + with pytest.raises(doxbench_model.NoModelConfiguredError) as raised: + port.dispatch(object()) + assert str(raised.value) == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + + +class _Plane: + """The two facts `_workbench_model_port` reads, and nothing else.""" + + capabilities = {"actions": {"session": True}} + + def __init__(self, factory): + self.model_port_factory = factory + + +def test_the_accessor_answers_the_no_model_port_as_no_port() -> None: + accessor = WorkbenchRoutes._workbench_model_port + assert accessor(_Plane(inst.no_model_port_factory)) is None + other = doxbench_model.NoModelConfigured() + assert accessor(_Plane(lambda: other)) is other, \ + "only THE no-model port is absence; anything else is a port" + real = object() + assert accessor(_Plane(lambda: real)) is real + + +# --------------------------------------------------------------------------- +# 4 — the served routes, standalone +# --------------------------------------------------------------------------- + +_URL = re.compile(r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +def _request(base, method, path, *, body=None, headers=None): + connection = http.client.HTTPConnection(*base, timeout=30) + try: + connection.request(method, path, body=body, headers=headers or {}) + response = connection.getresponse() + return response.status, json.loads(response.read() or b"null") + finally: + connection.close() + + +@pytest.fixture +def standalone(tmp_path, monkeypatch): + """A `generate-and-open` child over T050's fixture, with neither sibling + importable, no binding, and a PATH holding no `omp`. Answers + `(base, console token, child)`.""" + assert ACTOR in GATE_TEST_PRINCIPALS + repo = fresh_repository(PLAIN, tmp_path) + monkeypatch.setenv("PATH", _no_omp_path(tmp_path)) # the child inherits it + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", "--run-dir", str(tmp_path / "run"), + "--actor", ACTOR) + try: + match = child.wait_for_line(_URL) + base = (match.group(2), int(match.group(3))) + status, capabilities = _request(base, "GET", "/capabilities") + assert status == 200 and capabilities["actions"]["session"] is True + yield base, capabilities["console_token"], child + assert child.interrupt() == 0, child.stderr_text() + assert child.refused() == [], child.refused() + assert "Traceback" not in child.stderr_text(), child.stderr_text() + finally: + child.kill() + + +def test_the_served_catalog_offers_no_available_entry(standalone) -> None: + """T085's falsifier, whole: the served catalog route answers standalone, + with no available entry, in an envelope openDox's own validator accepts.""" + base, token, _child = standalone + status, envelope = _request(base, "GET", "/workbench/model-catalog", + headers={"X-XF-Console-Token": token}) + assert status == 200, envelope + assert envelope["kind"] == serve_wire.DOXBENCH_MODEL_CATALOG_KIND + assert own.validate(envelope) == [] + assert [m for m in envelope["models"] if m["available"]] == [] + + +# --------------------------------------------------------------------------- +# 5 — the chat rail: "No model configured", and how to configure one +# --------------------------------------------------------------------------- + +def test_the_rails_remedy_is_spelled_as_the_python_twin() -> None: + source = CHAT_VIEW_JS.read_text(encoding="utf-8") + match = re.search(r'export const NO_MODEL_CONFIGURED_REMEDY =\s*("(?:[^"\\]|\\.)*");', + source) + assert match, "the rail declares NO_MODEL_CONFIGURED_REMEDY as one literal" + assert json.loads(match.group(1)) == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + remedy = doxbench_model.NO_MODEL_CONFIGURED_REMEDY + assert remedy.startswith("No model configured.") + assert '"opendox model-binding add"' in remedy and '"omp"' in remedy + + +_RAIL_HARNESS = r""" +class Node { + constructor(tag) { + this.tagName = String(tag).toUpperCase(); + this.children = []; this.attributes = {}; this.listeners = {}; + this.className = ''; this._text = ''; this.hidden = false; + this.disabled = false; this.value = ''; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent).join(''); + } + set textContent(value) { this.children = []; this._text = String(value); } + appendChild(child) { child.parentNode = this; this.children.push(child); return child; } + append(...kids) { for (const k of kids) this.appendChild(k); } + setAttribute(name, value) { this.attributes[name] = String(value); } + getAttribute(name) { + return Object.prototype.hasOwnProperty.call(this.attributes, name) + ? this.attributes[name] : null; + } + addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); } + focus() {} + walk() { return this.children.reduce((a, c) => a.concat(c.walk()), [this]); } +} +const doc = { createElement: (tag) => new Node(tag), activeElement: null }; +const byClass = (root, cls) => root.walk().filter( + (n) => String(n.className).split(' ').includes(cls)); + +import { mountDoxBenchChatRail, NO_MODEL_CONFIGURED_REMEDY } from "./doxbench-chat.mjs"; + +const KEY = { repository: "fixture", ref: "main", tile_kind: "staged", + tile_id: "a-topic" }; +const ENTRY = { model_id: "model-a", label: "A model", provider_class: "local", + available: true, input_limit_bytes: 800000, output_limit_bytes: 900000, + data_handling: "on this host" }; +const OFF = { ...ENTRY, model_id: "model-off", available: false }; +const bufferOf = (kind, path) => ({ kind, path, base_ref: "main", + base_revision: "r1", base_hash: { algorithm: "sha256", hex: "c".repeat(64) }, + current_hash: { algorithm: "sha256", hex: "d".repeat(64) }, + hash_pending: false, content: "# " + kind, dirty: false }); +const editorState = () => ({ active_buffer: "document", buffers: { + outline: bufferOf("outline", "docs/outline.md"), + document: bufferOf("document", "docs/detail.md") } }); + +async function mount(catalog, { intake = null } = {}) { + const host = new Node("div"); host.ownerDocument = doc; + let turns = 0; + let release; + const gate = new Promise((res) => { release = res; }); + const rail = mountDoxBenchChatRail(host, { + scopeKey: KEY, + transports: { catalog: async () => { await gate; return catalog(); }, + chatTurn: async () => { turns += 1; return null; } }, + editorState }); + const line = () => byClass(host, "doxchat-no-model")[0] || null; + const shown = () => (line() && !line().hidden) ? line().textContent : null; + const loading = shown(); + release(); + await rail.ready; + if (intake !== null) rail.intakeOffer(intake); + const send = byClass(host, "doxchat-send")[0]; + return { loading, shown: shown(), exists: Boolean(line()), turns, + sendDisabled: send.disabled === true, sendTitle: send.title, + srNote: (byClass(host, "doxchat-unavailable")[0] || {}).textContent }; +} + +const empty = () => ({ schema_version: 1, kind: "workbench-model-catalog", models: [] }); +const out = { + remedy: NO_MODEL_CONFIGURED_REMEDY, + empty: await mount(empty), + onlyUnavailable: await mount(() => ({ schema_version: 1, + kind: "workbench-model-catalog", models: [OFF] })), + available: await mount(() => ({ schema_version: 1, + kind: "workbench-model-catalog", models: [ENTRY] })), + unreadable: await mount(() => null), + intakeOffered: await mount(empty, { intake: true }), +}; +process.stdout.write(JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def rail(tmp_path_factory) -> dict: + if NODE is None: + pytest.skip("node not available for the chat rail's no-model probe") + work = tmp_path_factory.mktemp("no-model-rail") + source = CHAT_VIEW_JS.read_text(encoding="utf-8").replace( + "./doxbench-chat-model.js", "./doxbench-chat-model.mjs") + (work / "doxbench-chat.mjs").write_text(source, encoding="utf-8") + shutil.copy(CHAT_MODEL_JS, work / "doxbench-chat-model.mjs") + harness = work / "harness.mjs" + harness.write_text(_RAIL_HARNESS, encoding="utf-8") + done = subprocess.run([NODE, str(harness)], capture_output=True, text=True, + timeout=60) + assert done.returncode == 0, done.stderr + return json.loads(done.stdout) + + +def test_the_rail_shows_no_model_configured_and_how_before_any_turn(rail) -> None: + empty = rail["empty"] + assert empty["shown"] == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + assert empty["turns"] == 0, "shown before any turn, and no turn was sent" + assert empty["sendDisabled"] is True + # the send button's own reason is the existing sentence, byte for byte + assert empty["srNote"] == CONFIGURED_NONE + assert empty["sendTitle"] == CONFIGURED_NONE + + +def test_a_catalog_of_unavailable_entries_is_no_model_too(rail) -> None: + assert rail["onlyUnavailable"]["shown"] == doxbench_model.NO_MODEL_CONFIGURED_REMEDY + + +def test_the_line_shows_in_that_state_only(rail) -> None: + """Not while the catalog is loading (nothing is known yet), not with a + model available, not when the catalog could not be read (that has its + own remedy), and not where the intake flow is offered (its option is the + remedy's home).""" + for case in ("empty", "onlyUnavailable", "available", "unreadable", "intakeOffered"): + assert rail[case]["exists"] is True, case + assert rail[case]["loading"] is None, case + assert rail["available"]["shown"] is None + assert rail["unreadable"]["shown"] is None + assert rail["intakeOffered"]["shown"] is None diff --git a/tests/test_model_provider_broker.py b/tests/test_model_provider_broker.py index 4a5fbaea..e33f5a10 100644 --- a/tests/test_model_provider_broker.py +++ b/tests/test_model_provider_broker.py @@ -380,8 +380,12 @@ def poisoned(name, *args, **kwargs): monkeypatch.setattr(builtins, "__import__", poisoned) monkeypatch.delitem(sys.modules, "yaml", raising=False) + # The harness is PRESENT here (plan 034 T081): with it absent, a document + # read as declaring nothing resolves the no-model port instead, which + # tests/test_chat_model_configuration.py holds. resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) from opendox import doxbench_bridge as bridge_mod assert isinstance(resolve(), bridge_mod.OmpHarnessBridge) assert "bindings document could not be read" in capsys.readouterr().err @@ -1159,11 +1163,14 @@ def test_the_port_satisfies_the_seam_without_growing_a_fourth_verb(tmp_path): def test_a_checkout_with_no_bindings_resolves_exactly_the_harness_declaration( tmp_path): """TASK 3.3. Not "a port of the same kind" — the SAME construction the - entrypoints have always made.""" + entrypoints have always made, WHERE THE HARNESS IS INSTALLED (plan 034 + T081, #1144's 16.4). With it absent, there is no model, and + tests/test_chat_model_configuration.py holds that state.""" checkout = tmp_path / "checkout" checkout.mkdir() resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) port = resolve() from opendox import doxbench_bridge as bridge_mod assert isinstance(port, bridge_mod.OmpHarnessBridge) @@ -1191,8 +1198,10 @@ def test_an_unreadable_bindings_document_falls_back_and_says_so(tmp_path, path.parent.mkdir(parents=True) path.write_text("schema_version: 9\nkind: something-else\n", encoding="utf-8") + # The harness is PRESENT here (plan 034 T081), as in the case above. resolve = install_mod.declared_model_port_factory( - tmp_path / "sessions", checkout_root=checkout) + tmp_path / "sessions", checkout_root=checkout, + harness_present=lambda: True) from opendox import doxbench_bridge as bridge_mod assert isinstance(resolve(), bridge_mod.OmpHarnessBridge) assert "bindings document could not be read" in capsys.readouterr().err From 0a12dc58ef161922c19231c34dd0da99e2fdf51e Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:36:33 +0000 Subject: [PATCH 04/10] T081: a turn with no model port is refused before step 5 reads the scope (16.4) Measured with a schema-valid v2 turn from a standalone generate-and-open child, siblings refused: - at main 047bb4fa it answered 403 model_capability_unavailable, but only because no validators were registered, so the hoisted validators refusal answered; - with T085's validators (openDox-code#71 e0298cf4) it reached step 5's "from openxdox import doxbench_scope" (serve_workbench.py:1669, T084's :1665 reach) and the connection dropped. Step 7's no-port refusal (the same _refuse_turn call, code and envelope) now also runs just above step 5. A plane with no model configured, or no model factory at all, refuses a well-formed turn there, before anything is spawned or contacted. It is the rule the validators refusal already keeps: a plane-level verdict outranks a defect in the caller's request. Schema and parse refusals still come first. Idempotency (step 8) stays after the model verdict, as it was. A plane WITH a model still meets step 5 as before. tests/test_chat_model_configuration.py gains the standalone turn case: 403, the v2 failure envelope with the request's client_turn_id, accepted by openDox's validator, no traceback, no sibling import. Held as its own commit, pending the holder's ruling on option (a). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/serve_workbench.py | 17 +++++++++++++++++ tests/test_chat_model_configuration.py | 24 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 384579ad..525d86d9 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1676,6 +1676,23 @@ def _handle_workbench_chat_turn(self) -> None: transcript_turns = fields["transcript_turns"] turn_buffers = fields["turn_buffers"] + # ---- the model verdict, AHEAD of step 5 (#1144's 16.4; plan 034's + # T081). A plane with NO model port refuses a well-formed turn here, + # with step 7's own code and envelope, before the scope is read: a + # plane-level verdict outranks any defect in the caller's request, the + # rule the validators refusal above keeps. It answers both an install + # with no model configured (`doxbench_model.NO_MODEL_CONFIGURED`, which + # the accessor answers as no port) and a plane with no factory at all, + # and it spawns nothing and contacts nothing. Step 7 keeps its own check + # for the port it then reads. Measured at openDox-code#71 `e0298cf4`, + # once T085's validators answered standalone: without this, a standalone + # turn reached step 5's scope import and the connection dropped. ---- + if self._workbench_model_port() is None: + self._refuse_turn(validators, + DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, + turn_id, failure_kind=failure_kind) + return + from opendox import doxbench_hash from opendox import doxbench_model from openxdox import doxbench_scope diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 95b31e54..0225c14f 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -26,7 +26,8 @@ 4. THE SERVED ROUTES, STANDALONE. A `generate-and-open` child with neither sibling importable (`tests/standalone_child.py`) answers the catalog route 200 with no available entry. This is T085's falsifier's second half, and - it needs T085's validators. + it needs T085's validators. The child also answers a schema-valid turn + `403 model_capability_unavailable`. 5. THE CHAT RAIL. Mounted over an empty catalog with no intake flow, the rail shows "No model configured" and how to configure one, visibly and before any turn, while the send button keeps its existing sentence byte for byte. @@ -357,6 +358,27 @@ def test_the_served_catalog_offers_no_available_entry(standalone) -> None: assert [m for m in envelope["models"] if m["available"]] == [] +def test_a_turn_is_refused_model_capability_unavailable(standalone) -> None: + """A schema-valid turn, which no rail sends with no model selected but any + client can, is refused with the fixed code and the contract's failure + envelope, and the child spawns no harness (none is on its PATH, and the + no-model port spawns nothing).""" + base, token, _child = standalone + request = yaml.safe_load( + (EXAMPLES / "workbench-chat-turn-v2-loaded-set.example.yaml").read_text( + encoding="utf-8")) + status, body = _request( + base, "POST", "/actions/workbench/chat-turn", + body=json.dumps(request).encode("utf-8"), + headers={"Content-Type": "application/json", "X-XF-Console-Token": token}) + code = serve_wire.DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE + assert status == serve_wire.doxbench_error_status(code) == 403, body + assert body["error"] == code, body + assert body["kind"] == serve_wire.DOXBENCH_CHAT_TURN_V2_FAILURE_KIND + assert body["client_turn_id"] == request["client_turn_id"] + assert own.validate(body) == [], own.report(own.validate(body)) + + # --------------------------------------------------------------------------- # 5 — the chat rail: "No model configured", and how to configure one # --------------------------------------------------------------------------- From 38a4bba9e06b5d798b8613ad9c34ac19426baff7 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:41:28 +0000 Subject: [PATCH 05/10] T081: probe the rail's remedy verdict over a failure beside an empty catalog recordCatalogFailure() keeps `models`. So a failure recorded beside an adopted empty catalog is a state the one-shot mount does not reach, and noModelConfiguredRemedy() must still give it no remedy line. The rail probe now calls the exported verdict over that state, for both failure markers. It kills the mutant that dropped the catalogFailure check, which survived the mounted cases alone (N09). Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_chat_model_configuration.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 0225c14f..6ea95b23 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -421,7 +421,8 @@ class Node { const byClass = (root, cls) => root.walk().filter( (n) => String(n.className).split(' ').includes(cls)); -import { mountDoxBenchChatRail, NO_MODEL_CONFIGURED_REMEDY } from "./doxbench-chat.mjs"; +import { mountDoxBenchChatRail, NO_MODEL_CONFIGURED_REMEDY, + noModelConfiguredRemedy } from "./doxbench-chat.mjs"; const KEY = { repository: "fixture", ref: "main", tile_kind: "staged", tile_id: "a-topic" }; @@ -469,6 +470,15 @@ class Node { kind: "workbench-model-catalog", models: [ENTRY] })), unreadable: await mount(() => null), intakeOffered: await mount(empty, { intake: true }), + // the pure verdict over states a mount does not reach in one shot: a + // failure recorded beside an adopted empty catalog keeps its own remedy + pure: { + emptyAdopted: noModelConfiguredRemedy({ models: [], catalogFailure: null }), + emptyThenUnreadable: noModelConfiguredRemedy( + { models: [], catalogFailure: "unreadable" }), + emptyThenStaleToken: noModelConfiguredRemedy( + { models: [], catalogFailure: "console_required" }), + }, }; process.stdout.write(JSON.stringify(out)); """ @@ -516,3 +526,5 @@ def test_the_line_shows_in_that_state_only(rail) -> None: assert rail["available"]["shown"] is None assert rail["unreadable"]["shown"] is None assert rail["intakeOffered"]["shown"] is None + assert rail["pure"] == {"emptyAdopted": doxbench_model.NO_MODEL_CONFIGURED_REMEDY, + "emptyThenUnreadable": None, "emptyThenStaleToken": None} From 9061b22a0fd5bba398834cb94d4144bc2d531efe Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:08:35 +0000 Subject: [PATCH 06/10] T081: the remedy is for an EMPTY catalog and is announced; test the turn order Copilot's review of openDox-code#74 at 38a4bba9, two findings: - r4169715519: a configured model can be unavailable. After a broker refusal, BrokeredProviderPort.catalog() keeps the binding with `available: false`, and the rail then told that operator to declare a binding they already have. noModelConfiguredRemedy() now answers the remedy for an EMPTY catalog only, which is what the server's no-model port serves. The onlyUnavailable case now asserts no line, plus a pure probe over the same state. - r4169715599: the catalog settles asynchronously, with no focus change, and the line was not a live region. render() now writes the remedy to the rail's polite `announce` region as well, behind the same only-when-it-changes guard the context and retry notes keep, so typing does not re-announce it. The harness counts the region's writes: once on arrival, still once after two keystrokes, and never in any other state. The holder's ruling on 0a12dc58 (option (a), keep the hoist) asked for one ordering test. test_the_turn_routes_order_with_and_without_a_port drives _handle_workbench_chat_turn itself, over openDox's real validators and identity checks, with only the HTTP plumbing and openxdox's scope module stood in: - a console, body, kind, schema or parse defect answers first in every posture; - with no port (no model configured, or no factory), the no-model refusal answers before a scope, identity or limits defect, and the scope is never read; - with a port, each defect answers what it did before the hoist, and a well-formed turn reaches step 7. The standalone turn case also sends the turn without the console token first, and gets console_required. The web census moves views/doxbench-chat.js from 1864 to 1877 loc, and the class-A total from 18109 to 18122. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/web/views/doxbench-chat.js | 41 ++-- tests/fixtures/web_boundary_census.yaml | 4 +- tests/test_chat_model_configuration.py | 283 ++++++++++++++++++++++-- 3 files changed, 295 insertions(+), 33 deletions(-) diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index add3321f..bb712ae8 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -317,22 +317,25 @@ function unavailabilityNote(stateValue) { // R15), and an install with no model and no intake flow, which is every // standalone one, had no other place that said it. So this is a SEPARATE, // VISIBLE line with the remedy, and it shows only when that is the state: -// the catalog has ANSWERED, it offers no available entry, no catalog failure -// is recorded (a stale token or an unreadable answer has its own remedy), and -// no intake affordance is rendered (where intake is offered, the selector's -// first option is the remedy's home, and a second statement of it would be the -// "second, weaker statement" the intake requirement refuses). The Python twin -// is `doxbench_model.NO_MODEL_CONFIGURED_REMEDY`, which -// tests/test_chat_model_configuration.py holds to this spelling. +// the catalog has ANSWERED, it is EMPTY, no catalog failure is recorded (a +// stale token or an unreadable answer has its own remedy), and no intake +// affordance is rendered (where intake is offered, the selector's first option +// is the remedy's home, and a second statement of it would be the "second, +// weaker statement" the intake requirement refuses). EMPTY, not "nothing +// available": a configured model can be unavailable — after a broker refusal +// `BrokeredProviderPort.catalog()` keeps the binding, `available: false` — and +// telling that operator to declare a binding they already have would send them +// to the wrong repair. The server's no-model port answers the empty catalog +// (`doxbench_model.NO_MODEL_CONFIGURED`), so empty is exactly "no binding and +// no harness". The Python twin is `doxbench_model.NO_MODEL_CONFIGURED_REMEDY`, +// which tests/test_chat_model_configuration.py holds to this spelling. export const NO_MODEL_CONFIGURED_REMEDY = "No model configured. To configure one, declare a model binding with \"opendox model-binding add\" (\"--help\" lists its fields), or put the local harness \"omp\" on PATH, then restart this console."; export function noModelConfiguredRemedy(stateValue) { if (stateValue.catalogFailure) return null; if (stateValue.models === null) return null; - if ((stateValue.models || []).some((entry) => entry.available === true)) { - return null; - } + if ((stateValue.models || []).length !== 0) return null; if (stateValue.intakeOffered === true) return null; return NO_MODEL_CONFIGURED_REMEDY; } @@ -1127,7 +1130,8 @@ export function mountDoxBenchChatRail(host, options = {}) { const unavailableNote = el("div", "doxchat-unavailable doxchat-sronly", CHAT_CATALOG_LOADING_NOTE); // 16.4's visible line (`noModelConfiguredRemedy`): hidden until the catalog - // has answered with nothing available. + // has answered empty. It is not itself a live region; render() ANNOUNCES its + // text through `announce` below, once per change. const noModelNote = el("div", "doxchat-no-model"); noModelNote.hidden = true; const transcriptList = el("ul", "doxchat-transcript"); @@ -1500,9 +1504,18 @@ export function mountDoxBenchChatRail(host, options = {}) { // would trade a statement of posture for a call to action, and the posture is // the fact the human needs. unavailableNote.textContent = selectable ? "" : unavailabilityNote(state); - const remedy = noModelConfiguredRemedy(state); - noModelNote.textContent = remedy || ""; - noModelNote.hidden = !remedy; + // ANNOUNCED, ONCE PER CHANGE. The catalog settles asynchronously and with + // no focus change, so a line that only appears is a line a screen-reader + // user is never told about: its text goes to the polite `announce` region + // too. Same only-when-it-changes guard as the posture and retry notes + // below, for the same measured reason: render() runs on every keystroke, + // and re-writing a live region with the same sentence re-announces it. + const remedyText = noModelConfiguredRemedy(state) || ""; + if (noModelNote.textContent !== remedyText) { + noModelNote.hidden = !remedyText; + noModelNote.textContent = remedyText; + if (remedyText) announce.textContent = remedyText; + } selector.value = defaultSelectorValue(state); transcriptList.textContent = ""; for (const turn of transcriptWindow(state)) { diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index dbfcd6d3..84a93114 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18109} + A: {files: 26, loc: 18122} B: {files: 1, loc: 73} C: {files: 14, loc: 12587} "?": {files: 1, loc: 1495} @@ -278,7 +278,7 @@ files: - path: views/doxbench-chat.js class: A - loc: 1864 + loc: 1877 note: "doxBench chat rail; imports only the pure chat model" - path: views/doxbench-editor.js diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 6ea95b23..69d4b410 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -27,12 +27,19 @@ sibling importable (`tests/standalone_child.py`) answers the catalog route 200 with no available entry. This is T085's falsifier's second half, and it needs T085's validators. The child also answers a schema-valid turn - `403 model_capability_unavailable`. -5. THE CHAT RAIL. Mounted over an empty catalog with no intake flow, the rail + `403 model_capability_unavailable`, and the same turn without the console + token `console_required`. + THE TURN ROUTE'S ORDER (the holder's ruling on the hoist, option (a)): a + console, body, kind, schema or parse defect answers first in every + posture; with no port, the no-model refusal answers before a scope, + identity or limits defect and the scope is never read; with a port, every + defect answers what it answered before the hoist. +5. THE CHAT RAIL. Mounted over an EMPTY catalog with no intake flow, the rail shows "No model configured" and how to configure one, visibly and before - any turn, while the send button keeps its existing sentence byte for byte. - The line shows in that state only. Its spelling is held to the Python - twin's. + any turn, and announces it once through its polite live region, while the + send button keeps its existing sentence byte for byte. The line shows in + that state only: a catalog of configured models that are unavailable is + not "no model configured". Its spelling is held to the Python twin's. `omp` is `doxbench_bridge.HARNESS_COMMAND`. A case that means "no harness" says so with `harness_present=lambda: False`, or with a PATH holding no `omp`, @@ -47,6 +54,8 @@ from __future__ import annotations +import copy +import dataclasses import http.client import json import os @@ -55,16 +64,20 @@ import subprocess import sys import textwrap +import types from pathlib import Path +from types import SimpleNamespace import pytest import yaml from opendox import doxbench_binding as binding_mod from opendox import doxbench_bridge as bridge_mod +from opendox import doxbench_hash from opendox import doxbench_install as inst from opendox import doxbench_intake as intake_mod from opendox import doxbench_model +from opendox import doxbench_turns from opendox import serve_wire from opendox import validator as own from opendox.serve_workbench import WorkbenchRoutes @@ -358,15 +371,28 @@ def test_the_served_catalog_offers_no_available_entry(standalone) -> None: assert [m for m in envelope["models"] if m["available"]] == [] +def _example_turn() -> dict: + return yaml.safe_load( + (EXAMPLES / "workbench-chat-turn-v2-loaded-set.example.yaml").read_text( + encoding="utf-8")) + + def test_a_turn_is_refused_model_capability_unavailable(standalone) -> None: """A schema-valid turn, which no rail sends with no model selected but any client can, is refused with the fixed code and the contract's failure envelope, and the child spawns no harness (none is on its PATH, and the - no-model port spawns nothing).""" + no-model port spawns nothing). The CONSOLE verdict still comes first: the + same turn without the console token is refused `console_required`, so an + unauthenticated caller learns nothing about the model posture.""" base, token, _child = standalone - request = yaml.safe_load( - (EXAMPLES / "workbench-chat-turn-v2-loaded-set.example.yaml").read_text( - encoding="utf-8")) + request = _example_turn() + status, body = _request( + base, "POST", "/actions/workbench/chat-turn", + body=json.dumps(request).encode("utf-8"), + headers={"Content-Type": "application/json"}) + console = serve_wire.DOXBENCH_ERR_CONSOLE_REQUIRED + assert (status, body["error"]) == (serve_wire.doxbench_error_status(console), + console), body status, body = _request( base, "POST", "/actions/workbench/chat-turn", body=json.dumps(request).encode("utf-8"), @@ -379,6 +405,186 @@ def test_a_turn_is_refused_model_capability_unavailable(standalone) -> None: assert own.validate(body) == [], own.report(own.validate(body)) +# --------------------------------------------------------------------------- +# 4b — the turn route's ORDER, with and without a port (the holder's ruling +# on 0a12dc58: option (a), with an ordering test) +# --------------------------------------------------------------------------- + +class _OfferingNothing: + """A CONFIGURED port whose catalog offers nothing: reaching its catalog is + step 7 (`model_unavailable`), and reaching `dispatch` would be a defect.""" + + timeout_seconds = 30.0 + + def catalog(self): + return doxbench_model.EMPTY_CATALOG + + def dispatch(self, prompt_envelope): + raise AssertionError("a turn with a defect was dispatched") + + +class _Registry: + """The server's scope truth for one key: found, or not. Every read is + recorded, so a case can assert the scope was never read.""" + + def __init__(self, found: bool, root: Path): + self.found, self.root, self.reads = found, root, [] + + def resolve(self, repository, ref): + self.reads.append((repository, ref)) + if not self.found: + return None + return SimpleNamespace(source_root=str(self.root), repository=repository, + ref=ref, session_base=None, + read_bytes=lambda: b"{}") + + +class _TurnRoute(WorkbenchRoutes): + """`_handle_workbench_chat_turn` itself, over openDox's real validators + (T085) and its real identity checks, with only the HTTP plumbing replaced: + the console verdict, the bounded body read, and the reply.""" + + loopback = True + capabilities = {"actions": {"session": True}} + actor = ACTOR + + def __init__(self, payload, *, port_factory, root, console=None, + body_bound=None, parsed=True, scope_found=True): + self.payload, self.console, self.body_bound = payload, console, body_bound + self.parsed = parsed + self.model_port_factory = port_factory + self.schema_validator_factory = own.doxbench_validators + self.source = SimpleNamespace(registry=_Registry(scope_found, root)) + self.sent = [] + + def _not_the_human_console(self): + return self.console + + def _read_bounded_json_body(self, max_bytes, dimension): + if self.body_bound is not None: + return None, self.body_bound + return self.payload, None + + def _parse_workbench_chat_turn_v2_body(self, payload): + # "parse": a validator more permissive than the release would let a + # body through that the parser still refuses + if not self.parsed: + return None + return WorkbenchRoutes._parse_workbench_chat_turn_v2_body(payload) + + def _send_json(self, status, obj): + self.sent.append((status, obj)) + + +@pytest.fixture +def scope_stand_in(monkeypatch): + """openxdox's scope module, which openDox's suite does not install (T084 + routes step 5 without it): a key type, the confinement error, and a scope + that resolves. Revalidation against that projection is a no-op here, so + step 5's verdict is exactly the registry's: found, or not.""" + scope = types.ModuleType("openxdox.doxbench_scope") + + @dataclasses.dataclass(frozen=True) + class ScopeKey: + repository: str + ref: str + tile_kind: str + tile_id: str + + class ScopeConfinementError(ValueError): + pass + + scope.ScopeKey = ScopeKey + scope.ScopeConfinementError = ScopeConfinementError + scope.session_created_paths_for_scope = lambda *args, **kwargs: () + scope.resolve_scope = lambda *args, **kwargs: SimpleNamespace() + package = types.ModuleType("openxdox") + package.doxbench_scope = scope + monkeypatch.setitem(sys.modules, "openxdox", package) + monkeypatch.setitem(sys.modules, "openxdox.doxbench_scope", scope) + monkeypatch.setattr(doxbench_turns, "revalidate_scope", lambda **kwargs: None) + + +def _defective(defect: str) -> tuple[dict | None, dict]: + """The example turn with one defect, and the route arguments it needs.""" + turn = copy.deepcopy(_example_turn()) + if defect == "console": + return turn, {"console": "no console token was presented"} + if defect == "not an object": + return None, {} + if defect == "body over its bound": + return turn, {"body_bound": {"dimension": "request_body_bytes", + "measured": 1_048_577, "maximum": 1_048_576}} + if defect == "kind": + turn["kind"] = "workbench-chat-turn" # the retired v1 kind + elif defect == "schema": + del turn["message"] + elif defect == "parse": + return turn, {"parsed": False} + elif defect == "scope": + return turn, {"scope_found": False} + elif defect == "identity": + turn["buffers"][0]["content"] += "edited after hashing\n" + elif defect == "limits": + # within the schema's 1 MiB `maxLength`, past step 6's 400 000 bytes + big = "a" * (doxbench_hash.MAX_BUFFER_BYTES + 1) + digest = doxbench_hash.sha256_hex(big, max_bytes=None) + turn["buffers"][1].update(content=big, content_hash=digest, base_hash=digest) + else: + assert defect == "none", defect + return turn, {} + + +#: Each defect's verdict WITH a configured port: today's order, unchanged. +_WITH_A_PORT = { + "console": serve_wire.DOXBENCH_ERR_CONSOLE_REQUIRED, + "body over its bound": serve_wire.DOXBENCH_ERR_REQUEST_LIMIT_EXCEEDED, + "not an object": "invalid_body", + "kind": serve_wire.DOXBENCH_ERR_UNRECOGNIZED_TURN_KIND, + "schema": serve_wire.DOXBENCH_ERR_INVALID_TURN_REQUEST, + "parse": serve_wire.DOXBENCH_ERR_INVALID_TURN_REQUEST, + "scope": serve_wire.DOXBENCH_ERR_TURN_SCOPE_REFUSED, + "identity": serve_wire.DOXBENCH_ERR_CONTENT_IDENTITY_MISMATCH, + "limits": serve_wire.DOXBENCH_ERR_REQUEST_LIMIT_EXCEEDED, + "none": serve_wire.DOXBENCH_ERR_MODEL_UNAVAILABLE, # step 7, after step 6 +} + +#: The defects that still answer FIRST with no port: the console, the body, +#: the kind, the schema and the parse. Every later one yields to the +#: no-model verdict. +_BEFORE_THE_MODEL_VERDICT = ("console", "body over its bound", "not an object", + "kind", "schema", "parse") + +_NO_PORT = {"no model configured": inst.no_model_port_factory, "no factory": None} + + +@pytest.mark.parametrize("defect", sorted(_WITH_A_PORT)) +@pytest.mark.parametrize("posture", ["a port", *_NO_PORT]) +def test_the_turn_routes_order_with_and_without_a_port( + defect, posture, scope_stand_in, tmp_path) -> None: + """A console, body, kind, schema or parse defect answers first in every + posture. With no port (no model configured, or no factory), the no-model + refusal answers before a scope, identity or limits defect, and the scope + is never read. With a port, every defect answers what it answered before + the hoist, and a well-formed turn reaches step 7.""" + payload, arguments = _defective(defect) + port = _OfferingNothing() + route = _TurnRoute(payload, root=tmp_path, + port_factory=(lambda: port) if posture == "a port" + else _NO_PORT[posture], **arguments) + route._handle_workbench_chat_turn() + assert len(route.sent) == 1, route.sent + status, body = route.sent[0] + expected = (_WITH_A_PORT[defect] + if posture == "a port" or defect in _BEFORE_THE_MODEL_VERDICT + else serve_wire.DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE) + assert body["error"] == expected, (posture, defect, body) + if expected != "invalid_body": + assert status == serve_wire.doxbench_error_status(expected) + if posture != "a port": + assert route.source.registry.reads == [], "the scope was read" + + # --------------------------------------------------------------------------- # 5 — the chat rail: "No model configured", and how to configure one # --------------------------------------------------------------------------- @@ -400,12 +606,14 @@ class Node { this.tagName = String(tag).toUpperCase(); this.children = []; this.attributes = {}; this.listeners = {}; this.className = ''; this._text = ''; this.hidden = false; - this.disabled = false; this.value = ''; + this.disabled = false; this.value = ''; this.writes = []; } get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); } - set textContent(value) { this.children = []; this._text = String(value); } + set textContent(value) { + this.children = []; this._text = String(value); this.writes.push(this._text); + } appendChild(child) { child.parentNode = this; this.children.push(child); return child; } append(...kids) { for (const k of kids) this.appendChild(k); } setAttribute(name, value) { this.attributes[name] = String(value); } @@ -438,7 +646,7 @@ class Node { outline: bufferOf("outline", "docs/outline.md"), document: bufferOf("document", "docs/detail.md") } }); -async function mount(catalog, { intake = null } = {}) { +async function mount(catalog, { intake = null, intakeFirst = false } = {}) { const host = new Node("div"); host.ownerDocument = doc; let turns = 0; let release; @@ -450,12 +658,26 @@ class Node { editorState }); const line = () => byClass(host, "doxchat-no-model")[0] || null; const shown = () => (line() && !line().hidden) ? line().textContent : null; + const announce = byClass(host, "doxchat-announce")[0]; + // how many times the polite region was written the remedy + const announced = () => announce.writes.filter( + (text) => text === NO_MODEL_CONFIGURED_REMEDY).length; const loading = shown(); + if (intake !== null && intakeFirst) rail.intakeOffer(intake); release(); await rail.ready; - if (intake !== null) rail.intakeOffer(intake); + if (intake !== null && !intakeFirst) rail.intakeOffer(intake); + const onArrival = announced(); + // render() runs on every keystroke: type twice and count again + const composer = byClass(host, "doxchat-composer")[0]; + for (const value of ["w", "wh"]) { + composer.value = value; + for (const fn of composer.listeners.input || []) fn({ target: composer }); + } const send = byClass(host, "doxchat-send")[0]; return { loading, shown: shown(), exists: Boolean(line()), turns, + announcePolite: announce.getAttribute("aria-live"), + announcedOnArrival: onArrival, announcedAfterTyping: announced(), sendDisabled: send.disabled === true, sendTitle: send.title, srNote: (byClass(host, "doxchat-unavailable")[0] || {}).textContent }; } @@ -470,6 +692,7 @@ class Node { kind: "workbench-model-catalog", models: [ENTRY] })), unreadable: await mount(() => null), intakeOffered: await mount(empty, { intake: true }), + intakeFirst: await mount(empty, { intake: true, intakeFirst: true }), // the pure verdict over states a mount does not reach in one shot: a // failure recorded beside an adopted empty catalog keeps its own remedy pure: { @@ -478,6 +701,8 @@ class Node { { models: [], catalogFailure: "unreadable" }), emptyThenStaleToken: noModelConfiguredRemedy( { models: [], catalogFailure: "console_required" }), + // a configured model the broker refused: kept, `available: false` + onlyUnavailable: noModelConfiguredRemedy({ models: [OFF], catalogFailure: null }), }, }; process.stdout.write(JSON.stringify(out)); @@ -511,8 +736,29 @@ def test_the_rail_shows_no_model_configured_and_how_before_any_turn(rail) -> Non assert empty["sendTitle"] == CONFIGURED_NONE -def test_a_catalog_of_unavailable_entries_is_no_model_too(rail) -> None: - assert rail["onlyUnavailable"]["shown"] == doxbench_model.NO_MODEL_CONFIGURED_REMEDY +def test_a_configured_model_that_is_unavailable_is_not_no_model(rail) -> None: + """After a broker refusal `BrokeredProviderPort.catalog()` keeps the + binding with `available: false` (doxbench_provider.py). That operator HAS + a model configured, and telling them to declare a binding would send them + to the wrong repair: the line is for an EMPTY catalog only. The send + button's configured-none sentence still states the posture.""" + only_unavailable = rail["onlyUnavailable"] + assert only_unavailable["shown"] is None + assert only_unavailable["announcedAfterTyping"] == 0 + assert only_unavailable["sendDisabled"] is True + assert rail["pure"]["onlyUnavailable"] is None + + +def test_the_remedy_is_announced_once_when_it_arrives(rail) -> None: + """The catalog settles asynchronously with no focus change, so the line's + text also goes to the rail's polite live region -- once: render() runs on + every keystroke, and a re-write of the same sentence would re-announce it.""" + empty = rail["empty"] + assert empty["announcePolite"] == "polite" + assert empty["announcedOnArrival"] == 1 + assert empty["announcedAfterTyping"] == 1, "typing re-announced the remedy" + for case in ("available", "onlyUnavailable", "unreadable", "intakeFirst"): + assert rail[case]["announcedAfterTyping"] == 0, case def test_the_line_shows_in_that_state_only(rail) -> None: @@ -520,11 +766,14 @@ def test_the_line_shows_in_that_state_only(rail) -> None: model available, not when the catalog could not be read (that has its own remedy), and not where the intake flow is offered (its option is the remedy's home).""" - for case in ("empty", "onlyUnavailable", "available", "unreadable", "intakeOffered"): + for case in ("empty", "onlyUnavailable", "available", "unreadable", + "intakeOffered", "intakeFirst"): assert rail[case]["exists"] is True, case assert rail[case]["loading"] is None, case assert rail["available"]["shown"] is None assert rail["unreadable"]["shown"] is None assert rail["intakeOffered"]["shown"] is None + assert rail["intakeFirst"]["shown"] is None assert rail["pure"] == {"emptyAdopted": doxbench_model.NO_MODEL_CONFIGURED_REMEDY, - "emptyThenUnreadable": None, "emptyThenStaleToken": None} + "emptyThenUnreadable": None, "emptyThenStaleToken": None, + "onlyUnavailable": None} From 4ef7575a828475f0f73693aa7bdb877193067769 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:01:48 +0000 Subject: [PATCH 07/10] T081: a PATH without omp keeps every other command Copilot at openDox-code#74 9551f20d (r4170794383). The tests' _no_omp_path() dropped every PATH directory that held `omp`. If that directory also held the only `git`, the standalone fixture handed generate-and-open a PATH without Git, and the served-route cases failed in setup instead of testing the no-model answers. A directory that holds `omp` is now replaced by a mirror of it, in its own place in the order. The mirror is a symlink to each entry except `omp`, and it lives under the case's tmp_path. The new case, test_a_path_without_omp_keeps_every_other_command, puts a fake `omp` beside a `fixture-git` between two other directories. It asserts: - the order is kept; - `omp` is gone; - `fixture-git` still resolves, to the same file. Three mutants are killed: the directory dropped whole, as before this commit; the mirror keeping `omp`; and the mirror moved out of the directory's place. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_chat_model_configuration.py | 56 ++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index 69d4b410..e0b317a4 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -63,6 +63,7 @@ import shutil import subprocess import sys +import tempfile import textwrap import types from pathlib import Path @@ -100,13 +101,54 @@ "editors remain fully usable.") -def _no_omp_path(tmp_path: Path) -> str: - """This process's PATH with every directory holding `omp` left out.""" - kept = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) - if entry and shutil.which(bridge_mod.HARNESS_COMMAND, path=entry) is None] - path = os.pathsep.join(kept) - assert shutil.which(bridge_mod.HARNESS_COMMAND, path=path) is None - return path +def _no_omp_path(tmp_path: Path, path: str | None = None) -> str: + """This process's PATH (or `path`) with `omp` taken out and EVERY OTHER + COMMAND KEPT, in order. A directory holding `omp` is replaced by a mirror + of it under `tmp_path`: a symlink to each of its other entries. Dropping + the whole directory would drop whatever else it holds, `git` among them, + and a child that cannot find `git` fails before the case it exists for + (Copilot at openDox-code#74 9551f20d, r4170794383).""" + harness = bridge_mod.HARNESS_COMMAND + mirrors = Path(tempfile.mkdtemp(prefix="path-without-omp-", dir=tmp_path)) + kept = [] + for index, entry in enumerate( + (os.environ.get("PATH", "") if path is None else path).split(os.pathsep)): + if not entry: + continue + if shutil.which(harness, path=entry) is None: + kept.append(entry) + continue + mirror = mirrors / str(index) + mirror.mkdir() + for item in sorted(Path(entry).iterdir()): + if item.name != harness: + (mirror / item.name).symlink_to(item) + kept.append(str(mirror)) + without = os.pathsep.join(kept) + assert shutil.which(harness, path=without) is None + return without + + +def test_a_path_without_omp_keeps_every_other_command(tmp_path) -> None: + """A directory that holds `omp` beside another command keeps the other + command, in its place in the order.""" + shared = tmp_path / "shared-bin" + shared.mkdir() + for name in (bridge_mod.HARNESS_COMMAND, "fixture-git"): + tool = shared / name + tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + tool.chmod(0o755) + before, after = tmp_path / "before", tmp_path / "after" + before.mkdir() + after.mkdir() + path = os.pathsep.join([str(before), str(shared), str(after)]) + without = _no_omp_path(tmp_path, path).split(os.pathsep) + assert len(without) == 3, "a directory holding omp was dropped whole" + assert without[0] == str(before) and without[2] == str(after), without + assert shutil.which(bridge_mod.HARNESS_COMMAND, path=os.pathsep.join(without)) is None + found = shutil.which("fixture-git", path=os.pathsep.join(without)) + assert found is not None and Path(found).parent == Path(without[1]), found + assert Path(found).resolve() == (shared / "fixture-git").resolve() def _fake_omp(tmp_path: Path) -> Path: From b930af491fb6571bf38f8026eda8a7574df783a2 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:19:31 +0000 Subject: [PATCH 08/10] T081: a relative PATH entry's mirror links to absolute targets Copilot at openDox-code#74 4ef7575a (r4170839174). For a relative PATH entry that holds `omp`, such as `shared-bin`, the mirror's symlinks took the entry's relative paths. They then resolved from the mirror directory rather than from the working directory, so the other commands became dangling links. The helper now reads each entry through os.path.abspath, so every link names an absolute target. The new case, test_a_relative_path_entry_without_omp_keeps_its_commands, builds the PATH from `shared-bin` in tmp_path and then moves to the checkout root, as a child's own directory would be. It asserts that `omp` is gone and that `fixture-git` resolves to the same file. A mutant that restores the relative targets is killed (P04), as are P01 to P03 of the helper. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_chat_model_configuration.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index de5a0696..e65cd1cc 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -120,7 +120,11 @@ def _no_omp_path(tmp_path: Path, path: str | None = None) -> str: continue mirror = mirrors / str(index) mirror.mkdir() - for item in sorted(Path(entry).iterdir()): + # ABSOLUTE targets: a relative PATH entry names a directory relative + # to the current directory, and a relative link would resolve from + # the mirror instead (Copilot at openDox-code#74 4ef7575a, + # r4170839174). + for item in sorted(Path(os.path.abspath(entry)).iterdir()): if item.name != harness: (mirror / item.name).symlink_to(item) kept.append(str(mirror)) @@ -151,6 +155,26 @@ def test_a_path_without_omp_keeps_every_other_command(tmp_path) -> None: assert Path(found).resolve() == (shared / "fixture-git").resolve() +def test_a_relative_path_entry_without_omp_keeps_its_commands( + tmp_path, monkeypatch) -> None: + """A RELATIVE PATH entry holding `omp`: its other commands still resolve, + from anywhere, to the same files.""" + shared = tmp_path / "shared-bin" + shared.mkdir() + for name in (bridge_mod.HARNESS_COMMAND, "fixture-git"): + tool = shared / name + tool.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + tool.chmod(0o755) + monkeypatch.chdir(tmp_path) + assert shutil.which("fixture-git", path="shared-bin") is not None + without = _no_omp_path(tmp_path, "shared-bin") + monkeypatch.chdir(ROOT) # a child's own directory + assert shutil.which(bridge_mod.HARNESS_COMMAND, path=without) is None + found = shutil.which("fixture-git", path=without) + assert found is not None, without + assert Path(found).resolve() == (shared / "fixture-git").resolve() + + def _fake_omp(tmp_path: Path) -> Path: """A directory holding an executable named `omp`, which is never run.""" bin_dir = tmp_path / "harness-bin" From 7b1ecbe208fd2edff687b4e0c8fc1ace497766f4 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:31:05 +0000 Subject: [PATCH 09/10] T081: resolve the model port once per turn Copilot at openDox-code#74 8104fa6e (r4170882125). The hoisted model verdict called _workbench_model_port(), and step 7 called it again. For a factory injected through build_server that does not memoize, each turn reaching step 7 built two adapters and discarded the first. The built-in factories memoize, but the accessor does not require an injected one to. The port resolved ahead of step 5 is now the one step 7 reads, so the early no-model refusal is unchanged and the factory runs once. test_the_turn_routes_order_with_and_without_a_port now counts the factory's calls in all 30 cases: once where the turn reaches the model verdict, and never where an earlier defect answers or no factory is declared. Over the code before this commit, the well-formed turn with a port fails that count. The hoist's mutants are updated for the new text, and a new mutant, N24 (step 7 resolves the port again), is killed: 24 of 24. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/serve_workbench.py | 26 ++++++++++++++------------ tests/test_chat_model_configuration.py | 20 +++++++++++++++++--- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 525d86d9..5142c10f 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -1683,11 +1683,18 @@ def _handle_workbench_chat_turn(self) -> None: # rule the validators refusal above keeps. It answers both an install # with no model configured (`doxbench_model.NO_MODEL_CONFIGURED`, which # the accessor answers as no port) and a plane with no factory at all, - # and it spawns nothing and contacts nothing. Step 7 keeps its own check - # for the port it then reads. Measured at openDox-code#71 `e0298cf4`, - # once T085's validators answered standalone: without this, a standalone - # turn reached step 5's scope import and the connection dropped. ---- - if self._workbench_model_port() is None: + # and it spawns nothing and contacts nothing. Measured at + # openDox-code#71 `e0298cf4`, once T085's validators answered + # standalone: without this, a standalone turn reached step 5's scope + # import and the connection dropped. + # + # THE PORT RESOLVED HERE IS THE ONE STEP 7 READS, so the declared + # factory runs ONCE per turn. The built-in factories memoize, but the + # accessor does not require an injected one to, and a second call + # would build a second adapter and discard the first (Copilot at + # openDox-code#74 8104fa6e, r4170882125). ---- + port = self._workbench_model_port() + if port is None: self._refuse_turn(validators, DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, turn_id, failure_kind=failure_kind) @@ -1852,13 +1859,8 @@ def _session_text(rel, _root=source_root): failure_kind=failure_kind) return - # ---- step 7: model ---- - port = self._workbench_model_port() - if port is None: - self._refuse_turn(validators, - DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE, - turn_id, failure_kind=failure_kind) - return + # ---- step 7: model. `port` is the one resolved, and found present, + # ahead of step 5; it is not resolved a second time. ---- try: catalog = port.catalog() except Exception: # noqa: BLE001 - never let a provider-shaped exception reach the wire diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index e65cd1cc..fb1aca18 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -635,13 +635,27 @@ def test_the_turn_routes_order_with_and_without_a_port( posture. With no port (no model configured, or no factory), the no-model refusal answers before a scope, identity or limits defect, and the scope is never read. With a port, every defect answers what it answered before - the hoist, and a well-formed turn reaches step 7.""" + the hoist, and a well-formed turn reaches step 7. + + The declared model factory runs AT MOST ONCE per turn: once where the + turn reaches the model verdict, never where an earlier defect answers + (Copilot at openDox-code#74 8104fa6e, r4170882125).""" payload, arguments = _defective(defect) port = _OfferingNothing() + declared = (lambda: port) if posture == "a port" else _NO_PORT[posture] + resolved = [] + + def counted(): + resolved.append(posture) + return declared() + route = _TurnRoute(payload, root=tmp_path, - port_factory=(lambda: port) if posture == "a port" - else _NO_PORT[posture], **arguments) + port_factory=None if declared is None else counted, + **arguments) route._handle_workbench_chat_turn() + reaches_the_verdict = defect not in _BEFORE_THE_MODEL_VERDICT + assert len(resolved) == (1 if reaches_the_verdict and declared is not None + else 0), (posture, defect, resolved) assert len(route.sent) == 1, route.sent status, body = route.sent[0] expected = (_WITH_A_PORT[defect] From 407f6ecde787eac01f84d6b5e5a2746f10d39884 Mon Sep 17 00:00:00 2001 From: Brett Heap <1513478+brettheap@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:02:27 +0000 Subject: [PATCH 10/10] T081: state the remedy's scope where the rail decides it This answers Copilot at openDox-code#74 fd7cccd7 (r4170956940), RULED option (a) by the holder on 2026-10-03. It is a comment, and the web census moves with it. noModelConfiguredRemedy() reads an empty catalog as openDox's own no-model state, and the comment beside that check now states the limit that reading has. - The remedy names openDox's own ways to configure a model. Every openDox entry point declares openDox's own model port (doxbench_install.declared_model_port_factory), and that port's empty catalog means exactly no binding and no harness. - A programmatic embedder that injects its own port supplies its own intake offer, and where intake is offered the line is hidden. - The server cannot say more within the released contract. xfactory-workbench-model-catalog is closed (additionalProperties: false), and #1144's 16.5 (T082) holds /capabilities and the intake surface equal with and without a model. The web census moves views/doxbench-chat.js from 1877 to 1890 loc, and the class-A total from 18122 to 18135. No behaviour changes. Locally, the whole suite gives 3095 passed, 178 skipped. Arc: neutral-product-standalone-operability Lane: openxfactory-4 (openXfactory-4-openDox_extraction) Co-Authored-By: Claude Opus 5.5 (1M context) --- src/opendox/web/views/doxbench-chat.js | 13 +++++++++++++ tests/fixtures/web_boundary_census.yaml | 4 ++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/opendox/web/views/doxbench-chat.js b/src/opendox/web/views/doxbench-chat.js index bb712ae8..7b4d66b9 100644 --- a/src/opendox/web/views/doxbench-chat.js +++ b/src/opendox/web/views/doxbench-chat.js @@ -335,6 +335,19 @@ export const NO_MODEL_CONFIGURED_REMEDY = export function noModelConfiguredRemedy(stateValue) { if (stateValue.catalogFailure) return null; if (stateValue.models === null) return null; + // EMPTY READS AS openDox's OWN NO-MODEL STATE, and that is a stated limit + // (RULED by the holder, 2026-10-03, on Copilot's r4170956940 at + // openDox-code#74). The remedy names openDox's OWN ways to configure a + // model. Every openDox entry point declares openDox's own model port + // (`doxbench_install.declared_model_port_factory`), whose empty catalog is + // exactly no binding and no harness. A programmatic embedder that injects + // its own port, and serves an empty catalog from it, supplies its own intake + // offer, and where intake is offered this line is hidden (the check below). + // The server cannot say more within the released contract: + // `xfactory-workbench-model-catalog` is closed (`additionalProperties: + // false`: `schema_version`, `kind`, `models`), and `/capabilities` and the + // intake surface are held EQUAL with and without a model by #1144's 16.5 + // (plan 034 T082), so neither may carry a model posture. if ((stateValue.models || []).length !== 0) return null; if (stateValue.intakeOffered === true) return null; return NO_MODEL_CONFIGURED_REMEDY; diff --git a/tests/fixtures/web_boundary_census.yaml b/tests/fixtures/web_boundary_census.yaml index 379f9771..7128b840 100644 --- a/tests/fixtures/web_boundary_census.yaml +++ b/tests/fixtures/web_boundary_census.yaml @@ -191,7 +191,7 @@ measured_at: "opensoft/openDox-code main a99eba03e31a0aee1cc15a061fdf718cc88a2c4 # shape and `test_the_declared_totals_are_re_derived_from_the_rows` can refuse a # drift between the two. Measured at slice S4 (see the S4 block above). totals: - A: {files: 26, loc: 18122} + A: {files: 26, loc: 18135} B: {files: 1, loc: 73} C: {files: 14, loc: 12587} "?": {files: 1, loc: 1577} @@ -278,7 +278,7 @@ files: - path: views/doxbench-chat.js class: A - loc: 1877 + loc: 1890 note: "doxBench chat rail; imports only the pure chat model" - path: views/doxbench-editor.js