diff --git a/src/opendox/branch_session.py b/src/opendox/branch_session.py index 5604cc7a..f59efd3a 100644 --- a/src/opendox/branch_session.py +++ b/src/opendox/branch_session.py @@ -74,13 +74,17 @@ import yaml from . import doxbench_hash -# THE GATE COLUMN, NAMED LATE (BUILD slice 2b, `split-opendox-two-layer-product` -# § 3.5/3.6). `gate_console` is openXdox's — the layer that PINS this one — so an -# import statement here made `import opendox.branch_session` require openXdox to -# be installed, which `design.md`:243 refuses: *"what must not survive is the -# direction, not the calls."* The stand-in resolves on first attribute access and -# refuses naming the layering; every `gate_console.X` below is unchanged. -from .consumer_reach import gate_console +# THE GATE COLUMN, THROUGH ITS SEAM (plan 034 T084; #1144 4.3, R1Q10 (a)). +# `gate_console` is openXdox's, the layer that PINS this one, so an import +# statement here would make `import opendox.branch_session` require openXdox, +# which `design.md`:243 refuses. BUILD slice 2b named it late through +# `consumer_reach`'s stand-in, which still raised where openXdox was absent. +# It is now a proxy over `column_seams.gate`: each `gate_console.X` below reads +# the registration current when it runs, a host's or openDox's own default +# (`default_columns.GATE`, whose governed record functions refuse by name). +# Every `gate_console.X` below is unchanged. Stdlib-only, so no edge. +from . import column_seams +gate_console = column_seams.gate.proxy # ...EXCEPT the nine `records_dir` DEFAULTS at :1740, :1859, :1878, :4179, :4217, # :4254, :4287, :4504 and :4991, which no stand-in can defer: a default argument @@ -1588,8 +1592,13 @@ def _active_pick_fallbacks( A change picked from two staging ids is ambiguous and is refused instead of selecting whichever register row happened to be encountered first. + + The register is the one registered at `column_seams.register` (plan 034 + T084): a host's, or openDox's own default, whose register holds no row, + so a lone openDox proves no fallback. """ - from openxdox.register import CrossReferenceIndexAdapter + CrossReferenceIndexAdapter = ( + column_seams.register.current().CrossReferenceIndexAdapter) rows = rows if rows is not None else _change_rows(checkout_root) active_without_origin = { @@ -2007,7 +2016,9 @@ def proposal_state_for(tile: "Tile", *, records_root: Path | str | None = None, Only a STAGED-TOPIC tile can carry a proposal: both signals are keyed on a staging id, and a cluster or possible tile has none.""" - from openxdox import kickoff as kickoff_mod # lazy: mirrors gate_console's cycle note + # Kickoff through its seam (plan 034 T084): a host's commission reader, or + # openDox's own default, which reads no dispatched commission. + kickoff_mod = column_seams.kickoff.current() staged = tile.scope_kind == STAGED_TOPIC landed = None diff --git a/src/opendox/cli.py b/src/opendox/cli.py index 89d0ea05..6f1a0f97 100644 --- a/src/opendox/cli.py +++ b/src/opendox/cli.py @@ -19,6 +19,8 @@ import argparse import json import os +import re +import shutil import signal import sys import tempfile @@ -71,8 +73,14 @@ from opendox import branch_session as branch_session_mod # noqa: E402 from opendox import doxbench_install as install_mod # noqa: E402 from opendox import doxbench_knowledge as knowledge_mod # noqa: E402 -from opendox import consumer_reach # noqa: E402 -gate_mod = consumer_reach.gate_console # noqa: E402 +# THE GATE PRIMITIVES, THROUGH THEIR SEAM (plan 034 T084; #1144 4.3, R1Q10 +# (a)). This was `consumer_reach.gate_console`, a late stand-in over openXdox's +# `gate_console` that still raised where openXdox was absent. `gate_mod.X` now +# reads the registration current at `column_seams.gate` when it runs, a host's +# or openDox's own default, which `build_parser()` and `main()` register. +# Stdlib-only, so this adds no reach. +from opendox import column_seams # noqa: E402 +gate_mod = column_seams.gate.proxy # noqa: E402 from opendox import serve as serve_mod # noqa: E402 from opendox import workbench as workbench_mod # noqa: E402 # THE HOME-CORPUS SEAM'S DEFAULT (4.1a; plan 034 T022) -- see @@ -494,16 +502,65 @@ def _warn_validator_could_not_run(result, validator) -> None: print(f" {sys.executable} -m {remedy}", file=sys.stderr) +#: One broken rule, as a validator's report names it: `[] : +#: `, the line `opendox.validator.Violation.line()` prints. +_RULE_LINE = re.compile(r"^\[(?P[^\[\]\s]+)\] (?P.+)$") + +#: How many of a report's other lines (its summary, or a validator's own +#: words where it names no rule) are printed. +_REPORT_TAIL = 20 + +#: How many places each broken rule is shown at: the first on the rule's own +#: line, and the next ones beneath it. A rule broken at more places than this +#: says how many more, so its count stays exact and the report stays short. +_PLACES_SHOWN = 5 + + def _report_non_conformance(written: Path, result) -> None: """NOT CONFORMANT — the validator ran, reached a verdict, and rejected the snapshot. The one thing this message must never be mistaken for is the warning above it, so it says whose fault it is out loud and prints the findings themselves; "1 error(s)" alone told a human nothing he could act - on.""" + on. + + EVERY BROKEN RULE, ONCE, WITH ITS COUNT (plan 034 T084; RULED + openxFactory#656 `5920216845`, item 3, *"Show every rule, grouped + (Recommended)"*). This printed the validator's LAST 20 LINES, so a + snapshot that broke one rule a hundred times and a second rule once + showed twenty copies of the first and never named the second. Now each + rule id the report names is printed ONCE, on a line of its own, + ` × [] : `, in the order the validator found + them, with where it is first broken. The next places it is broken follow + beneath it, without the id, up to `_PLACES_SHOWN` in all, because one rule + can be broken in different ways (a missing key, then another), and a + count beside the first place alone would read as that place repeated. + The report's other lines (the validator's summary) follow. A validator + whose output names no rule id has nothing to group, so its own last lines + are printed, as before.""" print(f" validation FAILED — the pinned validator REJECTED {written}. This " f"is the SNAPSHOT, not the environment: the validator ran fine and " f"found the data non-conformant.", file=sys.stderr) - for line in (result.stdout or result.stderr).strip().splitlines()[-20:]: + lines = (result.stdout or result.stderr).strip().splitlines() + places: dict[str, list[str]] = {} + others: list[str] = [] + for line in lines: + named = _RULE_LINE.match(line.strip()) + if named is None: + others.append(line) + continue + places.setdefault(named["rule"], []).append(named["rest"]) + if places: + total = sum(len(where) for where in places.values()) + print(f" {total} violation(s) of {len(places)} rule(s), each rule " + f"once, with its count and where it is broken:", file=sys.stderr) + for rule, where in places.items(): + print(f" {len(where)} × [{rule}] {where[0]}", file=sys.stderr) + for place in where[1:_PLACES_SHOWN]: + print(f" {place}", file=sys.stderr) + if len(where) > _PLACES_SHOWN: + print(f" … and {len(where) - _PLACES_SHOWN} more of " + "this rule", file=sys.stderr) + for line in others[-_REPORT_TAIL:]: print(f" {line}", file=sys.stderr) @@ -731,16 +788,39 @@ def report() -> dict: return report +#: The prefix of the temporary run directory `generate-and-open` mints when +#: no `--run-dir` is given: the installed command's own name (plan 034 T084, +#: adversarial review 2, G7), not openxFactory's pre-carve one. +RUN_DIR_PREFIX = "opendox-" + + def _generate_and_open(args: argparse.Namespace, *, opener) -> int: - """`generate-and-open`'s generate-then-serve half, once the install is known.""" + """`generate-and-open`'s generate-then-serve half, once the install is known. + + A RUN DIRECTORY THIS PROCESS MINTED IS REMOVED WHEN IT IS DONE WITH IT + (plan 034 T084, adversarial review 2, G7): when the server stops, on a + `--no-serve` run, on a refusal and on a failure. It used to be left under + the system's temporary directory on every run. A `--run-dir` the caller + names is the caller's, and is left exactly as this run wrote it.""" # Ahead of minting the run dir, so a refused root leaves not even an empty # temp directory behind. `_generate_and_write` is still the guard that MATTERS # (it is the one no caller can skip); these are the same checks, earlier. _refuse_non_corpus_repo_root(args) _refuse_malformed_generated_at(args) _refuse_empty_source_options(args) - run_dir = Path(args.run_dir).resolve() if args.run_dir else Path( - tempfile.mkdtemp(prefix="ideation-dashboard-")) + if args.run_dir: + return _generate_and_serve(args, Path(args.run_dir).resolve(), + opener=opener) + minted = Path(tempfile.mkdtemp(prefix=RUN_DIR_PREFIX)) + try: + return _generate_and_serve(args, minted, opener=opener) + finally: + shutil.rmtree(minted, ignore_errors=True) + + +def _generate_and_serve(args: argparse.Namespace, run_dir: Path, *, + opener) -> int: + """Generate into `run_dir`, serve it, and stop, for `_generate_and_open`.""" run_dir.mkdir(parents=True, exist_ok=True) output = run_dir / "snapshot.json" @@ -901,9 +981,14 @@ def _commission_cli(verb: str, args: argparse.Namespace, target: str, engine, same guards. The CLI adds nothing of its own except the printing — which is exactly what makes the two surfaces equivalent.""" repo_root = Path(args.repo_root).resolve() - console = gate_mod.GateConsole(_human_gate(repo_root, args), - records_dir=args.records_dir) + human = _human_gate(repo_root, args) try: + # INSIDE the refusal boundary (plan 034 T084): openDox's own gate + # default refuses the governed `GateConsole` at construction + # (`GateRecordsNotRegistered`, a `GateRefused`), so a contributed gate + # verb that reaches it with no host's gate registered answers + # " refused: ..." rather than a traceback. + console = gate_mod.GateConsole(human, records_dir=args.records_dir) res = getattr(console, verb.replace("-", "_"))( target, outline=args.outline, workflow=args.workflow, note=args.note, provenance=cli_provenance(), **engine_kwargs) @@ -1239,6 +1324,21 @@ def _default_home_factory(root): corpus_adapter.CorpusRef(name="home", location=str(root))) +#: THE INSTALLED COMMAND'S OWN NAME AND WORDS (plan 034 T084; found by T099's +#: PyPI writer). `opendox --help` is what a published install prints, so the +#: usage line names the console script `pyproject.toml` installs, `opendox`, +#: and the description and epilog name openDox only. They used to print +#: `usage: ideation-dashboard` and this module's docstring, which is +#: openxFactory's pre-carve history, not a user's help. +PROG = "opendox" +PARSER_DESCRIPTION = ( + "openDox, a document workbench over a corpus of documents: regenerate " + "the corpus's deterministic snapshot, serve it locally and open it in a " + "browser, create and edit its documents, declare the model providers a " + "chat may use, and run the identity and coordination runtime.") +PARSER_EPILOG = "Run `opendox --help` for a command's own options." + + def build_parser(*, subcommand_extensions: tuple = ()) -> argparse.ArgumentParser: """The command line, plus whatever this invocation was ASSEMBLED with. @@ -1302,8 +1402,12 @@ def build_parser(*, subcommand_extensions: tuple = ()) -> argparse.ArgumentParse # 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) + # AND the consumer columns' defaults (plan 034 T084; #1144 4.3, + # R1Q10 (a)): the gate primitives, the doxBench scope, kickoff and + # the cross-reference register, the same way. + column_seams.register_defaults() + parser = argparse.ArgumentParser(prog=PROG, description=PARSER_DESCRIPTION, + epilog=PARSER_EPILOG) sub = parser.add_subparsers(dest="command", required=True) gen = sub.add_parser("generate", help="regenerate the deterministic snapshot") @@ -1349,7 +1453,7 @@ def build_parser(*, subcommand_extensions: tuple = ()) -> argparse.ArgumentParse gao.set_defaults(func=cmd_generate_and_open) create = sub.add_parser( - "create", help="scaffold a new header-compliant ideation doc and open it for editing") + "create", help="scaffold a new header-compliant document and open it for editing") create.add_argument("--repo-root", required=True, help="repository to scaffold into") create.add_argument("--area", default=authoring_mod.DEFAULT_AREA, help=f"target ideation area (default: {authoring_mod.DEFAULT_AREA})") @@ -1422,6 +1526,10 @@ def main(argv: list[str] | None = None, *, projection_seams.register_defaults() # AND openDox's own doxBench defaults (4.3, T085), the same way. doxbench_defaults.register_defaults() + # AND the consumer columns' defaults (plan 034 T084; #1144 4.3, + # R1Q10 (a)): the gate primitives, the doxBench scope, kickoff and + # the cross-reference register, the same way. + column_seams.register_defaults() args = build_parser( subcommand_extensions=subcommand_extensions).parse_args(argv) try: diff --git a/src/opendox/column_seams.py b/src/opendox/column_seams.py new file mode 100644 index 00000000..05d6f47d --- /dev/null +++ b/src/opendox/column_seams.py @@ -0,0 +1,201 @@ +"""THE CONSUMER COLUMNS' SEAMS: the gate primitives, the doxBench scope, +kickoff and the cross-reference register. Each is a host's contribution or +openDox's own default, held for late resolution (plan 034 T084; #1144 4.3, as +T007 batch G's and batch L's addenda read). + +WHY THIS FILE EXISTS. #1144's 4.3: *"Route EVERY deferred reach through a seam +the product declares. None stays late-bound by name."* After phase 2, openDox's +own verbs still reached four mechanisms of openXdox's columns by module name: +the gate console (`branch_session` and `cli` through `consumer_reach`'s late +stand-in, and `serve_workbench`'s model approval and chat Save by deferred +imports), the doxBench scope authority (`serve_workbench`'s thread, chat-turn +and document-abstract routes), kickoff (`branch_session`'s proposal state and +`serve_project`'s register projection) and the cross-reference register +(`branch_session`'s pick fallbacks). With openXdox absent, which is the normal +state of a neutral openDox, each of those raised from inside a function, and +three of them ended a request with a dropped connection (batch L). This module +declares a seam for each, in `projection_seams`' discipline (the class is the +same one, `projection_seams._Seam`, naming this module). + +R1Q10 (a) (`openxFactory#656` comment `5850003126`, in R-G3's pattern): +openDox grows a small neutral default for each, `opendox.default_columns`, and +openXdox contributes its governed one through the same seam (T086). What each +default does, and the holder's reading behind it, is that module's docstring. + +THE ENTRY POINTS REGISTER THE DEFAULTS WHERE NO HOST HAS (R1Q3 (a), comment +`5817152735`). `cli.build_parser()`, `cli.main()`, `serve.build_server()` and +`serve.main()` call `register_defaults()`, beside `projection_seams`'. So a +process that runs none of them still meets `SeamNotRegistered`, naming the +seam and the call (4.2); a host registration made before a default has been +read replaces it; one made after is refused (`SeamAlreadyRegistered`); and the +same registration again is a no-op. These are `projection_seams`' exception +classes, so a verb that reports one projection-seam refusal in a clause reports +these in the same one. + +THE FOUR SEAMS, AND WHAT A REGISTRATION CARRIES. Each is a module, or any +object, carrying the names below. openXdox's modules carry them as they stand, +except the gate's: `first_edit_gate_factory` is `gate_routes`', so openXdox's +gate registration is `gate_console` with that one name beside it. + +* `gate`: `GATE_CALLABLES` and `GATE_VALUES`, every gate-console name openDox + reads. ONE registration for the whole family, because the names agree with + each other: a record builder checks a `Provenance` of its own type, and a + caller catches its own `GateRefused`. +* `scope`: `SCOPE_CALLABLES`, the scope authority. The scope VALUE types are + openDox's own (`opendox.doxbench_scope_types`) and are no seam. +* `kickoff`: `KICKOFF_CALLABLES`, the dispatched commissions and the project + register's discovery. +* `register`: `REGISTER_CALLABLES`, the cross-reference register's adapter. + +`gate_records_writable()` answers whether a HOST's gate is registered, so the +model-intake surface can decline to start a flow whose approval openDox's +default would refuse (plan 034 T084; RULED by Brett Heap, 2026-10-02, "Refuse +by name, hide intake (Recommended)", confirmed at openxFactory#656 comment +`5961364221`, item 1). + +IMPORT WEIGHT: `opendox.projection_seams` only, which is stdlib-only, so this +module names no sibling in an import. `register_defaults()` imports +`opendox.default_columns` when it is CALLED. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +from opendox import projection_seams +from opendox.projection_seams import ( + ProjectionSeamError, + SeamAlreadyRegistered, + SeamNotRegistered, +) + +__all__ = [ + "GATE_CALLABLES", + "GATE_RECORDS_REFUSAL", + "GATE_VALUES", + "KICKOFF_CALLABLES", + "ProjectionSeamError", + "REGISTER_CALLABLES", + "SCOPE_CALLABLES", + "SeamAlreadyRegistered", + "SeamNotRegistered", + "gate", + "gate_records_writable", + "kickoff", + "register", + "register_defaults", + "scope", +] + +_MODULE = "opendox.column_seams" + +#: The fixed sentence a surface puts on the wire, and the refusal's text, where +#: a governed gate-action record is asked for and no host's gate is registered. +#: It names the seam and the call that registers one (4.2). +GATE_RECORDS_REFUSAL = ( + "this install records no governed gate actions: no host's gate is " + "registered at openDox's gate seam (opendox.column_seams.gate), and " + "openDox's own default writes no gate-action record, because that record " + "is a shape only the governing host's pinned schema declares. A host that " + "records gate actions registers its gate at process start with " + "opendox.column_seams.gate.register(). A model is " + "declared on this install with `opendox model-binding add`, which needs " + "no approval record.") + +#: What a gate registration must carry as callables: the human-gate guard and +#: the types it works with, the record functions, the session-ref target id, +#: the first-edit gate builder chat's Save writes through, and the clock, the +#: stamp and the records prefix the session layer composes with them. +GATE_CALLABLES: tuple[str, ...] = ( + "GateConsole", "GateRefused", "HumanGate", "Provenance", + "build_gate_action_record", "validate_gate_action_record", + "write_gate_action_record", "validate_demotion_execution_receipt", + "require_human_gate", "ref_target_id", "first_edit_gate_factory", + "_prefix", "_stamp", "_utcnow") + +#: ...and as values: the session actions and the commit artifact, the default +#: records prefix, the HTTP console's provenance, and the CLI's surface and +#: presence proofs. +GATE_VALUES: tuple[str, ...] = ( + "ACTION_ABANDON_SESSION", "ACTION_CREATE_DOCUMENT", "ACTION_EDIT_DOCUMENT", + "ART_COMMIT", "DEFAULT_RECORDS_DIR", "HTTP_CONSOLE_TOKEN", + "PRESENCE_DECLARED", "PRESENCE_TTY", "SURFACE_CLI") + +#: What a scope registration must carry. +SCOPE_CALLABLES: tuple[str, ...] = ( + "resolve_scope", "is_live_session_ref", "session_created_paths_for_scope") + +#: What a kickoff registration must carry. +KICKOFF_CALLABLES: tuple[str, ...] = ( + "dispatched_commission_rows", "dispatched_commissions", + "dispatched_propose_topics", "discover_project_register") + +#: What a cross-reference register registration must carry. +REGISTER_CALLABLES: tuple[str, ...] = ("CrossReferenceIndexAdapter",) + +def _gate_shape(registration) -> list[str]: + """`GateRefused` is CAUGHT (`except gate_console.GateRefused`), so it must + be an exception class: a callable that is not one would pass the name + probe and then raise `TypeError` from the first `except` that reads it.""" + refused = getattr(registration, "GateRefused", None) + if isinstance(refused, type) and issubclass(refused, BaseException): + return [] + return ["GateRefused must be an exception class, because openDox's verbs " + "catch it (`except .GateRefused`)"] + + +def _register_shape(registration) -> list[str]: + """openDox calls `CrossReferenceIndexAdapter.discover()`, so the + adapter must carry a callable `discover`, not only be callable itself.""" + adapter = getattr(registration, "CrossReferenceIndexAdapter", None) + if callable(getattr(adapter, "discover", None)): + return [] + return ["CrossReferenceIndexAdapter must carry a callable `discover`, " + "because openDox calls CrossReferenceIndexAdapter.discover()"] + + +gate = projection_seams._Seam( + "gate", what="gate primitives", callables=GATE_CALLABLES, + values=GATE_VALUES, default="opendox.default_columns.GATE", + consequence="no gate action can be guarded, stamped or recorded", + module=_MODULE, shape=_gate_shape) + +scope = projection_seams._Seam( + "scope", what="doxBench scope authority", callables=SCOPE_CALLABLES, + default="opendox.default_columns.SCOPE", + consequence="no workbench tile can be resolved to its documents", + module=_MODULE) + +kickoff = projection_seams._Seam( + "kickoff", what="commission reader", callables=KICKOFF_CALLABLES, + default="opendox.default_columns.KICKOFF", + consequence="no dispatched commission, proposal or project register can " + "be read", + module=_MODULE) + +register = projection_seams._Seam( + "register", what="cross-reference register", callables=REGISTER_CALLABLES, + default="opendox.default_columns.REGISTER", + consequence="no cross-reference register can be read", + module=_MODULE, shape=_register_shape) + + +def register_defaults() -> None: + """Register openDox's OWN default at each of the four seams, where no host + has registered one (R1Q10 (a), in R1Q3 (a)'s pattern). It registers + nothing over a host and reads nothing, so a host registration made + afterwards, and before any consumer reads a default, still replaces it. + Importing this module registers nothing.""" + from opendox import default_columns + + gate.register_default(default_columns.GATE) + scope.register_default(default_columns.SCOPE) + kickoff.register_default(default_columns.KICKOFF) + register.register_default(default_columns.REGISTER) + + +def gate_records_writable() -> bool: + """Whether a HOST's gate is registered, which is what can write a governed + gate-action record: openDox's own default writes none. Answers without + reading the seam, so it closes no default's window.""" + return gate.holds_a_hosts() diff --git a/src/opendox/consumer_reach.py b/src/opendox/consumer_reach.py deleted file mode 100644 index a552c2aa..00000000 --- a/src/opendox/consumer_reach.py +++ /dev/null @@ -1,342 +0,0 @@ -"""Late-bound reaches from openDox INTO its consumer, openXdox. - -WHY THIS FILE EXISTS. openDox is the NEUTRAL product and openXdox is the layer -that pins it: `contracts/opendox-pin.yaml` in the openXdox assembly root names -openDox's commit and tree digest (`split-opendox-two-layer-product` § 4.2, -RULED OQ-2), and nothing in the chain points back. A module of THIS package may -therefore never require `openxdox` to be importable. `design.md`:243 states the -standard the carve is held to in one sentence: *"What must not survive is the -direction, not the calls."* - -Thirty-two calls survived the carve pointing the wrong way — **13 at import -time** and 19 deferred, over six modules, measured at `8e9ffa62` and recorded -as a per-module ratchet in openXdox-code's `tests/test_dependency_direction.py` -(`OPENDOX_BACK_IMPORTS`). They are not a defect of the carve: the manifest's -`import rewrites` class rewrote `ideation_dashboard.` to the package that -now owns ``, and for the gate column that package IS `openxdox`. The -rewrite was correct and the direction it produced is the thing the BUILD arc -removes. - -WHAT THIS MODULE DOES. It makes a surviving reach LATE, NAMED and REFUSABLE -instead of an import-time dependency on the consumer. `import opendox.workbench` -no longer requires openXdox to be installed; the verb that actually needs the -consumer's module resolves it on first use and, when it is absent, refuses with -the layering spelled out rather than raising `ModuleNotFoundError` from an -import line a thousand lines away from the call. - -WHAT IT DELIBERATELY IS NOT. It is not the § 2.4 extension points and it does -not replace them. A route or a subcommand openXdox CONTRIBUTES travels through -`route_extension.RouteBinding` / `subcommand_extension.SubcommandExtension` — -seams this repository already declares (`serve.build_server(route_extensions=)`, -`cli.build_parser(subcommand_extensions=)`) and openXdox-code already supplies -its half of (`serve_gate.routes()`, `serve_projection.routes()`, -`cli_gate.GateSubcommands.register()`). This module is for the OTHER class: a -neutral verb of openDox's own that calls a function living in the consumer's -column. Those calls are what `design.md`:243 permits to survive; their -DIRECTION at import time is what it does not. - -It is kept narrow on purpose so it cannot grow into a facade. A name is added -here only for a reach that is already in the tree and already counted in the -ratchet, and each carries the reason it is still pointing that way. - -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). It sits under -a declared root, so the arrival verifier is told about it explicitly with -`--allow-created src/opendox/consumer_reach.py`. -""" - -from __future__ import annotations - -import importlib -from types import ModuleType -from typing import Any - -#: The consumer package. One spelling, unlike openXdox-code's two: openXdox is -#: an installed distribution (`pyproject.toml` at openXdox-code declares -#: `opendox` as a dependency and is itself installed as `openxdox`), never a -#: directory a runner happens to put on `sys.path`. A bare-name fallback here -#: would make an unrelated top-level `gate_console` on the path answer for the -#: consumer's, which is a worse failure than the absence it would paper over. -CONSUMER_PACKAGE = "openxdox" - - -def _names_the_candidate(missing: str, dotted: str) -> bool: - """Is `missing` the candidate itself, or a package on its dotted path? - - `import_module("openxdox.gate_console")` raises - `ModuleNotFoundError(name="openxdox")` when the PACKAGE is absent and - `name="openxdox.gate_console"` when only the submodule is — both mean "the - consumer is not here". `name="jsonschema"`, raised from inside a consumer - module that DID load, does not, and must not be swallowed: catching - `ImportError` wholesale would report a broken openXdox as a layering - problem and send the reader to the wrong repository. - """ - return dotted == missing or dotted.startswith(f"{missing}.") - - -class ConsumerReachUnavailable(RuntimeError): - """A verb of openDox reached its consumer's column and it is absent. - - Raised instead of `ModuleNotFoundError` so the failure names the LAYERING - rather than a module path: openDox does not ship, pin or depend on - openXdox — the pin runs the other way — so "openXdox is not installed" is - the NORMAL state of a neutral openDox, and a caller that needs this verb is - responsible for assembling a server that has it. - """ - - -class _LateConsumerModule: - """A stand-in for a consumer module, resolved on first use. - - Attribute access — and nothing earlier — performs the import. The resolved - module is cached, so the cost is paid once and `is` identity holds across - accesses, which is what lets a caller `monkeypatch.setattr` the real module - and be seen by openDox's verbs. - """ - - __slots__ = ("_name", "_reason", "_module") - - def __init__(self, name: str, *, reason: str) -> None: - self._name = name - self._reason = reason - self._module: ModuleType | None = None - - @property - def name(self) -> str: - """The dotted name this stand-in resolves, e.g. `openxdox.snapshot`.""" - return f"{CONSUMER_PACKAGE}.{self._name}" - - def resolve(self) -> ModuleType: - """Import the consumer module, or refuse naming the layering. - - An ABSENT consumer refuses; a consumer that is PRESENT and raises while - executing re-raises untouched (`_names_the_candidate`). - """ - if self._module is not None: - return self._module - dotted = self.name - try: - self._module = importlib.import_module(dotted) - except ModuleNotFoundError as exc: - if exc.name is None or not _names_the_candidate(exc.name, dotted): - raise - raise ConsumerReachUnavailable( - f"{dotted!r} belongs to openXdox, the layer that PINS this " - f"one, and openDox does not supply it: {self._reason}. " - "openXdox pins openDox by commit and tree digest " - "(split-opendox § 4.2, RULED OQ-2) and openDox pins nothing " - "back, so a neutral openDox with no openXdox installed is the " - "normal case and this reach is the exception. THE REMEDY IS " - "TO INSTALL openXdox — and only that, today. The § 2.4 " - "extension points are NOT an alternative here and this " - "message will not offer one: `build_server(route_extensions=)` " - "contributes route bindings and " - "`build_parser(subcommand_extensions=)` contributes " - "subcommands, and neither injects a MODULE, so a caller who " - "followed them would arrive back at this same refusal. The " - "injection that would make this reach disappear — openDox " - "naming a protocol and being handed an implementation — does " - "not exist yet and is BUILD-arc work " - "(split-opendox § 3.5/3.6, § 4.3)") from exc - return self._module - - def __getattr__(self, attr: str) -> Any: - # Dunder lookups must not resolve the consumer: `copy`, `pickle`, - # `inspect` and pytest's own assertion rewriting all probe for dunders - # on arbitrary objects, and resolving openXdox because something asked - # for `__wrapped__` would make the reach fire at a moment no verb chose. - if attr.startswith("__") and attr.endswith("__"): - raise AttributeError(attr) - return getattr(self.resolve(), attr) - - def __repr__(self) -> str: - state = "resolved" if self._module is not None else "unresolved" - return f"" - - -class _LateConsumerColumn: - """The BASE-CLASS member of this family: one handler-method column of the - consumer, reached on first CALL instead of at class-definition time. - - THE SITE, AND WHY NOTHING NARROWER WOULD DO. `serve.DashboardHandler` named - `serve_gate.GateRoutes` and `serve_projection.ProjectionRoutes` as MIXIN - BASES. A base expression is evaluated when the class statement runs, which - is when the module loads, so those two lines alone made - `import opendox.serve` require openXdox — and a base, unlike a call, cannot - be deferred by the module stand-in above: a class needs its bases to exist - before its first instance does. - - So the column is replaced by a base openDox OWNS, carrying one method per - name the consumer's column defines. Each forwards - `getattr(, name)(self, *args, **kwargs)` — the SAME function - object, with the SAME `self`, which is the live request handler — so the - handler behaves exactly as it did when it inherited: a contributed binding - naming `_handle_gate_action` or `_serve_index` still resolves - against the bound class at wiring time - (`route_extension.resolve_handlers`), which is the check that refuses a - route that cannot be served BEFORE a socket. - - `design.md`:243 is the standard again: *"What must not survive is the - direction, not the calls."* The call into openXdox's column survives, and it - is the same call; what goes is the import that used to make it at load time. - - WHY THE METHOD NAMES ARE RESTATED HERE. The same reason `defaults.py` - restates three literals: the alternative is reading them off the consumer, - which is the import this removes. `LATE_COLUMN` publishes the triple - (consumer module, class, method names) so openXdox-code's - `tests/test_dependency_direction.py` can hold the two surfaces together and - refuse if either side moves without the other — the drift guard is the - invariant, the restatement is the spelling. - - NOT A GENERAL SUBCLASS PROXY. There is no `__getattr__` here, deliberately: - a handler instance is probed for absent attributes constantly (`hasattr(self, - "do_PUT")` in `http.server`'s own dispatch, `copy`, `pickle`, pytest), and a - base that answered those by importing openXdox would fire the reach at a - moment no verb chose — and, worse, would raise this module's - `ConsumerReachUnavailable` where the caller was testing for `AttributeError`. - A NAMED method list answers exactly the names the column has and nothing - else, and an absent consumer refuses at the call with the layering spelled - out. - """ - - #: `(consumer module, class, method names)`. Set on each generated subclass. - LATE_COLUMN: tuple[str, str, tuple[str, ...]] = ("", "", ()) - - -def _column_forwarder(holder: _LateConsumerModule, class_name: str, attr: str): - """One forwarding method: resolve the column's class, then call through it.""" - - def forward(self, *args: Any, **kwargs: Any) -> Any: - column = getattr(holder.resolve(), class_name) - return getattr(column, attr)(self, *args, **kwargs) - - forward.__name__ = attr - forward.__qualname__ = f"Late{class_name}.{attr}" - forward.__doc__ = ( - f"`{holder.name}.{class_name}.{attr}`, resolved on first call " - f"(consumer_reach._LateConsumerColumn).") - return forward - - -def route_column(holder: _LateConsumerModule, class_name: str, - methods: tuple[str, ...]) -> type: - """A mixin base standing in for `.`.""" - if not methods: - raise ValueError( - "a late column with no methods stands in for nothing; name the " - f"methods {holder.name}.{class_name} defines") - namespace: dict[str, Any] = { - name: _column_forwarder(holder, class_name, name) for name in methods} - namespace["LATE_COLUMN"] = (holder.name, class_name, tuple(methods)) - namespace["__doc__"] = ( - f"Late stand-in for `{holder.name}.{class_name}` as a mixin base. " - "See `consumer_reach._LateConsumerColumn`.") - return type(f"Late{class_name}", (_LateConsumerColumn,), namespace) - - -def module(name: str, *, reason: str) -> _LateConsumerModule: - """A late stand-in for `openxdox.`. `name` carries no package prefix.""" - if name.startswith(f"{CONSUMER_PACKAGE}."): - raise ValueError( - f"consumer_reach.module() takes the module name WITHOUT the " - f"{CONSUMER_PACKAGE!r} prefix; got {name!r}") - return _LateConsumerModule(name, reason=reason) - - -# -------------------------------------------------------------------------- -# The reaches this package still makes, each with the reason it still makes it -# -------------------------------------------------------------------------- - -#: The gate console — openXdox's gate-and-commission loop (`design.md` § D3, -#: the gate column). `cli`'s human-gate plumbing and `branch_session`'s session -#: refusals read `GateConsole`, `GateRefused`, `Provenance`, `PRESENCE_*`, -#: `SURFACE_CLI` and `require_human_gate` off it. The injection that removes -#: the reach altogether is § 4.3/§ 4.5 work at openXdox-code, not a direction -#: fix, so the call stays and only its direction at import time goes. -gate_console = module( - "gate_console", - reason="the gate-and-commission loop is openXdox's column (design.md § D3) " - "and openDox contributes no gate of its own") - -#: THE PROJECTION MECHANISM'S FOUR STAND-INS AND THEIR SIX NAMES ARE GONE -#: (plan 034 T055; #1144 5.5 and 4.3 in part; R1Q10 (a), openxFactory#656 -#: comment 5850003126). `snapshot`, `snapshot_registry`, `corpus_root` and -#: `generator` stood here as module stand-ins, with `find_validator`, -#: `corpus_root_refusal`, `generate_snapshot`, `is_rfc3339_datetime`, -#: `hosted_ref_refused` and the one constant stand-in, `scanned_roots`, bound -#: over them. Each was a neutral verb of openDox's reaching the consumer's -#: projection column, so a lone openDox could neither generate nor build a -#: server (plan 034, research R7). They are now read from DECLARED SEAMS, -#: `opendox.projection_seams` (the snapshot registry and source, the -#: corpus-root predicate, the writer and the validator lookup) and -#: `opendox.generator_seam` (the generate operation), each with openDox's own -#: default, which the entry points register where no host has. The consumer -#: contributes its governed mechanism through the same seams (T059). -#: `is_rfc3339_datetime` is openDox's own (`opendox.rfc3339`), and -#: `hosted_ref_refused` is `serve.py`'s own, over the registry seam's -#: `is_publishable_ref`. With no binding left for them, the callable and value -#: stand-ins (`function`, `constant`) went too. - -#: The projection's HTTP column. Named here only to carry -#: `LateProjectionRoutes` below: `serve.py` holds no other reference to it -#: since plan 034 T055. -serve_projection = module( - "serve_projection", - reason="the snapshot index route is openXdox's column (design.md § D3); " - "this core dispatches it through the § 2.4 route extension point " - "instead of inheriting it") - -#: `resolve_source_path` STOOD HERE and is `serve.py`'s own definition since -#: § 3.4 slice S6 (RULED Q4, openxFactory#656 comment 5642758731). It is the -#: single-root entry point to the `/source` pass-through's containment, and that -#: pass-through is now a FIXED CORE ARM of the neutral product — so a stand-in -#: forwarding into the layer that PINS openDox was the wrong shape for it. The -#: RULE is the snapshot registry's `resolve_within`, which the entry point -#: calls through `serve.py`'s `registry_mod`, the registry seam's proxy since -#: plan 034 T055. `notebook_action.py`:52 still imports the name `from .serve`, -#: and gets a real function rather than a forwarder. - -#: The gate console's HTTP column — the door the gate verbs are posted through. -#: Named here only to carry `LateGateRoutes` below; `serve.py` holds no other -#: reference to it. -serve_gate = module( - "serve_gate", - reason="the gate console's route column is openXdox's (design.md § D3); " - "this core dispatches its one contributed route through the § 2.4 " - "route extension point") - -#: `serve_gate.GateRoutes` as a mixin base — `DashboardHandler`'s third base -#: until slice 2b. Two methods: the contributed `POST /actions/gate/` handler -#: the § 2.4 binding names, and the failure logger it calls. -LateGateRoutes = route_column(serve_gate, "GateRoutes", - ("_handle_gate_action", "_log_gate_failure")) - -#: `serve_projection.ProjectionRoutes` as a mixin base — `DashboardHandler`'s -#: fourth base until slice 2b. ONE method since plan 034 T055: `_serve_index`, -#: which the column's own § 2.4 binding for `/snapshot-index.json` names, and -#: which T084 hands to the handler-contribution facet with the column. -#: -#: IT WAS EIGHT UNTIL § 3.4 SLICE S6 AND FIVE UNTIL T055. S6 (RULED Q4, -#: openxFactory#656 comment 5642758731) made `_keyed_source`, `_serve_source` -#: and `_refuse_bare_source` `serve.py`'s own, because the route they answer is -#: the neutral product's fixed core arm. T055 did the same for the core -#: `/snapshot.json` arm's four, `_query_key`, `_read_snapshot`, -#: `_serve_snapshot` and `_hosted_entry_refused`: forwarded here, a standalone -#: server refused every `/snapshot.json`. The rules they consult (FR-048's -#: hosted-plane confinement, the registry's per-entry resolution) are the -#: snapshot registry's, reached through its seam. -LateProjectionRoutes = route_column( - serve_projection, "ProjectionRoutes", ("_serve_index",)) - -__all__ = [ - "CONSUMER_PACKAGE", - "ConsumerReachUnavailable", - "gate_console", - "LateGateRoutes", - "LateProjectionRoutes", - "module", - "route_column", - "serve_gate", - "serve_projection", -] diff --git a/src/opendox/default_columns.py b/src/opendox/default_columns.py new file mode 100644 index 00000000..b00b195e --- /dev/null +++ b/src/opendox/default_columns.py @@ -0,0 +1,640 @@ +"""openDox's OWN defaults for the four column seams: the gate primitives, the +doxBench scope, kickoff and the cross-reference register (plan 034 T084; +#1144 4.3 as T007 batch G's addendum reads; RULED R1Q10 (a), openxFactory#656 +comment `5850003126`). + +WHAT THIS MODULE IS. `opendox.column_seams` declares four seams where openDox's +own verbs reached the consumer's columns by module name (`openxdox.gate_console`, +`openxdox.gate_routes`, `openxdox.doxbench_scope`, `openxdox.kickoff`, +`openxdox.register`). Each seam is a host's contribution or openDox's default, +and the entry points register the defaults below where no host has (R1Q3 (a)'s +pattern, `5817152735`). openXdox contributes its governed ones through the same +seams (T086). Each default is small and neutral, and it is new code, not +openXdox's modules relocated: R1Q10's option (b), the relocation, was not the +ruling. + +WHAT EACH DEFAULT DOES, as the holder read R1Q10 (a) for T084 (relayed on +openxFactory#656's thread, 2026-10-02; Brett may overrule): + +* `GATE`, the gate primitives. The VOCABULARY-FREE primitives are real code: + the human-gate guard (`require_human_gate`, over `opendox.boundary`'s + `HumanGate`), the clock and the stamp (`_utcnow`, `_stamp`), the records + prefix (`_prefix`) and the session-ref target id (`ref_target_id`), the + gateway `Provenance` with its surface and presence constants and + `HTTP_CONSOLE_TOKEN`, `GateRefused`, the session action and artifact + constants, and the first-edit gate builder chat's Save writes through + (`first_edit_gate_factory`). The GOVERNED record functions, a gate-action + record's build, validation and write, the demotion-receipt validator and the + `GateConsole` facade, are NOT emulated: the gate-action record is a shape only + openxFactory's pinned schema declares, and openDox writes no governed shape it + does not own. With no host's gate registered they refuse, naming the seam and + the call that registers one (4.2), as `GateRecordsNotRegistered`. +* `SCOPE`, the doxBench scope authority. `resolve_scope` projects a tile of the + neutral snapshot (a group's members, a selection's files, a candidate's + claiming groups' members), each path confined to the selected root by the + registry seam's own `resolve_within`. A TILE'S OWN DOCUMENTS ARE EDITABLE + (RULED by Brett Heap, openxFactory#656 comment `5961651355`, "Tile's own + documents editable (Recommended)", superseding the holder's read-only + reading): those are exactly the three sections above, as openXdox's own + authority marks its owned sections, and the set is one named function, + `editable_paths`. `is_live_session_ref` keeps the governed authority's logic, which is + openDox's own session layer (`branch_session.live_session_branches`). + `session_created_paths_for_scope` answers no path: which documents a session + CREATED is known only from governed gate-action records, which this default + neither writes nor reads. +* `KICKOFF`: no dispatched commission and no dispatched proposal, and no + project register, so `/project-register.json` keeps today's structured + "no project register" answer. +* `REGISTER`: a cross-reference register with no possibles. + +NOTHING HERE NAMES THE CONSUMER OR THE PUBLISHER, at import time or in a body: +the standard library, `opendox.boundary`, `opendox.path_slug`, +`opendox.doxbench_threads` and `opendox.defaults` at import, and +`opendox.branch_session` and `opendox.projection_seams` inside the bodies that +need them, so `import opendox.default_columns` starts nothing. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path, PurePosixPath +from typing import Any, Callable, Iterable, Mapping, NamedTuple, Sequence + +from opendox import defaults +from opendox.column_seams import GATE_RECORDS_REFUSAL +# openDox's OWN SETTINGS DOCUMENTS, never a tile's editable material (plan 034 +# T084, adversarial review 2, M1). Both modules are stdlib-only at import. +from opendox.doxbench_binding import DEFAULT_BINDINGS_RELPATH +from opendox.doxbench_intake import DEFAULT_DECLARATIONS_RELPATH +from opendox.boundary import GATE_SIDE_EFFECT, BoundaryViolation, HumanGate, Refusal +from opendox.doxbench_scope_types import ( + ScopeConfinementError, + ScopeDocument, + ScopeKey, + ScopeProjection, + ScopeSection, +) +from opendox.doxbench_threads import THREAD_PREFIX +from opendox.path_slug import slug + +__all__ = [ + "GATE", + "GATE_RECORDS_REFUSAL", + "GateRecordsNotRegistered", + "GateRefused", + "KICKOFF", + "Provenance", + "REGISTER", + "SCOPE", +] + + +class _Registration: + """One default, as a seam registration: a named object carrying the names + its seam requires. `__name__` is what a refusal names it by.""" + + def __init__(self, name: str, members: Mapping[str, Any]) -> None: + self.__name__ = name + for key, value in members.items(): + setattr(self, key, value) + + def __repr__(self) -> str: + return f"" + + +# ========================================================================== +# GATE — the vocabulary-free primitives, and refusals for the governed rest +# ========================================================================== + +class GateRefused(Exception): + """A gate action refused on a precondition. openDox's default raises it + for a refusal of its own, and `GateRecordsNotRegistered` below for the + governed functions it does not carry.""" + + +class GateRecordsNotRegistered(GateRefused): + """A governed gate function was asked for and no host's gate is + registered: openDox's default carries none of them (see the module + docstring).""" + + def __init__(self, what: str) -> None: + super().__init__(f"{what}: {GATE_RECORDS_REFUSAL}") + + +def _governed(what: str): + def refuse(*_args: Any, **_kwargs: Any): + raise GateRecordsNotRegistered(what) + + refuse.__name__ = what.split(" ", 1)[0] + refuse.__doc__ = (f"`{what}`, which openDox's default does not carry: " + "refused as `GateRecordsNotRegistered`.") + return refuse + + +class _NoGateConsole: + """`GateConsole`, which openDox's default does not carry: constructing it + refuses as `GateRecordsNotRegistered`.""" + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + raise GateRecordsNotRegistered("GateConsole") + + +#: The gateway SURFACES and the CONSOLE-PRESENCE proofs a provenance block may +#: name. The words are the gateway facts openDox's own entry points observe +#: (`cli.console_presence`, the console-token check), and nothing governs them +#: but this list. +SURFACE_HTTP = "http" +SURFACE_CLI = "cli" +SURFACES = (SURFACE_HTTP, SURFACE_CLI) +PRESENCE_CONSOLE_TOKEN = "console-token" +PRESENCE_TTY = "tty" +PRESENCE_DECLARED = "declared" +CONSOLE_PRESENCES = (PRESENCE_CONSOLE_TOKEN, PRESENCE_TTY, PRESENCE_DECLARED) + + +@dataclass(frozen=True) +class Provenance: + """The gateway facts about one invocation: the surface it arrived on and + how console presence was shown. A type, validated at construction, so a + mapping a request body could carry is never a provenance.""" + + surface: str + console_presence: str + + def __post_init__(self) -> None: + if self.surface not in SURFACES: + raise GateRefused( + f"unknown gateway surface {self.surface!r}: a provenance names " + f"one of {', '.join(SURFACES)}") + if self.console_presence not in CONSOLE_PRESENCES: + raise GateRefused( + f"unknown console-presence proof {self.console_presence!r}: a " + f"provenance names one of {', '.join(CONSOLE_PRESENCES)}, and " + "there is no value meaning 'presence was not shown'") + + def as_record(self) -> dict: + return {"surface": self.surface, "console_presence": self.console_presence} + + +HTTP_CONSOLE_TOKEN = Provenance(SURFACE_HTTP, PRESENCE_CONSOLE_TOKEN) + + +def require_human_gate(gate: Any) -> HumanGate: + """A `HumanGate` passes; anything else is REJECTED and REPORTED: appended to + its own refusal ledger where it has one, then raised as a + `BoundaryViolation` carrying the structured `Refusal`.""" + if isinstance(gate, HumanGate): + return gate + actor = (getattr(gate, "actor", None) + or getattr(gate, "human_actor", None) or "non-human") + refusal = Refusal( + GATE_SIDE_EFFECT, str(actor), "", + "a gate action requires a HumanGate constructed with an identified " + "human actor; an OutputBoundary or agent path cannot invoke one") + ledger = getattr(gate, "refusals", None) + if isinstance(ledger, list): + ledger.append(refusal) + raise BoundaryViolation(refusal) + + +def _utcnow() -> str: + """Wall-clock UTC, as a date-time: a gate action is a live event.""" + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _stamp(at: str) -> str: + """A filesystem-safe slug of a timestamp (its separators dropped).""" + return re.sub(r"[^0-9A-Za-z]", "", at) or "unstamped" + + +def _prefix(records_dir: str) -> str: + return records_dir if records_dir.endswith("/") else records_dir + "/" + + +def ref_target_id(ref: str) -> str: + """The records-path segment for a session ref: the branch, slugged, so a + hostile value can never traverse the records tree.""" + return slug(str(ref or "").replace("/", "-")) + + +def first_edit_gate_factory(actor: str, records_dir: str): + """The worktree-rooted gate chat's Save writes through: the records tree + and the thread-sidecar tree declared, and nothing else.""" + def build(worktree): + return HumanGate(worktree, [records_dir, THREAD_PREFIX], + human_actor=actor, session_root=worktree) + return build + + +GATE = _Registration("opendox.default_columns.GATE", { + # the vocabulary-free primitives, as real code + "GateRefused": GateRefused, + "HumanGate": HumanGate, + "Provenance": Provenance, + "require_human_gate": require_human_gate, + "ref_target_id": ref_target_id, + "first_edit_gate_factory": first_edit_gate_factory, + "_prefix": _prefix, + "_stamp": _stamp, + "_utcnow": _utcnow, + "ACTION_ABANDON_SESSION": "abandon-session", + "ACTION_CREATE_DOCUMENT": "create-document", + "ACTION_EDIT_DOCUMENT": "edit-document", + "ART_COMMIT": "commit", + "DEFAULT_RECORDS_DIR": defaults.DEFAULT_RECORDS_DIR, + "HTTP_CONSOLE_TOKEN": HTTP_CONSOLE_TOKEN, + "PRESENCE_DECLARED": PRESENCE_DECLARED, + "PRESENCE_TTY": PRESENCE_TTY, + "SURFACE_CLI": SURFACE_CLI, + # the governed rest, refused by name (4.2) + "GateConsole": _NoGateConsole, + "build_gate_action_record": _governed("build_gate_action_record"), + "validate_gate_action_record": _governed("validate_gate_action_record"), + "write_gate_action_record": _governed("write_gate_action_record"), + "validate_demotion_execution_receipt": _governed( + "validate_demotion_execution_receipt"), +}) + + +# ========================================================================== +# SCOPE — a small, neutral projection of a tile, its own documents editable +# ========================================================================== + +def _mapping(value: Any) -> Mapping[str, Any]: + return value if isinstance(value, Mapping) else {} + + +def _sequence(value: Any) -> Sequence[Any]: + return value if isinstance(value, (list, tuple)) else () + + +def _text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _canonical(path: Any) -> str: + """`path` if it is a canonical repository-relative POSIX path, or a + `ScopeConfinementError`: a projection never carries a path it cannot + name safely.""" + if (not isinstance(path, str) or not path or "\x00" in path or "\\" in path + or path.startswith("/")): + raise ScopeConfinementError( + "scope paths must be non-empty repository-relative POSIX paths") + parts = PurePosixPath(path).parts + if PurePosixPath(path).as_posix() != path or ".." in parts or "." in parts: + raise ScopeConfinementError( + f"scope path {path!r} must use canonical repository-relative spelling") + return path + + +class _DocumentIndex(NamedTuple): + """Each listed document's PATH, looked up in the namespace a reference + is written in: `ids` for a document ID, `paths` for a document PATH.""" + + ids: Mapping[str, str] + paths: Mapping[str, str] + + +def _document_index(snapshot: Mapping[str, Any]) -> _DocumentIndex: + """Each listed document's PATH, by its id and, apart, by its path. + + A group's `document_edges[].document` names a document by ID, and a + selection's `files` by PATH (the snapshot schema's `$defs/id` and + `$defs/path`). The neutral generator happens to write ids equal to paths, + but the contract does not promise it: a snapshot with id `notes/soil-test` + and path `notes/soil-test.md` is valid (Copilot review of + openDox-code#77, r4171136778). So every reference is looked up here and + the section carries the document's path, as openXdox's authority does. + + THE TWO NAMESPACES ARE KEPT APART (r4173844321). Nor does the contract + forbid one document's path from equaling another document's id, so one + map from either spelling would resolve a selection's file `x` to the + document whose ID is `x`. `paths` answers a path only. `ids` answers an + id first and then, for an edge written as a path, a path: an id is + indexed first, so a path that equals some other document's id cannot + take that id's place.""" + ids: dict[str, str] = {} + paths: dict[str, str] = {} + documents = [_mapping(d) for d in _sequence(snapshot.get("documents"))] + for field in ("id", "path"): + for document in documents: + key, path = _text(document.get(field)), _text(document.get("path")) + if key and path and key not in ids: + ids[key] = path + for document in documents: + path = _text(document.get("path")) + if path and path not in paths: + paths[path] = path + return _DocumentIndex(ids=ids, paths=paths) + + +def _section(key: str, label: str, note: str, references: Iterable[Any], *, + index: Mapping[str, str], seen: set[str], root: Path, + inherited: bool, owned: bool) -> ScopeSection: + """One section of a tile: each reference as the document it names in + `index`, the namespace its references are written in (`_DocumentIndex`), + by that document's path, confined to `root`. A + reference no listed document answers is kept, unresolved, under its own + spelling, which must still be a safe path.""" + from opendox import projection_seams + + resolve_within = projection_seams.registry.current().resolve_within + rows: list[ScopeDocument] = [] + for raw in references: + listed = index.get(_text(raw)) if isinstance(raw, str) else None + path = _canonical(listed if listed is not None else raw) + if path in seen: + continue + seen.add(path) + resolved = listed is not None and resolve_within(root, path) is not None + rows.append(ScopeDocument(id=_text(raw) or path, path=path, + resolved=resolved)) + return ScopeSection(key=key, label=label, note=note, inherited=inherited, + owned=owned, documents=tuple(rows)) + + +#: The documents an install's own settings live in: the model-provider +#: bindings and the model declarations. They sit in the checkout, so a corpus +#: scan can list them and a tile can name them. They are never a tile's OWN +#: material: a turn that could edit one could rewrite which provider a chat +#: talks to, or approve a pending declaration, through a proposal. So +#: `resolve_scope` keeps them out of every owned section, into a section of +#: their own that is readable and owned by nothing, and `editable_paths` +#: refuses them whatever section carries them. An in-root symlink that reaches +#: one is treated as the document it reaches (`_settings_test`). The corpus +#: scan's own exclusion (openDox-code#76) is a second layer, not this one. +SETTINGS_DOCUMENTS: frozenset[str] = frozenset({ + DEFAULT_BINDINGS_RELPATH, DEFAULT_DECLARATIONS_RELPATH}) + +_SETTINGS_SECTION = ("settings", "openDox's own settings documents", + "the install's settings: readable here, and never " + "editable through a tile") + + +def _settings_test(root: Path) -> Callable[[str], bool]: + """Whether a row's path IS one of openDox's settings documents, by its + spelling or by the file it reaches under `root`. + + A SYMLINK ALIAS IS ONE (Copilot review of openDox-code#77, r4173903232). + `resolve_within` follows links to the canonical file, but a row keeps the + spelling it was named by, so `alias.md -> ideation/dashboard/ + model-provider-bindings.yaml` (or a directory link on the way) compared + unequal to every settings path and stayed owned and editable. So the + file a row resolves to is compared with the files the settings documents + resolve to, and an alias is moved out of the owned sections under its own + name, like the document it reaches.""" + from opendox import projection_seams + + resolve_within = projection_seams.registry.current().resolve_within + targets = {target for target in (resolve_within(root, name) + for name in SETTINGS_DOCUMENTS) + if target is not None} + + def is_settings(path: str) -> bool: + return path in SETTINGS_DOCUMENTS or ( + bool(targets) and resolve_within(root, path) in targets) + + return is_settings + + +def _without_settings(sections: Sequence[ScopeSection], *, + root: Path) -> list[ScopeSection]: + """`sections` with every settings document, or an alias that reaches one + (`_settings_test`), moved out of an OWNED section into one trailing + section that nothing owns, in the order they appeared.""" + is_settings = _settings_test(root) + kept: list[ScopeSection] = [] + moved: list[ScopeDocument] = [] + for section in sections: + if not section.owned: + kept.append(section) + continue + rows = [row for row in section.documents if not is_settings(row.path)] + moved.extend(row for row in section.documents if is_settings(row.path)) + kept.append(ScopeSection(key=section.key, label=section.label, + note=section.note, inherited=section.inherited, + owned=True, documents=tuple(rows))) + if moved: + key, label, note = _SETTINGS_SECTION + kept.append(ScopeSection(key=key, label=label, note=note, + inherited=False, owned=False, + documents=tuple(moved))) + return kept + + +def editable_paths(sections: Sequence[ScopeSection]) -> tuple[str, ...]: + """The paths a projected tile lets a turn edit: THE TILE'S OWN DOCUMENTS. + + RULED by Brett Heap, openxFactory#656 comment `5961651355`, "Tile's own + documents editable (Recommended)", which supersedes the holder's read-only + reading of R1Q10 (a). A tile's own documents are the resolved rows of its + OWNED sections, in order and once each, as openXdox's own authority derives + its editable set from its owned sections. In openDox's default every + section a tile projects is its own: a group's members, a selection's files + and a candidate's claiming groups' members. Nothing outside them is + editable, a row that does not resolve is not, and neither is a created path + (this default records none). openDox's turn guard requires a turn's paths + to be in scope AND editable (`doxbench_turns._require_in_scope_and_editable`), + so a turn over a tile's own document passes it, and one over any other + document is still refused. openDox's own settings documents + (`SETTINGS_DOCUMENTS`) are never editable, whatever section carries them. + ONE named function, so the set is decided in one place.""" + editable: list[str] = [] + for section in sections: + if not section.owned: + continue + for row in section.documents: + if (row.resolved and row.path not in editable + and row.path not in SETTINGS_DOCUMENTS): + editable.append(row.path) + return tuple(editable) + + +def resolve_scope(snapshot: Mapping[str, Any], key: ScopeKey, *, + source_root: Path, created_paths: Iterable[str] = () + ) -> ScopeProjection | None: + """One tile of the neutral snapshot, or None where the snapshot has no + such tile. + + A group (`cluster`) projects its members, a selection (`staged`) its files, + and a candidate (`possible`) the members of the groups that claim it. Each + path is confined to `source_root` by the registry seam's own + `resolve_within`. Each of those sections is the tile's OWN, so its + resolved documents are editable (`editable_paths`, RULED `5961651355`), + `active_document_candidates` are the documents the turn guard would + accept, and there is no outline. A `created_paths` entry is confined and + readable, never editable.""" + if not isinstance(snapshot, Mapping): + return None + if isinstance(created_paths, (str, bytes, bytearray)): + raise ScopeConfinementError( + "created_paths must be a collection of repository-relative paths") + try: + created = tuple(created_paths) + except TypeError as error: + raise ScopeConfinementError( + "created_paths must be a collection of repository-relative paths" + ) from error + root = Path(source_root) + index = _document_index(snapshot) + groups = {_text(_mapping(g).get("id")): _mapping(g) + for g in _sequence(snapshot.get("clusters"))} + + def members(group: Mapping[str, Any]) -> list[Any]: + return [_mapping(edge).get("document") + for edge in _sequence(group.get("document_edges"))] + + seen: set[str] = set() + sections: list[ScopeSection] = [] + keywords: tuple[str, ...] = () + if key.tile_kind == "cluster": + group = groups.get(key.tile_id) + if group is None: + return None + title = _text(group.get("name")) or key.tile_id + keywords = tuple(_text(t) for t in _sequence(group.get("topics")) if _text(t)) + sections.append(_section( + "members", "group documents", "the group's own document edges", + members(group), index=index.ids, seen=seen, root=root, inherited=False, + owned=True)) + elif key.tile_kind == "staged": + selection = next((_mapping(s) for s in _sequence(snapshot.get("staged_topics")) + if _text(_mapping(s).get("staging_id")) == key.tile_id), None) + if selection is None: + return None + title = key.tile_id + sections.append(_section( + "files", "selection files", "the documents this selection names", + _sequence(selection.get("files")), index=index.paths, seen=seen, root=root, + inherited=False, owned=True)) + elif key.tile_kind == "possible": + candidate = next((_mapping(p) for p in _sequence(snapshot.get("possibles")) + if _text(_mapping(p).get("id")) == key.tile_id), None) + if candidate is None: + return None + title = _text(candidate.get("title")) or key.tile_id + claimed = [path for group_id in _sequence(candidate.get("claiming_clusters")) + for path in members(groups.get(_text(group_id), {}))] + sections.append(_section( + "claiming", "documents of the claiming groups", + "membership inferred from the groups that claim this candidate", + claimed, index=index.ids, seen=seen, root=root, inherited=True, + owned=True)) + else: + return None + sections = _without_settings(sections, root=root) + context = [row.path for section in sections for row in section.documents + if row.resolved] + for raw in created: + path = _canonical(raw) + if path not in context: + context.append(path) + revision = _text(_mapping(snapshot.get("generation")).get("source_revision")) + editable = editable_paths(sections) + return ScopeProjection( + key=key, title=title, keywords=keywords, source_revision=revision, + sections=tuple(sections), context_paths=tuple(context), + editable_paths=editable, outline_path=None, + # what the turn guard would accept: in scope AND editable + active_document_candidates=tuple(p for p in context if p in editable)) + + +def _normalize_ref(ref: Any) -> str: + from opendox import projection_seams + + text = str(ref).strip() if ref is not None else "" + return text or projection_seams.registry.current().DEFAULT_REF + + +def is_live_session_ref(registry: Any, key: ScopeKey, *, repository: str, + ref: str) -> bool: + """Whether `ref` is one of THIS tile's live session branches, by openDox's + own session layer. A declared session refusal (a cross-tile collision, an + ambiguous family) is "not this tile's session"; any other failure is the + caller's to handle.""" + from opendox import branch_session + + kinds = {"cluster": branch_session.CLUSTER, + "possible": branch_session.POSSIBLE, + "staged": branch_session.STAGED_TOPIC} + scope_kind = kinds.get(key.tile_kind) + if scope_kind is None or not ref or registry is None: + return False + try: + tile = branch_session.Tile(scope_kind, key.tile_id) + live = branch_session.live_session_branches(registry, repository, tile) + except branch_session.SessionRefused: + return False + wanted = _normalize_ref(ref) + return any(wanted == _normalize_ref(branch) for branch in live) + + +def session_created_paths_for_scope(registry: Any, key: ScopeKey, *, + repository: str, ref: str, + source_root: Path | str) -> tuple[str, ...]: + """No path: which documents a session created is known only from the + governed gate-action records, which this default neither writes nor + reads.""" + return () + + +SCOPE = _Registration("opendox.default_columns.SCOPE", { + "resolve_scope": resolve_scope, + "is_live_session_ref": is_live_session_ref, + "session_created_paths_for_scope": session_created_paths_for_scope, +}) + + +# ========================================================================== +# KICKOFF and REGISTER — nothing dispatched, no register +# ========================================================================== + +def dispatched_commission_rows(records_root: Any, verb: str) -> list: + """No dispatched commission: openDox commissions no workflow.""" + return [] + + +def dispatched_commissions(records_root: Any, verb: str) -> dict: + """No dispatched commission, by target.""" + return {} + + +def dispatched_propose_topics(records_root: Any) -> set: + """No dispatched proposal.""" + return set() + + +def discover_project_register(root: Any) -> None: + """No project register: openDox's own corpus declares none.""" + return None + + +KICKOFF = _Registration("opendox.default_columns.KICKOFF", { + "dispatched_commission_rows": dispatched_commission_rows, + "dispatched_commissions": dispatched_commissions, + "dispatched_propose_topics": dispatched_propose_topics, + "discover_project_register": discover_project_register, +}) + + +class _NoPossibles: + def possibles(self) -> tuple: + return () + + +class CrossReferenceIndexAdapter: + """A cross-reference register with no possibles: openDox's own corpus + declares none.""" + + @classmethod + def discover(cls, root: Any) -> _NoPossibles: + return _NoPossibles() + + +REGISTER = _Registration("opendox.default_columns.REGISTER", { + "CrossReferenceIndexAdapter": CrossReferenceIndexAdapter, +}) diff --git a/src/opendox/generator_seam.py b/src/opendox/generator_seam.py index c7edcd30..674fc0f4 100644 --- a/src/opendox/generator_seam.py +++ b/src/opendox/generator_seam.py @@ -9,7 +9,8 @@ contributes it *"through a DECLARED GENERATOR SEAM"*. That seam *"SHALL BE DECLARED BY THIS ARC, naming the operation it hands over, the registration point, and what a conformant implementation must satisfy"*. `consumer_reach.py` -names the same gap from the other side. The injection that would retire its +named the same gap from the other side (until plan 034 T084 retired it). The +injection that would retire its `generator` reach, *"openDox naming a protocol and being handed an implementation"*, *"does not exist yet and is BUILD-arc work"*. This module is that declaration (plan 034's T052). diff --git a/src/opendox/profile_proxy.py b/src/opendox/profile_proxy.py index 6b74534c..92f59019 100644 --- a/src/opendox/profile_proxy.py +++ b/src/opendox/profile_proxy.py @@ -15,17 +15,18 @@ `Dox` profile may want; not now"). This module is that proxy, and `domain_profile.py` beside it is the registration it resolves through. -THE PATTERN IS THIS REPOSITORY'S OWN. `consumer_reach.py` (§ 4.1, landed) -already defers a name to first use — `_LateConsumerModule` defers an attribute -read, `_LateConsumerValue` defers the first OPERATION on a value — and refuses -with the layering spelled out instead of raising `ModuleNotFoundError` from an -import line a thousand lines away from the call. `_LateProfile` below is the -same shape pointed at a different question. It is NOT in `consumer_reach.py`, -deliberately: that module is for reaches into `openxdox`, the package that PINS -openDox, and every name in it is counted in a ratchet that must reach zero. A -host profile is not a reach into the consumer at all — the host may be an -openxFactory, a `MedxDox`, or a test — so filing it there would corrupt the one -number `tests/test_consumer_reach.py` exists to hold. +THE PATTERN IS THIS REPOSITORY'S OWN. `consumer_reach.py` (§ 4.1; retired at +plan 034 T084, when its last reaches became declared seams) deferred a name to +first use — `_LateConsumerModule` deferred an attribute read, +`_LateConsumerValue` the first OPERATION on a value — and refused with the +layering spelled out instead of raising `ModuleNotFoundError` from an import +line a thousand lines away from the call. `_LateProfile` below is the same +shape pointed at a different question. It was NOT in `consumer_reach.py`, +deliberately: that module was for reaches into `openxdox`, the package that +PINS openDox, and every name in it was counted in a ratchet that had to reach +zero, as it did at T084. A host profile is not a reach into the consumer at +all — the host may be an openxFactory, a `MedxDox`, or a test — so filing it +there would have corrupted the one number that ratchet held. WHAT IT RESOLVES, AND WHEN. Nothing at import time. `import opendox.profile_proxy` performs no lookup, touches no registry and cannot fail @@ -166,8 +167,9 @@ def __getattr__(self, attr: str) -> Any: # because something asked for `__wrapped__` would fire the composition # point at a moment no caller chose — and would raise # `ProfileNotRegistered` where the prober was testing for - # `AttributeError`. `consumer_reach._LateConsumerModule` holds the same - # line for the same reason. + # `AttributeError`. `projection_seams._SeamProxy` holds the same line + # for the same reason (as `consumer_reach._LateConsumerModule` did, + # until plan 034 T084 retired it). if attr.startswith("__") and attr.endswith("__"): raise AttributeError(attr) # A composition point's read of the facet it composes from IS the build diff --git a/src/opendox/projection_seams.py b/src/opendox/projection_seams.py index c426196a..06a62466 100644 --- a/src/opendox/projection_seams.py +++ b/src/opendox/projection_seams.py @@ -10,7 +10,7 @@ validator (`openxdox.snapshot`). With openXdox absent, which is the normal state of a neutral openDox, each of those reaches refused, so a server could not be BUILT standalone (plan 034, research R7) and a generate verb could not write. -`consumer_reach` names the gap itself: the injection that would retire a reach, +`consumer_reach` named the gap itself: the injection that would retire a reach, *"openDox naming a protocol and being handed an implementation"*, *"does not exist yet and is BUILD-arc work"*. This module is that injection for the four. @@ -83,7 +83,7 @@ import threading from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Callable __all__ = [ "CORPUS_ROOT_CALLABLES", @@ -234,12 +234,30 @@ class _Seam: `callables` and `values` are the names a registration must carry. `default` names openDox's own default, for the refusal. The records a registration keeps are whether it is the entry point's default, and whether - a consumer has read that default since it was registered.""" + a consumer has read that default since it was registered. + + `module` names the module that declares the seam, in every refusal and in + the call a host makes. It is this module's own by default, and + `opendox.column_seams` declares its four seams through the same class + (plan 034 T084), so every seam of openDox's keeps one discipline. + + `shape`, where a seam's consumers rely on more than a name being present + and callable, answers the registration's further defects as sentences, an + empty list for none. A registration with any is refused at registration, + as one lacking a name is, rather than at the first consumer that relies on + it (an exception class a consumer catches, say, or a member of a member it + calls).""" def __init__(self, name: str, *, what: str, callables: tuple[str, ...], values: tuple[str, ...] = (), default: str, - consequence: str) -> None: + consequence: str, + module: str = "opendox.projection_seams", + shape: Callable[[Any], list[str]] | None = None) -> None: self.name = name + self.module = module + self._shape = shape + #: The module's own name without the package, as a refusal names a call. + self._short = module.rsplit(".", 1)[-1] self.what = what self.callables = callables self.values = values @@ -247,7 +265,7 @@ def __init__(self, name: str, *, what: str, callables: tuple[str, ...], self.consequence = consequence #: The ONE call a host makes, quoted verbatim in every refusal. self.registration_call = ( - f"opendox.projection_seams.{name}.register()") + f"{module}.{name}.register()") self._registered: Any = None self._is_default = False self._default_read = False @@ -272,7 +290,9 @@ def register(self, registration: Any) -> Any: if registration is not None and registration is self._registered: return registration _probe(registration, self.callables, self.values, - f"projection_seams.{self.name}.register()", f"the host's {self.what}") + f"{self._short}.{self.name}.register()", f"the host's {self.what}") + self._probe_shape(registration, f"{self._short}.{self.name}.register()", + f"the host's {self.what}") with self._lock: held = self._registered if held is registration: @@ -289,7 +309,7 @@ def register(self, registration: Any) -> Any: f"{name_of(registration)} would replace it. Registration " "happens ONCE, at process start: one process holding two " f"would split its consumers between them. Call " - f"opendox.projection_seams.{self.name}.unregister() first if " + f"{self.module}.{self.name}.unregister() first if " "the swap is deliberate.") raise SeamAlreadyRegistered( f"openDox's own default {self.what} ({name_of(held)}) is " @@ -300,7 +320,7 @@ def register(self, registration: Any) -> Any: "would (R1Q3 (ii), openxFactory#656 comment 5817152735; RN-1 (a), " "comment 5850003126). Register the host's own at process start, " f"ahead of {_ENTRY_POINTS}. Call " - f"opendox.projection_seams.{self.name}.unregister() first if the " + f"{self.module}.{self.name}.unregister() first if the " "swap is deliberate.") def register_default(self, registration: Any) -> Any: @@ -315,13 +335,32 @@ def register_default(self, registration: Any) -> Any: if registration is not None and registration is self._registered: return registration _probe(registration, self.callables, self.values, - f"projection_seams.{self.name}.register_default()", + f"{self._short}.{self.name}.register_default()", f"openDox's own default {self.what}") + self._probe_shape(registration, + f"{self._short}.{self.name}.register_default()", + f"openDox's own default {self.what}") with self._lock: if self._registered is None: self._begin(registration, is_default=True) return self._registered + def _probe_shape(self, registration: Any, call: str, what: str) -> None: + """Refuse a registration whose `shape` answers a defect (see the class + docstring). Raised as `_probe` raises, a `TypeError` naming the call.""" + if self._shape is None: + return + try: + defects = list(self._shape(registration)) + except Exception as exc: # noqa: BLE001 - a shape it cannot show is a defect + raise TypeError( + f"{call} takes {what}, and the shape of " + f"{name_of(registration)} could not be read: {exc}") from exc + if defects: + raise TypeError( + f"{call} takes {what}, and {name_of(registration)} " + f"carries the names but not their shape: {'; '.join(defects)}.") + def unregister(self) -> None: """Drop the registration, a host's or the default, and its records. For test isolation and for a host tearing down.""" @@ -332,6 +371,13 @@ def is_registered(self) -> bool: """Is anything registered? Answers without reading or refusing.""" return self._registered is not None + def holds_a_hosts(self) -> bool: + """Is a HOST's registration held here, not the entry point's default + and not nothing? Answers without reading, so it closes no default's + window, and without refusing.""" + with self._lock: + return self._registered is not None and not self._is_default + def current(self) -> Any: """The registration, or a refusal naming this seam and its call. @@ -344,13 +390,13 @@ def current(self) -> Any: if registered is None: raise SeamNotRegistered( f"no {self.what} is registered at openDox's {self.name} seam " - f"(opendox.projection_seams.{self.name}), so {self.consequence}. " + f"({self.module}.{self.name}), so {self.consequence}. " f"openDox ships its own, {self.default}, but it is a " "registration an ENTRY POINT makes and never a fallback here " f"({_DEFAULTS_RULING}). {_ENTRY_POINTS} register it where no " "host has. Nothing is registered now, so either nothing in " "this process has run one of them, or " - f"opendox.projection_seams.{self.name}.unregister() has " + f"{self.module}.{self.name}.unregister() has " "dropped the registration since. A host that contributes its " "own registers it at process start with\n\n " + self.registration_call + "\n\nbefore anything reads it.") diff --git a/src/opendox/runtime/cli.py b/src/opendox/runtime/cli.py index 605f65ad..ad15d616 100644 --- a/src/opendox/runtime/cli.py +++ b/src/opendox/runtime/cli.py @@ -1136,7 +1136,7 @@ def register(subparsers: Any) -> None: """ runtime = subparsers.add_parser( "runtime", - help="the identity and coordination runtime (split-opendox § 3.5)", + help="the identity and coordination runtime", description="Lifecycle verbs for the openDox runtime: FastAPI + " "Postgres holding identity and coordination (RULING Q1), " "OIDC through the Keycloak broker (RULING Q2).") diff --git a/src/opendox/serve.py b/src/opendox/serve.py index 22a2ff30..48db44ee 100644 --- a/src/opendox/serve.py +++ b/src/opendox/serve.py @@ -148,9 +148,9 @@ # evaluated where the `def` sits, at import time. openDox owns those two # values (`defaults.py`), and openXdox-code's drift guard holds the literals # together. -from opendox import consumer_reach # noqa: E402 from opendox import defaults # noqa: E402 from opendox import projection_seams # noqa: E402 +from opendox import column_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. @@ -456,6 +456,65 @@ ACTIONS_WORKBENCH_MODEL_INTAKE_ROUTE = "/actions/workbench/model-intake" ACTIONS_WORKBENCH_MODEL_APPROVAL_ROUTE = "/actions/workbench/model-approval" LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) +# THE TWO ROUTES THE `actions` MAP NAMES THAT A HOST CONTRIBUTES (plan 034 +# T084; #1144 4.3 as T007 batch L's addendum reads, RULED openxFactory#656 +# `5920216845`, item 1). Neither is a fixed core arm: a gate verb is +# `POST /actions/gate/` and the refresh is `POST /actions/refresh`, and +# each answers only where a route binding the assembly collected carries it. +# Everything else `do_POST` reaches is core. So `gate` and `refresh` are true +# only where such a binding is assembled (`compute_capabilities`), and a +# standalone server, which carries neither, reports both false rather than +# offering two affordances that would answer `404 unknown_action`. +ACTIONS_GATE_PREFIX = "/actions/gate/" +ACTIONS_REFRESH_ROUTE = "/actions/refresh" + +# THE STATIC BUNDLE'S CONTENT TYPES, PINNED (plan 034 T084, the holder's +# addition for #1144 10.2, "reachable in a browser from an openDox-only +# install", from T075's finding on openDox-code#73). The static route is +# `SimpleHTTPRequestHandler`'s, whose `guess_type` reads the handler's +# `extensions_map` FIRST and the platform's `mimetypes` table only for an +# extension that map lacks. The platform table is the host's: on Linux and +# in CI it answers `text/javascript` for `.js`, but a host whose table +# differs, and Windows reads its table from the registry, can serve an ES +# module as `text/plain`, which a browser refuses to run, so the console +# opens blank. Every extension the wheel's bundle carries is pinned here +# (measured at T084: 41 files under `opendox/web/`, 39 `.js`, one `.html` and +# one `.css`), with the types the bundle's own kinds of file take beside +# them. Each value is the one the standard library's built-in table gives +# (`.woff2`, which it lacks, takes its registered type, RFC 8081), so a host +# whose table was already right serves exactly what it served before. Any +# other extension still falls back to the platform table. +STATIC_CONTENT_TYPES: dict[str, str] = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/vnd.microsoft.icon", + ".woff2": "font/woff2", +} + + +def answers_a_gate_verb(binding) -> bool: + """Whether a contributed route binding answers `POST /actions/gate/` + for some verb: a POST prefix at or under `ACTIONS_GATE_PREFIX`, or one that + covers it, or an exact POST naming one verb under it. The match rule is the + binding's own (`RouteBinding.matches`), read for a family of paths.""" + if binding.method != "POST": + return False + pattern = binding.pattern + if binding.is_prefix: + return (pattern.startswith(ACTIONS_GATE_PREFIX) + or ACTIONS_GATE_PREFIX.startswith(pattern)) + return (pattern.startswith(ACTIONS_GATE_PREFIX) + and len(pattern) > len(ACTIONS_GATE_PREFIX)) + + +def answers_the_refresh(binding) -> bool: + """Whether a contributed route binding answers `POST /actions/refresh`.""" + return binding.matches("POST", ACTIONS_REFRESH_ROUTE) _DEFAULT_CAPABILITIES = {"actions": {"notebook": False, "gate": False, "refresh": False, "session": False, "edit": False, @@ -467,7 +526,8 @@ def compute_capabilities(*, nlm_present: bool, checkout_real: bool, loopback: bool, actor: str | None = None, - refresh_binding: str | None = None) -> dict: + refresh_binding: str | None = None, + route_bindings: tuple = ()) -> dict: """The startup capability verdict. The notebook action is available only on a loopback bind with `nlm` reachable and a real checkout — the served static image satisfies none of these, so the UI hides the affordance there. GATE @@ -526,16 +586,39 @@ def compute_capabilities(*, nlm_present: bool, checkout_real: bool, loopback: bo corpus. (The committed-intent FEED does read the checkout, but a feed with nothing in it is an empty feed, not an absent capability.) - So the predicate is the plane itself, and nothing else.""" + So the predicate is the plane itself, and nothing else. + + A FLAG WHOSE AFFORDANCE IS A ROUTE THIS SERVER SERVES IS TRUE ONLY WHERE + SUCH A ROUTE ANSWERS (plan 034 T084; #1144 4.3 as T007 batch L's addendum + reads, RULED openxFactory#656 `5920216845`, item 1). `gate` and `refresh` + govern routes a HOST contributes, `POST /actions/gate/` and + `POST /actions/refresh`, so each is true only when `route_bindings`, the + bindings the assembly collected, carry a route it governs + (`answers_a_gate_verb`, `answers_the_refresh`), and otherwise its + conditions above stand as they were. Measured at openDox-code `047bb4fa`, + a standalone server answered both true while every such POST answered + `404 unknown_action`, and the gate flag followed the checkout's git + identity alone. Standalone both now read false, which also hides the + workbench's session controls (`sessionActionsLive` reads `actions.gate`), + and a composed host that contributes the routes reads as before. + `notebook`, `edit` and `session` govern core routes and keep their + conditions. `intent` governs a POST to ANOTHER plane's intent API, which + that plane answers, so its condition, the served plane, stands. The + `refresh` block below still names the plane's binding: it says which + binding a contributed refresh would use, and the flag says whether one is + offered.""" binding = refresh_binding if binding == registry_mod.BINDING_REGENERATE and not (loopback and checkout_real): binding = None local_human = bool(actor and checkout_real and loopback) + bindings = tuple(route_bindings or ()) + gate_routed = any(answers_a_gate_verb(b) for b in bindings) + refresh_routed = any(answers_the_refresh(b) for b in bindings) return { "actions": { "notebook": bool(nlm_present and checkout_real and loopback), - "gate": local_human, - "refresh": bool(binding), + "gate": local_human and gate_routed, + "refresh": bool(binding) and refresh_routed, "session": local_human, "edit": local_human, # THE HOSTED WRITE-REQUEST SEAM, and the only capability here that @@ -793,22 +876,19 @@ def _head_of(checkout_root: Path, git=None) -> str | None: class DashboardHandler(serve_workbench.WorkbenchRoutes, serve_project.ProjectRoutes, - # BUILD slice 2b: these two read `serve_gate.GateRoutes` - # and `serve_projection.ProjectionRoutes` — openXdox - # classes, and a base expression is evaluated when the - # class statement runs, so these two lines alone made - # `import opendox.serve` require the layer that PINS - # openDox. The stand-ins carry the same method names and - # forward to the same functions with the same `self` on - # first call, so every contributed binding behaves - # exactly as before. Since plan 034 T055 the core - # `/snapshot.json` arm's handlers are THIS class's own - # (`_serve_snapshot` below), and the projection stand-in - # forwards one method, `_serve_index`, the one its - # contributed `/snapshot-index.json` binding names - # (T084 hands the column to the handler facet). - consumer_reach.LateGateRoutes, - consumer_reach.LateProjectionRoutes, + # Plan 034 T084 (#1144 4.3; R1Q1 (a), openxFactory#656 + # comment 5817152735): openXdox's gate and projection + # columns, `serve_gate.GateRoutes` and + # `serve_projection.ProjectionRoutes`, stood here, as + # `consumer_reach`'s late stand-ins since BUILD slice 2b + # and as the classes themselves before it. They are a + # HOST's columns, so they are composed in at build + # time through the handler-contribution facet, beside + # the route bindings that name their methods + # (`_handle_gate_action`, `_serve_index`). A host that + # contributes a binding without its column is refused + # at wiring, before a socket (`route_extension. + # resolve_handlers`). A lone openDox carries neither. # Plan 034 T011 (#1144 task 2.2): openxFactory's # `serve_openxfactory_lanes.LaneRoutes` stood here. # It is a descendant's column in a package openDox @@ -830,6 +910,11 @@ class DashboardHandler(serve_workbench.WorkbenchRoutes, # waits, and a healthy local client is orders of magnitude faster. timeout = 30 + # The static bundle's types, pinned ahead of the platform's table (see + # `STATIC_CONTENT_TYPES`). The stdlib's own compression entries stay. + extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, + **STATIC_CONTENT_TYPES} + checkout_root: Path = Path(".") snapshot_path: Path = Path("snapshot.json") snapshot_route: str = SNAPSHOT_ROUTE @@ -1167,12 +1252,12 @@ def do_HEAD(self): # noqa: N802 # at § 2.4 PR 3, because it tests `path == self.snapshot_route`, a # per-server keyword a frozen `RouteBinding.pattern` cannot carry, while # its handlers travelled to openXdox's projection column and were reached - # through `consumer_reach.LateProjectionRoutes`. So a standalone server - # refused every `/snapshot.json`. The four methods below are that route's - # handlers, answering from the REGISTERED snapshot source: the query key, - # the active snapshot's bytes, the route itself, and FR-048's per-entry - # hosted refusal, which `_serve_source` asks too. The rules they consult - # are the registry's, through its seam. + # through a late stand-in for it (`consumer_reach`, retired at T084). So + # a standalone server refused every `/snapshot.json`. The four methods + # below are that route's handlers, answering from the REGISTERED snapshot + # source: the query key, the active snapshot's bytes, the route itself, + # and FR-048's per-entry hosted refusal, which `_serve_source` asks too. + # The rules they consult are the registry's, through its seam. def _query_key(self) -> tuple[str | None, str | None]: """The optional `?repository=&ref=` of a read route. No repository means the ACTIVE entry, which is what a query-less request asks for.""" @@ -1835,6 +1920,10 @@ def build_server( # 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() + # AND the consumer columns' defaults (plan 034 T084; #1144 4.3, + # R1Q10 (a)): the gate primitives, the doxBench scope, kickoff and + # the cross-reference register, the same way. + column_seams.register_defaults() from opendox import doxbench_turns # Imported HERE rather than at module scope, for the reason that is @@ -1994,6 +2083,9 @@ def build_server( loopback=loopback, actor=resolved_actor, refresh_binding=source.refresh_binding, + # THE ROUTES THIS ASSEMBLY COLLECTED, so a flag whose affordance is a + # contributed route is true only where one answers (T084; batch L). + route_bindings=route_bindings, ) # The human console's per-serve token (FR-019's third clause, review finding # 2). Minted only where session verbs exist at all, and published on @@ -2332,6 +2424,21 @@ def _refuse_impossible_checkout_root(value: Path | str) -> int: return 0 +#: THE SERVER ENTRY POINT'S OWN NAME AND WORDS (plan 034 T084, with +#: `cli.PROG`; adversarial review 2). `python -m opendox.serve --help` printed +#: `usage: ideation-dashboard-serve` and this module's docstring, which is +#: openxFactory's pre-carve history. It names how it is run and openDox only. +#: Loopback is the DEFAULT bind, not a promise: `--host` takes any address, and +#: a hosted install serves through this entry point (Copilot review of +#: openDox-code#77, r4173844338). +SERVE_PROG = "python -m opendox.serve" +SERVE_DESCRIPTION = ( + "Serve an openDox snapshot: the browser bundle, the snapshot and the " + "read-only source of the checkout it was generated from, on a loopback " + "address unless --host names another. `opendox generate-and-open` " + "generates a snapshot and serves it in one command.") + + def main(argv: list[str] | None = None) -> int: # The process entry point registers openDox's own default where no host has # (R1Q3 (a)), exactly where a host would register its own. Nothing is BUILT @@ -2353,8 +2460,12 @@ def main(argv: list[str] | None = None) -> int: 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) + # AND the consumer columns' defaults (plan 034 T084; #1144 4.3, + # R1Q10 (a)): the gate primitives, the doxBench scope, kickoff and + # the cross-reference register, the same way. + column_seams.register_defaults() + parser = argparse.ArgumentParser(prog=SERVE_PROG, + description=SERVE_DESCRIPTION) parser.add_argument("--web-dir", default=str(Path(__file__).resolve().parent / "web"), help="static bundle directory (default: the packaged web/)") parser.add_argument("--snapshot", required=True, diff --git a/src/opendox/serve_project.py b/src/opendox/serve_project.py index 82ccf400..ebb6f999 100644 --- a/src/opendox/serve_project.py +++ b/src/opendox/serve_project.py @@ -46,6 +46,10 @@ # proxy is bound at module level here: `tests/test_projection_seams.py` holds # the set of modules that bind one, and this module reads the seam in a body. from opendox import projection_seams +# THE GATE'S RECORDS PREFIX AND KICKOFF'S READERS, THROUGH THEIR SEAMS (plan +# 034 T084; #1144 4.3, R1Q10 (a)): `_serve_project_register` reads both per +# request, a host's registration or openDox's own default. Stdlib-only. +from opendox import column_seams from opendox.serve_wire import ( AGENT_INVOCATION_REFUSAL, JSON_CTYPE, @@ -266,13 +270,19 @@ def _serve_project_register(self, head_only: bool) -> None: duplicate guard uses, so a fresh commission is visible as pending instead of looking like it did nothing. A pending id the register already carries is dropped: the register wins the moment the - fulfilment lands, even before the descriptor's status flips.""" + fulfilment lands, even before the descriptor's status flips. + + THROUGH THE COLUMN SEAMS (plan 034 T084; #1144 batch L, RULED + `5920216845`). These were deferred `openxdox.gate_console` and + `openxdox.kickoff` imports, so where openXdox is not installed every + request ended in a dropped connection. openDox's own kickoff default + discovers no register, so a lone openDox answers today's structured + 404 "no project register" and the picker hides, as it does in any + checkout without one.""" import yaml - from openxdox.gate_console import DEFAULT_RECORDS_DIR - from openxdox.kickoff import ( - dispatched_commission_rows, dispatched_commissions, - discover_project_register) - source = discover_project_register(Path(self.checkout_root)) + gate = column_seams.gate.current() + kickoff = column_seams.kickoff.current() + source = kickoff.discover_project_register(Path(self.checkout_root)) register = None if source is not None: try: @@ -289,7 +299,7 @@ def _serve_project_register(self, head_only: bool) -> None: if isinstance(p, dict) and p.get("id") ] real_ids = {p["id"] for p in projects} - records_root = Path(self.checkout_root) / DEFAULT_RECORDS_DIR + records_root = Path(self.checkout_root) / gate.DEFAULT_RECORDS_DIR def _job(descriptor): try: @@ -300,7 +310,8 @@ def _job(descriptor): pending = [] for pid, descriptor in sorted( - dispatched_commissions(records_root, "create-project").items()): + kickoff.dispatched_commissions(records_root, + "create-project").items()): if pid in real_ids: continue job = _job(descriptor) @@ -319,7 +330,7 @@ def _job(descriptor): # neither plane is dropped (nothing to badge). pending_ids = {p["id"] for p in pending} pending_edits = [] - for pid, _descriptor, job in dispatched_commission_rows( + for pid, _descriptor, job in kickoff.dispatched_commission_rows( records_root, "edit-project"): if pid not in real_ids and pid not in pending_ids: continue diff --git a/src/opendox/serve_workbench.py b/src/opendox/serve_workbench.py index 5142c10f..7e7a0abc 100644 --- a/src/opendox/serve_workbench.py +++ b/src/opendox/serve_workbench.py @@ -51,6 +51,14 @@ # moment, openDox's own where no host has contributed one, so the workbench # routes confine by the same rule `/source` does, in a lone openDox too. from opendox import projection_seams +# THE CONSUMER COLUMNS' SEAMS (plan 034 T084; #1144 4.3, R1Q10 (a)): the gate +# primitives, the doxBench scope authority, kickoff and the register. Each +# reach below that named `openxdox.gate_console`, `gate_routes` or +# `doxbench_scope` inside a function now reads the registration current at the +# moment it runs, a host's or openDox's own default. Stdlib-only, so the +# import adds no edge. The scope VALUE types are openDox's own. +from opendox import column_seams +from opendox.doxbench_scope_types import ScopeConfinementError, ScopeKey registry_mod = projection_seams.registry.proxy from opendox.serve_wire import ( DOXBENCH_ABSTRACT_REFUSED_PROSE_BYTES, @@ -355,13 +363,15 @@ def _session_worktree_for(self, key): def _is_live_session_ref(self, key, entry) -> bool: """Whether `key.ref` is one of this tile's LIVE session branches. - ONE spelling, in `doxbench_scope` beside the other consumer of the same - question (re-verify N-6). This method had grown as a second copy and had + ONE spelling, in the registered scope authority (`column_seams.scope`: + openXdox's `doxbench_scope`, or openDox's own default) beside the other + consumer of the same question (re-verify N-6). This method had grown as a second copy and had already diverged from it — different ref comparison, different exception breadth — which is precisely how the two would have drifted apart on the next change to what counts as a live session.""" - from openxdox import doxbench_scope - return doxbench_scope.is_live_session_ref( + # THROUGH THE SCOPE SEAM (T084): the registered authority's one + # spelling of the question, a host's or openDox's default. + return column_seams.scope.current().is_live_session_ref( self.source.registry, key, repository=entry.repository or key.repository, ref=key.ref) @@ -420,10 +430,9 @@ def _thread_gate(self, worktree): it — the one gate on this surface whose allowlist carries the thread prefix (`gate_routes.first_edit_gate_factory`, task 9.5). Built through that factory rather than beside it, so the widening has one spelling.""" - from openxdox import gate_console - from openxdox import gate_routes - return gate_routes.first_edit_gate_factory( - self.actor, gate_console.DEFAULT_RECORDS_DIR)(worktree) + gate = column_seams.gate.current() # THROUGH THE GATE SEAM (T084) + return gate.first_edit_gate_factory( + self.actor, gate.DEFAULT_RECORDS_DIR)(worktree) def _mirror_turn_into_sidecar(self, key, *, document: str, turn_id: str, model_id: str, bound_buffer_key: str, @@ -557,10 +566,20 @@ def _one(name): doxbench_error_status(DOXBENCH_ERR_INVALID_TURN_REQUEST), doxbench_error_body(DOXBENCH_ERR_INVALID_TURN_REQUEST)) return - from openxdox import doxbench_scope - key = doxbench_scope.ScopeKey( - repository=fields["repository"], ref=fields["ref"], - tile_kind=fields["tile_kind"], tile_id=fields["tile_id"]) + # openDox's OWN scope type (`doxbench_scope_types`), no seam (T084). + # It refuses a `tile_kind` outside its closed vocabulary with a + # `ValueError`, which used to escape and drop the connection + # (adversarial review 2, L1). An unknown kind is a malformed query, + # answered as a missing field is. + try: + key = ScopeKey( + repository=fields["repository"], ref=fields["ref"], + tile_kind=fields["tile_kind"], tile_id=fields["tile_id"]) + except ValueError: + self._send_json( + doxbench_error_status(DOXBENCH_ERR_INVALID_TURN_REQUEST), + doxbench_error_body(DOXBENCH_ERR_INVALID_TURN_REQUEST)) + return worktree = self._session_worktree_for(key) if worktree is None: # No live session on this scope. A DISTINCT cause (adversarial @@ -962,7 +981,14 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: # on top of one would be offering to write into a file it could not # read first. disclosure = None - offered = bool(disclosure and disclosure.get("broker")) + # NOT OFFERED WHERE IT COULD NOT FINISH (plan 034 T084; RULED by + # Brett Heap, 2026-10-02, "Refuse by name, hide intake + # (Recommended)"): the enrolment ends in a recorded approval, a + # governed gate-action record only a host's gate writes, so with none + # registered the flow is not offered, even beside a hand-written + # broker block, and the reason names the seam. + records = column_seams.gate_records_writable() + offered = bool(disclosure and disclosure.get("broker")) and records from opendox import doxbench_binding envelope: dict = { "kind": "workbench-model-intake", @@ -980,7 +1006,8 @@ def _handle_workbench_model_intake_surface(self, head_only: bool) -> None: "declarations": (disclosure or {}).get("declarations", []), } if not offered: - envelope["reason"] = doxbench_intake.NO_BROKER_NOTICE + envelope["reason"] = (doxbench_intake.NO_BROKER_NOTICE if records + else column_seams.GATE_RECORDS_REFUSAL) self._serve_bytes(json.dumps(envelope).encode("utf-8"), JSON_CTYPE, head_only) @@ -1086,6 +1113,22 @@ def _handle_workbench_model_intake(self) -> None: # broker's own declared flow rather than by this check. self._send_error_or_intake(DOXBENCH_ERR_INVALID_INTAKE_REQUEST) return + if not column_seams.gate_records_writable(): + # AN ENROLMENT THIS INSTALL COULD NEVER APPROVE IS NOT STARTED + # (plan 034 T084; RULED by Brett Heap, 2026-10-02, "Refuse by name, + # hide intake (Recommended)"). Enrolling writes a PENDING + # declaration, which suppresses its binding until a recorded + # approval, and the approval is a governed gate-action record that + # openDox's default does not write. So with no host's gate + # registered the act refuses here, naming the seam, before a + # broker is spawned or a declaration is written, and the body it + # sent is drained unread. The surface already answered + # `offered: false` with the same sentence. + if length > 0: + _drain_refused_body(self.rfile, length) + self._intake_refusal(DOXBENCH_ERR_INTAKE_REFUSED, + column_seams.GATE_RECORDS_REFUSAL) + return try: broker = store.broker() except doxbench_intake.IntakeRefused as error: @@ -1228,7 +1271,27 @@ def _handle_workbench_model_approval(self) -> None: doxbench_error_body(refusal)) return from opendox import doxbench_intake - from openxdox import gate_console + # THROUGH THE GATE SEAM (plan 034 T084; #1144 4.3 as T007 batch L's + # addendum reads). This was `from openxdox import gate_console`, which + # dropped the connection of every standalone approval. The approval + # IS a governed gate-action record, which openDox's own default does + # not write (`default_columns.GATE`), so with no host's gate registered + # the act refuses here, NAMING THE SEAM (4.2), before it parses a body + # or reads a store, and no record is written. RULED by Brett Heap, + # 2026-10-02: "Refuse by name, hide intake (Recommended)". The body is + # drained unread first, as the intake act drains a refused one, so the + # refusal is not lost to a reset of a socket closed with bytes unread. + if not column_seams.gate_records_writable(): + try: + length = int(self.headers.get("Content-Length", "0")) + except (TypeError, ValueError): + length = 0 + if length > 0: + _drain_refused_body(self.rfile, length) + self._intake_refusal(DOXBENCH_ERR_APPROVAL_REFUSED, + column_seams.GATE_RECORDS_REFUSAL) + return + gate_console = column_seams.gate.current() store = self._workbench_declaration_store() if store is None: self._send_json( @@ -1272,18 +1335,21 @@ def _handle_workbench_model_approval(self) -> None: approved_by=str(self.actor), expires_at=doxbench_intake.approval_expiry(), audit_ref=binding.credential_ref) - record = gate_console.build_gate_action_record( - actor=str(self.actor), - action=doxbench_intake.GATE_ACTION_APPROVE_MODEL, - at=at, - model_declaration=binding_id, - model_approval=approved.approval_block(), - provenance=gate_console.HTTP_CONSOLE_TOKEN, - artifacts=[{"kind": "other", - "reference": doxbench_intake.declarations_path( - Path(self.checkout_root)).relative_to( - Path(self.checkout_root)).as_posix()}]) try: + # INSIDE the refusal net (T084): a gate that refuses to build the + # record is answered as a stated refusal, never a dropped + # connection. + record = gate_console.build_gate_action_record( + actor=str(self.actor), + action=doxbench_intake.GATE_ACTION_APPROVE_MODEL, + at=at, + model_declaration=binding_id, + model_approval=approved.approval_block(), + provenance=gate_console.HTTP_CONSOLE_TOKEN, + artifacts=[{"kind": "other", + "reference": doxbench_intake.declarations_path( + Path(self.checkout_root)).relative_to( + Path(self.checkout_root)).as_posix()}]) gate_console.validate_gate_action_record(record) human = gate_console.HumanGate( Path(self.checkout_root), @@ -1702,13 +1768,26 @@ def _handle_workbench_chat_turn(self) -> None: from opendox import doxbench_hash from opendox import doxbench_model - from openxdox import doxbench_scope + # The scope authority through its seam (plan 034 T084; #1144 4.3, + # batch L): a host's registration or openDox's own default, never a + # deferred `openxdox` import that drops the connection where openXdox + # is not installed. + scope_authority = column_seams.scope.current() from opendox import doxbench_turns - key = doxbench_scope.ScopeKey(repository=scope_fields["repository"], - ref=scope_fields["ref"], - tile_kind=scope_fields["tile_kind"], - tile_id=scope_fields["tile_id"]) + try: + key = ScopeKey(repository=scope_fields["repository"], + ref=scope_fields["ref"], + tile_kind=scope_fields["tile_kind"], + tile_id=scope_fields["tile_id"]) + except ValueError: + # A scope outside `ScopeKey`'s closed vocabulary (an unknown + # `tile_kind`, an empty field) is a malformed request, refused in + # the released envelope, never a dropped connection (adversarial + # review 2, L1). The released schema refuses most of these first. + self._refuse_turn(validators, DOXBENCH_ERR_INVALID_TURN_REQUEST, + turn_id, failure_kind=failure_kind) + return # ---- step 5: scope, all from SERVER truth ---- projection = None session_base = None @@ -1759,11 +1838,11 @@ def _session_text(rel, _root=source_root): # cannot add a path to this set. With no live session on the # scope's own branch family the answer is empty and this # projection is what it was before T107. - created_paths = doxbench_scope.session_created_paths_for_scope( + created_paths = scope_authority.session_created_paths_for_scope( self.source.registry, key, repository=entry.repository, ref=entry.ref, source_root=Path(entry.source_root)) - projection = doxbench_scope.resolve_scope( + projection = scope_authority.resolve_scope( snapshot, key, source_root=Path(entry.source_root), created_paths=created_paths) if projection is None: @@ -1786,7 +1865,7 @@ def _session_text(rel, _root=source_root): doxbench_turns.buffer_key_for(b) for b in turn_buffers), paths=tuple(b.path for b in turn_buffers), ) - except (doxbench_turns.TurnScopeError, doxbench_scope.ScopeConfinementError, + except (doxbench_turns.TurnScopeError, ScopeConfinementError, ValueError, OSError): scope_refused = True @@ -2639,7 +2718,6 @@ def _handle_workbench_document_abstract(self) -> None: only then a provider.""" from opendox import doxbench_hash from opendox import doxbench_model - from openxdox import doxbench_scope from opendox import doxbench_turns # ---- step 1: the plane. The SAME three-part verdict the catalog and @@ -2694,9 +2772,26 @@ def _handle_workbench_document_abstract(self) -> None: subject_path = fields["subject_path"] model_id = fields["model_id"] refresh = fields["refresh"] - key = doxbench_scope.ScopeKey(**fields["scope"]) + try: + key = ScopeKey(**fields["scope"]) + except ValueError: + # An unknown `tile_kind` is outside `ScopeKey`'s closed vocabulary: + # the request is malformed, and is answered so rather than with a + # dropped connection (adversarial review 2, L1). + self._send_json( + doxbench_error_status(DOXBENCH_ERR_INVALID_ABSTRACT_REQUEST), + doxbench_error_body(DOXBENCH_ERR_INVALID_ABSTRACT_REQUEST)) + return # ---- step 4: scope, all from SERVER truth ---- + # THE SCOPE AUTHORITY IS READ HERE, BELOW STEP 1 (plan 034 T084; #1144 + # batch L, RULED `5920216845`). It was a deferred import at the top of + # this handler, above the plane check, so in an openDox with no + # openXdox installed every request, even one step 1 would have + # refused, ended in a dropped connection. It is now the registration + # current at `column_seams.scope`, a host's or openDox's own default, + # and a plane that refuses never reads it. + scope_authority = column_seams.scope.current() projection = None source_root = None snapshot = None @@ -2708,15 +2803,15 @@ def _handle_workbench_document_abstract(self) -> None: else: source_root = Path(entry.source_root) snapshot = json.loads(entry.read_bytes()) - created_paths = doxbench_scope.session_created_paths_for_scope( + created_paths = scope_authority.session_created_paths_for_scope( self.source.registry, key, repository=entry.repository, ref=entry.ref, source_root=source_root) - projection = doxbench_scope.resolve_scope( + projection = scope_authority.resolve_scope( snapshot, key, source_root=source_root, created_paths=created_paths) if projection is None: scope_refused = True - except (doxbench_scope.ScopeConfinementError, ValueError, OSError): + except (ScopeConfinementError, ValueError, OSError): scope_refused = True if scope_refused: # The same fail-closed refusal the chat route gives, so no response diff --git a/src/opendox/view_extension.py b/src/opendox/view_extension.py index e7ed67f4..a5df59d7 100644 --- a/src/opendox/view_extension.py +++ b/src/opendox/view_extension.py @@ -37,8 +37,9 @@ openXdox CONSUMES it, and openXdox already pins openDox (`openXdox-code` `af15f712`'s `opendox` pin -> `a99eba03`). A consumer importing the product it pins is the direction the split is FOR; it is the reverse — the -product reaching its consumer — that `consumer_reach.py` exists to make late, -named and refusable. So `from opendox import view_extension` is a legal downward +product reaching its consumer — that `consumer_reach.py` existed to make late, +named and refusable, and that declared seams carry since plan 034 T084 retired +it. So `from opendox import view_extension` is a legal downward import for a contributing column, and no replica is needed. THE THREE THINGS A CONTRIBUTED VIEW BRINGS WITH IT, and why each is a field diff --git a/tests/test_capability_honesty.py b/tests/test_capability_honesty.py new file mode 100644 index 00000000..30aa6ad1 --- /dev/null +++ b/tests/test_capability_honesty.py @@ -0,0 +1,871 @@ +"""Capability honesty: a flag in `/capabilities`' `actions` map whose affordance +is a route this server serves is true only where such a route answers (plan 034 +T084; #1144 4.3 as T007 batch L's addendum reads, RULED openxFactory#656 +`5920216845`, item 1, *"Fix in T084 + #1144 note (Recommended)"*). + +MEASURED AT openDox-code `047bb4fa`: a standalone `/capabilities` answered +`actions.gate` and `actions.refresh` true, while every `POST /actions/gate/` +and `POST /actions/refresh` answered `404 unknown_action`. The gate flag +followed the checkout's git identity alone (`compute_capabilities`). Both routes +are a HOST's: a gate verb and the refresh arrive only through the route bindings +the assembly collects, so each flag is now true only when those bindings carry a +route it governs. `notebook`, `edit` and `session` govern core routes and keep +their conditions, and `intent` governs another plane's API, so its condition +stands too. + +The cases: + +1. A STANDALONE SERVER, `python -m opendox.serve` as a child with neither + sibling importable (`tests/standalone_child.py`), over a fresh repository: + `gate` and `refresh` read false, and the two routes they would govern answer + `unknown_action`, which is why. It is run WITH a git identity, so an actor + resolves and the false `gate` is the routes' doing and not a missing actor's, + and without one. The child's environment carries no `GIT_*` and no `XF_*`: + the suite itself declares a roster of several principals + (`session_fixtures.declared_gate_principals`), and a roster of several names + with no claim resolves no actor at all. +2. COMPOSED HOSTS whose other conditions hold (a loopback bind, a real checkout, + an authenticated actor), built in process with contributed bindings and the + mixins that answer them, through the handler-contribution facet: a host that + contributes both routes reads both true; a gate-only host and a refresh-only + host each read only the flag whose route it contributes. +3. ON EVERY SERVER ABOVE, for every `actions` key that reads TRUE, a route it + governs does not answer `unknown_action`. So a plane that switched every flag + off would fail the cases that require a flag true, and a plane that left one + on with no route behind it fails here. `intent` governs no route of this + server, and every key of the map must be accounted for in `GOVERNED`. +4. The two predicates, case by case. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import json +import os +import re +import threading +from pathlib import Path + +import pytest + +from route_extension import RouteBinding +from standalone_child import Child, fresh_repository, git, run_module + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +WEB = ROOT / "src" / "opendox" / "web" + +#: For each key of the `actions` map, the route it governs on THIS server, as +#: `(method, path)`, or None where it governs no route this server serves. +GOVERNED: dict[str, tuple[str, str] | None] = { + "notebook": ("POST", "/actions/notebook"), + "gate": ("POST", "/actions/gate/demote"), + "refresh": ("POST", "/actions/refresh"), + "session": ("POST", "/actions/workbench/chat-turn"), + "edit": ("POST", "/actions/edit"), + # a POST to ANOTHER plane's intent API, which that plane answers + "intent": None, +} + +_SERVE_URL = re.compile(r"^serving ideation dashboard at " + r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _request(base: tuple[str, int], method: str, path: str) -> tuple[int, dict]: + """One request; the status and the JSON body (`{}` where it is not JSON). + A dropped connection raises, which is a failure of the case.""" + connection = http.client.HTTPConnection(*base, timeout=30) + try: + body = b"{}" if method == "POST" else None + headers = {"Content-Type": "application/json"} if body else {} + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + raw = response.read() + try: + parsed = json.loads(raw) if raw else {} + except ValueError: + parsed = {} + return response.status, parsed if isinstance(parsed, dict) else {} + finally: + connection.close() + + +def _capabilities(base: tuple[str, int]) -> dict: + status, caps = _request(base, "GET", "/capabilities") + assert status == 200, status + return caps + + +def _unknown(answer: tuple[int, dict]) -> bool: + status, body = answer + return status == 404 and body.get("error") == "unknown_action" + + +def _assert_every_true_flag_answers(base: tuple[str, int], caps: dict) -> None: + """Case 3: every `actions` key is accounted for, and each that reads true + has a route that answers something other than `unknown_action`.""" + actions = caps["actions"] + assert set(actions) == set(GOVERNED), ( + f"the actions map has keys this test does not account for: " + f"{sorted(set(actions) ^ set(GOVERNED))}") + for key, value in actions.items(): + if value is not True or GOVERNED[key] is None: + continue + method, path = GOVERNED[key] + answer = _request(base, method, path) + assert not _unknown(answer), ( + f"actions.{key} reads true, but {method} {path} answers " + f"unknown_action: {answer}") + + +def _clean_environment(monkeypatch) -> None: + """No `GIT_*` and no `XF_*` reaches the child, and no user or system git + configuration: its only identity is the one its repository carries.""" + for name in list(os.environ): + if name.startswith(("GIT_", "XF_")): + monkeypatch.delenv(name) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + + +def _repository(tmp_path: Path, *, identity: bool) -> Path: + repo = fresh_repository(PLAIN, tmp_path) + if identity: + git(repo, "config", "user.name", "fixture") + git(repo, "config", "user.email", "fixture@example.invalid") + return repo + + +def _snapshot(tmp_path: Path, repo: Path) -> Path: + out = tmp_path / "out" / "snapshot.json" + child, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, child.stderr_text() + return out + + +# --------------------------------------------------------------------------- +# 1 and 3 — a standalone server +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("identity", [True, False], ids=["identity", "no-identity"]) +def test_a_standalone_server_offers_neither_gate_nor_refresh( + tmp_path, monkeypatch, identity) -> None: + """`python -m opendox.serve`, nothing registered by any host: `gate` and + `refresh` read false, the routes they would govern answer + `unknown_action`, and every flag that reads true answers.""" + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=identity) + out = _snapshot(tmp_path, repo) + child = Child(tmp_path, "opendox.serve", "--snapshot", str(out), + "--checkout-root", str(repo), "--port", "0") + try: + match = child.wait_for_line(_SERVE_URL) + base = (match.group(2), int(match.group(3))) + caps = _capabilities(base) + actions = caps["actions"] + # THE OTHER CONDITIONS, so the false `gate` is the routes' doing: with + # an identity an actor resolves and the core write flags read true. + assert caps["actor"] == ("fixture" if identity else None), caps + assert actions["session"] is identity and actions["edit"] is identity + # the plane would regenerate, and no route offers it + assert caps["refresh"]["binding"] == "regenerate", caps + assert actions["gate"] is False and actions["refresh"] is False, caps + assert _unknown(_request(base, "POST", "/actions/gate/demote")) + assert _unknown(_request(base, "POST", "/actions/refresh")) + _assert_every_true_flag_answers(base, caps) + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + + +# --------------------------------------------------------------------------- +# 2 and 3 — composed hosts +# --------------------------------------------------------------------------- + +class _GateColumn: + """A host's gate column: answers every verb under the gate prefix.""" + + def _handle_honesty_gate(self, remainder): + self._send_json(200, {"ok": True, "verb": remainder}) + + +class _RefreshColumn: + """A host's refresh: answers `POST /actions/refresh`.""" + + def _handle_honesty_refresh(self): + self._send_json(200, {"ok": True, "refreshed": True}) + + +class _Contribution: + """A route extension contributing `bindings` and the mixins answering them.""" + + def __init__(self, bindings, mixins) -> None: + self._bindings = tuple(bindings) + self.HANDLER_CONTRIBUTIONS = tuple(mixins) + + def routes(self): + return self._bindings + + +def _gate(): + from opendox import serve + return (RouteBinding("POST", serve.ACTIONS_GATE_PREFIX, True, + "_handle_honesty_gate"), _GateColumn) + + +def _refresh(): + from opendox import serve + return (RouteBinding("POST", serve.ACTIONS_REFRESH_ROUTE, False, + "_handle_honesty_refresh"), _RefreshColumn) + + +@pytest.fixture() +def composed(tmp_path): + """`build(*contributions)`: a composed host over a REAL checkout, on a + loopback bind, with an authenticated actor (`brett`, one of the suite's + declared principals), served on a thread. Yields `(base, capabilities)`.""" + from opendox import serve + + repo = _repository(tmp_path, identity=True) + out = _snapshot(tmp_path, repo) + servers = [] + + def build(*contributed): + bindings = [binding for binding, _mixin in contributed] + mixins = [mixin for _binding, mixin in contributed] + httpd = serve.build_server( + WEB, out, repo, port=0, actor="brett", + route_extensions=(_Contribution(bindings, mixins),) + if contributed else ()) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + servers.append((httpd, worker)) + base = httpd.server_address[:2] + return base, _capabilities(base) + + try: + yield build + finally: + for httpd, worker in servers: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +def test_a_composed_host_contributing_both_routes_reads_both_true(composed) -> None: + base, caps = composed(_gate(), _refresh()) + assert caps["actor"] == "brett", caps + assert caps["actions"]["gate"] is True, caps + assert caps["actions"]["refresh"] is True, caps + assert caps["actions"]["session"] is True and caps["actions"]["edit"] is True + _assert_every_true_flag_answers(base, caps) + + +def test_a_gate_only_host_reads_only_gate_true(composed) -> None: + base, caps = composed(_gate()) + assert caps["actions"]["gate"] is True, caps + assert caps["actions"]["refresh"] is False, caps + _assert_every_true_flag_answers(base, caps) + + +def test_a_refresh_only_host_reads_only_refresh_true(composed) -> None: + base, caps = composed(_refresh()) + assert caps["actions"]["refresh"] is True, caps + assert caps["actions"]["gate"] is False, caps + _assert_every_true_flag_answers(base, caps) + + +def test_the_same_host_with_nothing_contributed_reads_both_false(composed) -> None: + """The control: the same checkout, actor and bind, and no contribution.""" + base, caps = composed() + assert caps["actor"] == "brett", caps + assert caps["actions"]["gate"] is False and caps["actions"]["refresh"] is False + assert caps["actions"]["session"] is True + _assert_every_true_flag_answers(base, caps) + + +# --------------------------------------------------------------------------- +# 4 — the two predicates +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("binding,answers", [ + (RouteBinding("POST", "/actions/gate/", True, "h"), True), + (RouteBinding("POST", "/actions/", True, "h"), True), + (RouteBinding("POST", "/actions/gate/demote", False, "h"), True), + (RouteBinding("POST", "/actions/gate/lens/", True, "h"), True), + (RouteBinding("POST", "/actions/gate/", False, "h"), False), + (RouteBinding("POST", "/actions/gatehouse/", True, "h"), False), + (RouteBinding("GET", "/actions/gate/", True, "h"), False), + (RouteBinding("POST", "/actions/refresh", False, "h"), False), +]) +def test_what_answers_a_gate_verb(binding, answers) -> None: + from opendox import serve + assert serve.answers_a_gate_verb(binding) is answers + + +@pytest.mark.parametrize("binding,answers", [ + (RouteBinding("POST", "/actions/refresh", False, "h"), True), + (RouteBinding("POST", "/actions/", True, "h"), True), + (RouteBinding("GET", "/actions/refresh", False, "h"), False), + (RouteBinding("POST", "/actions/refresh/", True, "h"), False), + (RouteBinding("POST", "/actions/gate/", True, "h"), False), +]) +def test_what_answers_the_refresh(binding, answers) -> None: + from opendox import serve + assert serve.answers_the_refresh(binding) is answers + + +def test_the_verdict_follows_the_bindings_and_keeps_the_other_conditions() -> None: + """`compute_capabilities` directly: the routes are necessary, never + sufficient, so an unresolved actor still keeps `gate` off.""" + from opendox import serve + both = (_gate()[0], _refresh()[0]) + kwargs = dict(nlm_present=False, checkout_real=True, loopback=True, + refresh_binding="regenerate") + assert serve.compute_capabilities(actor="a", **kwargs)["actions"]["gate"] is False + on = serve.compute_capabilities(actor="a", route_bindings=both, **kwargs) + assert on["actions"]["gate"] is True and on["actions"]["refresh"] is True + off = serve.compute_capabilities(actor=None, route_bindings=both, **kwargs) + assert off["actions"]["gate"] is False and off["actions"]["refresh"] is True + unbound = serve.compute_capabilities( + actor="a", route_bindings=both, + **{**kwargs, "refresh_binding": None}) + assert unbound["actions"]["refresh"] is False + + +# --------------------------------------------------------------------------- +# 5 — the three sites that dropped a connection, and the chat turn +# --------------------------------------------------------------------------- + +def _call(base: tuple[str, int], method: str, path: str, *, + body: bytes | None = None, token: str | None = None + ) -> tuple[int, dict, str]: + """One request carrying `body` and, where given, the console token, from + a same-origin JSON client. The status, the JSON body (`{}` where it is not + JSON) and the raw text. A dropped connection RAISES (`RemoteDisconnected`, + a reset), which fails the case: that is the failure this section exists + to rule out.""" + from opendox import serve + connection = http.client.HTTPConnection(*base, timeout=30) + try: + headers = {"Content-Type": "application/json"} + if token: + headers[serve.CONSOLE_TOKEN_HEADER] = token + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + raw = response.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) if raw else {} + except ValueError: + parsed = {} + return (response.status, parsed if isinstance(parsed, dict) else {}, + raw) + finally: + connection.close() + + +def _json(payload: dict) -> bytes: + return json.dumps(payload).encode("utf-8") + + +#: A scope no snapshot of the fixture's declares a document under: each +#: request below is refused, and none is served a document. +_SCOPE = {"repository": "fixture", "ref": "main", "tile_kind": "staged", + "tile_id": "honesty-topic"} + +#: A well-formed abstract request: the shape step 3 accepts. +_ABSTRACT = {"scope": _SCOPE, "subject_path": "notes/one.md", + "model_id": "honesty-model"} + + +def _chat_turn() -> dict: + """A well-formed v2 chat turn: the shape the body parser accepts, an + outline and one document, bound to the document.""" + from opendox.serve_wire import DOXBENCH_CHAT_TURN_V2_KIND + + def buffer(kind, path, content): + return {"kind": kind, "repository": "fixture", "path": path, + "base_ref": "main", "base_revision": "0" * 40, + "base_hash": "0" * 64, "content_hash": "0" * 64, + "content": content, "dirty": False} + + return {"schema_version": 1, "kind": DOXBENCH_CHAT_TURN_V2_KIND, + "client_turn_id": "honesty-turn-1", "scope": _SCOPE, + "working_subject": "", "message": "What does this note claim?", + "model_id": "honesty-model", "transcript": [], + "last_assistant_turn_id": None, + "bound_buffer": "notes/one.md", + "buffers": [buffer("outline", None, "# outline"), + buffer("document", "notes/one.md", "# one")]} + + +def _structured(answer: tuple[int, dict, str]) -> bool: + """An answer a client can read: a status, and a JSON body naming its + error, or a stated 404. Anything else (an empty 500, a reset) is not.""" + status, body, raw = answer + if body.get("ok") is False and isinstance(body.get("error"), str): + return True + # THE RELEASED FAILURE ENVELOPE IS STRUCTURED TOO. Where openDox's own + # validators answer (plan 034 T085), a turn refusal arrives in + # `workbench-chat-turn-v2-failure`, which carries `error` but no `ok`. + if (str(body.get("kind", "")).endswith("-failure") + and isinstance(body.get("error"), str)): + return True + return status == 404 and bool(raw.strip()) + + +def _standalone(tmp_path, repo): + """`python -m opendox.serve` over `repo`, as section 1 runs it, with its + base address and its capabilities.""" + out = _snapshot(tmp_path, repo) + child = Child(tmp_path, "opendox.serve", "--snapshot", str(out), + "--checkout-root", str(repo), "--port", "0") + match = child.wait_for_line(_SERVE_URL) + base = (match.group(2), int(match.group(3))) + return child, base, _capabilities(base) + + +def test_the_three_crash_sites_answer_a_standalone_server( + tmp_path, monkeypatch) -> None: + """#1144 batch L (RULED `5920216845`, item 1). At `047bb4fa` each of these + three ended a standalone request with a dropped connection, on a deferred + `openxdox` import: the document abstract's above its step-1 check, model + approval's past its only check, and the project register's with no check + at all. Each now gets a structured answer, and none reaches for openXdox. + Run with an identity, so `session` reads true and the abstract and the + approval pass the checks a plane with no actor would refuse at.""" + from opendox import column_seams + from opendox.serve_wire import (DOXBENCH_ERR_APPROVAL_REFUSED, + DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE) + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=True) + child, base, caps = _standalone(tmp_path, repo) + try: + token = caps.get("console_token") + assert caps["actions"]["session"] is True and token, caps + abstract = _call(base, "POST", "/actions/workbench/document-abstract", + body=_json(_ABSTRACT), token=token) + approval = _call(base, "POST", "/actions/workbench/model-approval", + body=_json({"binding": "honesty-binding"}), + token=token) + register = _call(base, "GET", "/project-register.json") + for name, answer in (("document abstract", abstract), + ("model approval", approval), + ("project register", register)): + assert _structured(answer), f"{name}: {answer}" + # step 1 refuses once `gate` reads false (batch L) + assert abstract[1]["error"] == DOXBENCH_ERR_MODEL_CAPABILITY_UNAVAILABLE + # the seam refuses by name: no host's gate, so no record is written + assert approval[1]["error"] == DOXBENCH_ERR_APPROVAL_REFUSED, approval + assert approval[1]["reason"] == column_seams.GATE_RECORDS_REFUSAL + # openDox's own kickoff discovers no register: today's 404 + assert register[0] == 404 and "no project register" in register[2] + assert not (repo / "ideation" / "dashboard" / "gate-records").exists() + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + + +def test_the_rails_thread_read_answers_a_standalone_server( + tmp_path, monkeypatch) -> None: + """The chat rail's thread read, with the exact query the rail sends on + opening a document (`views/staging-workbench.js` `loadThread`, called from + `views/doxbench-chat.js`): a group tile and one of its members. Found by + T095's AT-R1 harness (openDox-code#75): the route reached + `from openxdox import doxbench_scope` (`serve_workbench.py:548` at + `047bb4fa`) and dropped the connection. A query-less GET stops at the 400 + check first, which is why batch L's measurement missed it. The live-session + question now goes through `column_seams.scope`, and a checkout with no open + session answers the stated no-session absence.""" + from opendox import serve_workbench + from opendox.serve_wire import DOXBENCH_ERR_THREAD_CAPABILITY_UNAVAILABLE + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=True) + child, base, caps = _standalone(tmp_path, repo) + try: + token = caps.get("console_token") + assert caps["actions"]["session"] is True and token, caps + status, body, raw = _call( + base, "GET", + "/workbench/thread?repository=fixture&ref=main&tile_kind=cluster" + "&tile_id=barrel-rain&document=notes-rain-barrel-leak.md", + token=token) + assert _structured((status, body, raw)), (status, raw) + assert body["error"] == DOXBENCH_ERR_THREAD_CAPABILITY_UNAVAILABLE, raw + assert body.get("cause") == serve_workbench.NO_LIVE_SESSION_CAUSE, body + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + + +def test_an_unknown_tile_kind_on_the_thread_read_is_refused_not_dropped( + tmp_path, monkeypatch) -> None: + """Adversarial review 2, L1: `ScopeKey` refuses a `tile_kind` outside + its closed vocabulary with a `ValueError`, which escaped the thread read + and dropped the connection. It is a malformed query, answered so.""" + from opendox.serve_wire import DOXBENCH_ERR_INVALID_TURN_REQUEST + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=True) + child, base, caps = _standalone(tmp_path, repo) + try: + status, body, raw = _call( + base, "GET", + "/workbench/thread?repository=fixture&ref=main&tile_kind=bogus" + "&tile_id=barrel-rain&document=notes-rain-barrel-leak.md", + token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_INVALID_TURN_REQUEST, (status, raw) + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + + +def test_an_unknown_tile_kind_on_the_abstract_is_refused_not_dropped( + composed_turns) -> None: + """L1 on the document abstract, past step 1 (a host contributing the gate + routes, so `gate` reads true).""" + from opendox.serve_wire import DOXBENCH_ERR_INVALID_ABSTRACT_REQUEST + base, caps = composed_turns(_gate()) + status, body, raw = _call( + base, "POST", "/actions/workbench/document-abstract", + body=_json({**_ABSTRACT, "scope": {**_SCOPE, "tile_kind": "bogus"}}), + token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_INVALID_ABSTRACT_REQUEST, (status, raw) + + +def test_an_unknown_tile_kind_on_the_chat_turn_is_refused_not_dropped( + composed_turns) -> None: + """L1 on the chat turn, where validators that admit the shape carry it to + the scope step: refused in the released envelope.""" + from opendox.serve_wire import DOXBENCH_ERR_INVALID_TURN_REQUEST + base, caps = composed_turns() + turn = _chat_turn() + turn["scope"] = {**_SCOPE, "tile_kind": "bogus"} + status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", + body=_json(turn), token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_INVALID_TURN_REQUEST, (status, raw) + assert body.get("client_turn_id") == "honesty-turn-1", body + + +#: The binding `opendox model-binding add` declares for the cases below, as +#: `tests/test_model_provider_broker.py` declares its own. Nothing is spawned +#: and nothing is contacted: no case dispatches a turn. +_BINDING = ["--id", "honesty-binding", "--label", "Honesty binding", + "--provider", "honesty-provider", + "--credential-ref", "opref-4f2a91c07be3d5a8140b6e77", + "--auth-kind", "api_key", + "--credential-approver", "fixture@example.invalid", + "--endpoint", "https://provider.invalid/turn", + "--dialect", "xfactory-prompt-v1", + "--", "honesty-broker", "--home", "/srv/{binding_id}"] + + +def test_a_chat_turn_with_a_binding_configured_is_answered_standalone( + tmp_path, monkeypatch) -> None: + """The chat-turn route over a standalone server whose checkout DECLARES a + model binding (`opendox model-binding add`), so the turn does not stop at + "no model configured" (T081) and runs on toward its scope step, which + reached for openXdox by a deferred import until this task. The answer is + structured, whichever step refuses it.""" + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=True) + added, status = run_module(tmp_path, "opendox.cli", "model-binding", "add", + "--repo-root", str(repo), *_BINDING) + assert status == 0, added.stderr_text() + child, base, caps = _standalone(tmp_path, repo) + try: + token = caps.get("console_token") + assert caps["actions"]["session"] is True and token, caps + answer = _call(base, "POST", "/actions/workbench/chat-turn", + body=_json(_chat_turn()), token=token) + assert _structured(answer), answer + # past openDox's own validators (T085) to the scope step, which + # reached openXdox by a deferred import until this task: the scope + # names no tile of this snapshot, so it is refused, stated + from opendox.serve_wire import DOXBENCH_ERR_TURN_SCOPE_REFUSED + assert answer[1]["error"] == DOXBENCH_ERR_TURN_SCOPE_REFUSED, answer + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + + +class _Conforms: + """A validator every instance conforms to.""" + + @staticmethod + def iter_errors(_instance): + return iter(()) + + +class _EveryKind(dict): + """The released validators, as a plane that can read its contract has + them: one for every kind, each of which every instance conforms to. So a + turn's own shape carries it to the scope step.""" + + def get(self, _kind, _default=None): + return _Conforms() + + +class _Port: + """A model port. Never dispatched: every case is refused before.""" + + +def test_a_chat_turn_reaching_its_scope_step_is_refused_not_dropped( + composed_turns) -> None: + """A composed host with the released validators and a model port, so a + well-formed turn reaches step 5. At `047bb4fa` step 5 opened with a + deferred `from openxdox import doxbench_scope`, and the connection dropped. + The scope authority is now the one registered at `column_seams.scope`, + openDox's own default here, and the scope is refused in the released + failure envelope.""" + from opendox.serve_wire import DOXBENCH_ERR_TURN_SCOPE_REFUSED + base, caps = composed_turns() + status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", + body=_json(_chat_turn()), + token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_TURN_SCOPE_REFUSED, (status, raw) + assert body.get("client_turn_id") == "honesty-turn-1", body + + +def test_a_document_abstract_past_step_one_is_refused_not_dropped( + composed_turns) -> None: + """A composed host that contributes the gate routes, so `gate` reads true + and an abstract request passes step 1 and reaches its scope step, which + reads the scope authority through its seam (batch L): refused, stated.""" + from opendox.serve_wire import DOXBENCH_ERR_TURN_SCOPE_REFUSED + base, caps = composed_turns(_gate()) + assert caps["actions"]["gate"] is True, caps + status, body, raw = _call(base, "POST", + "/actions/workbench/document-abstract", + body=_json(_ABSTRACT), + token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_TURN_SCOPE_REFUSED, (status, raw) + + +@pytest.fixture() +def composed_turns(tmp_path): + """`build(*contributions)`: `composed`'s host, with the released + validators and a model port declared, as a plane that can run a turn has + them. Yields `(base, capabilities)`.""" + from opendox import serve + + repo = _repository(tmp_path, identity=True) + out = _snapshot(tmp_path, repo) + servers = [] + + def build(*contributed): + bindings = [binding for binding, _mixin in contributed] + mixins = [mixin for _binding, mixin in contributed] + httpd = serve.build_server( + WEB, out, repo, port=0, actor="brett", + route_extensions=(_Contribution(bindings, mixins),) + if contributed else (), + schema_validator_factory=_EveryKind, + model_port_factory=_Port) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + servers.append((httpd, worker)) + base = httpd.server_address[:2] + return base, _capabilities(base) + + try: + yield build + finally: + for httpd, worker in servers: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +# --------------------------------------------------------------------------- +# 6 — model intake and approval: not offered where no record can be written +# --------------------------------------------------------------------------- +# +# RULED by Brett Heap, 2026-10-02, "Refuse by name, hide intake +# (Recommended)". The enrolment ends in a recorded approval, a governed +# gate-action record that only a HOST's gate writes; openDox's own default +# writes none. So with no host's gate registered the surface answers +# `offered: false` with a stated reason, even beside a hand-written broker +# block, and the intake act and the approval refuse naming the seam, writing +# nothing. A host that registers its gate is offered the flow, and its +# approval is recorded. + +#: The pending declaration a hand-written document carries, for the binding +#: `_BINDING` declares. +_PENDING = {"binding_id": "honesty-binding", "status": "pending", + "install_posture": "single-operator", "proposed_by": "brett", + "proposed_at": "2026-10-02T00:00:00Z"} + +#: The intake act's declared facts, on its query string. +_INTAKE = ("/actions/workbench/model-intake?binding=honesty-intake" + "&label=Honesty&provider=honesty-provider" + "&endpoint=https%3A%2F%2Fprovider.invalid%2Fturn" + "&dialect=xfactory-prompt-v1&kind=api_key") + + +def _declare(tmp_path: Path, repo: Path) -> Path: + """A binding declared with `opendox model-binding add`, and a declarations + document written BY HAND beside it, naming a broker and the binding's + pending declaration. Returns the document's path.""" + import yaml + from opendox import doxbench_intake + + added, status = run_module(tmp_path, "opendox.cli", "model-binding", "add", + "--repo-root", str(repo), *_BINDING) + assert status == 0, added.stderr_text() + path = doxbench_intake.declarations_path(repo) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump({ + "schema_version": doxbench_intake.SCHEMA_VERSION, + "kind": doxbench_intake.DECLARATIONS_KIND, + "broker": {"kind": doxbench_intake.BROKER_KIND, + "argv": ["honesty-broker", "intake"]}, + "declarations": [dict(_PENDING)], + }, sort_keys=False), encoding="utf-8") + return path + + +def test_a_standalone_server_does_not_offer_an_intake_it_could_not_approve( + tmp_path, monkeypatch) -> None: + from opendox import column_seams + from opendox.serve_wire import (DOXBENCH_ERR_APPROVAL_REFUSED, + DOXBENCH_ERR_INTAKE_REFUSED) + _clean_environment(monkeypatch) + repo = _repository(tmp_path, identity=True) + document = _declare(tmp_path, repo) + before = document.read_bytes() + child, base, caps = _standalone(tmp_path, repo) + try: + token = caps.get("console_token") + assert caps["actions"]["session"] is True and token, caps + status, surface, raw = _call(base, "GET", "/workbench/model-intake", + token=token) + assert status == 200, raw + # a broker block is declared, and still the flow is not offered + assert surface["offered"] is False, surface + assert surface["reason"] == column_seams.GATE_RECORDS_REFUSAL, surface + assert surface["auth_kinds"] == [] and surface["dialects"] == [] + intake = _call(base, "POST", _INTAKE, body=b"not-a-real-credential", + token=token) + assert intake[1].get("error") == DOXBENCH_ERR_INTAKE_REFUSED, intake + assert intake[1]["reason"] == column_seams.GATE_RECORDS_REFUSAL + approval = _call(base, "POST", "/actions/workbench/model-approval", + body=_json({"binding": "honesty-binding"}), + token=token) + assert approval[1].get("error") == DOXBENCH_ERR_APPROVAL_REFUSED + assert approval[1]["reason"] == column_seams.GATE_RECORDS_REFUSAL + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() + # nothing was written: the declaration is still pending, no record exists + assert document.read_bytes() == before + assert not (repo / "ideation" / "dashboard" / "gate-records").exists() + + +class _HostGate: + """A HOST's gate: openDox's own default for every name, except that it + WRITES the gate-action record the default refuses to, into a list.""" + + def __init__(self) -> None: + from opendox import column_seams, default_columns + for name in (*column_seams.GATE_CALLABLES, *column_seams.GATE_VALUES): + if name not in type(self).__dict__: + setattr(self, name, getattr(default_columns.GATE, name)) + self.__name__ = "tests.test_capability_honesty._HostGate" + self.written: list[tuple[str, dict]] = [] + + def build_gate_action_record(self, **fields): + return dict(fields) + + def validate_gate_action_record(self, record): + assert record["action"] and record["actor"], record + + def HumanGate(self, root, prefixes, *, human_actor): # noqa: N802 + return (root, tuple(prefixes), human_actor) + + def write_gate_action_record(self, human, records_dir, record): + self.written.append((records_dir, record)) + return Path(human[0]) / records_dir / "honesty.gate-action.yaml" + + +@pytest.fixture() +def host_gate(): + """A host's gate registered at `column_seams.gate` for the case, as a host + registers it at process start, and dropped afterwards. Whatever this + process registered before (a default an earlier case read) is dropped + first: the swap is deliberate.""" + from opendox import column_seams + gate = _HostGate() + column_seams.gate.unregister() + column_seams.gate.register(gate) + try: + yield gate + finally: + column_seams.gate.unregister() + + +def test_a_host_that_registers_its_gate_is_offered_intake_and_approves( + tmp_path, host_gate) -> None: + import yaml + from opendox import doxbench_intake, serve + + repo = _repository(tmp_path, identity=True) + document = _declare(tmp_path, repo) + out = _snapshot(tmp_path, repo) + httpd = serve.build_server(WEB, out, repo, port=0, actor="brett") + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + caps = _capabilities(base) + token = caps["console_token"] + status, surface, raw = _call(base, "GET", "/workbench/model-intake", + token=token) + assert status == 200, raw + assert surface["offered"] is True and "reason" not in surface, surface + assert surface["auth_kinds"], surface + status, approved, raw = _call( + base, "POST", "/actions/workbench/model-approval", + body=_json({"binding": "honesty-binding"}), token=token) + assert status == 200 and approved.get("ok") is True, raw + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + # the host's gate wrote the record, BEFORE the document moved + assert len(host_gate.written) == 1, host_gate.written + records_dir, record = host_gate.written[0] + assert record["action"] == doxbench_intake.GATE_ACTION_APPROVE_MODEL + assert record["model_declaration"] == "honesty-binding" + stored = yaml.safe_load(document.read_text(encoding="utf-8")) + assert stored["declarations"][0]["status"] == doxbench_intake.STATUS_APPROVED + assert stored["declarations"][0]["approved_by"] == "brett" + + +def test_whether_a_gate_record_can_be_written_follows_the_registration( + host_gate) -> None: + """The predicate itself: a host's registration answers true; openDox's + own default, registered by an entry point, answers false; and asking reads + nothing, so it closes no default's window.""" + from opendox import column_seams + assert column_seams.gate_records_writable() is True + column_seams.gate.unregister() + assert column_seams.gate_records_writable() is False + column_seams.register_defaults() + assert column_seams.gate_records_writable() is False + # still replaceable: asking did not read the default + column_seams.gate.register(host_gate) + assert column_seams.gate_records_writable() is True diff --git a/tests/test_chat_model_configuration.py b/tests/test_chat_model_configuration.py index fb1aca18..f9c88333 100644 --- a/tests/test_chat_model_configuration.py +++ b/tests/test_chat_model_configuration.py @@ -55,7 +55,6 @@ from __future__ import annotations import copy -import dataclasses import http.client import json import os @@ -65,7 +64,6 @@ import sys import tempfile import textwrap -import types from pathlib import Path from types import SimpleNamespace @@ -547,31 +545,26 @@ def _send_json(self, 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) + """A scope authority at openDox's scope seam (`opendox.column_seams.scope`, + plan 034 T084), standing in for openDox's own default and for any host's: + a scope that resolves, no live session, and no session-created path. + Revalidation against that projection is a no-op here, so step 5's verdict + is exactly the registry's: found, or not. The seam's state is restored + exactly afterwards, so a default another case registered and read is put + back as it was.""" + from opendox import column_seams + seam = column_seams.scope + held = (seam._registered, seam._is_default, seam._default_read) + seam.unregister() + seam.register(SimpleNamespace( + resolve_scope=lambda *args, **kwargs: SimpleNamespace(), + is_live_session_ref=lambda *args, **kwargs: False, + session_created_paths_for_scope=lambda *args, **kwargs: ())) monkeypatch.setattr(doxbench_turns, "revalidate_scope", lambda **kwargs: None) + try: + yield + finally: + seam._registered, seam._is_default, seam._default_read = held def _defective(defect: str) -> tuple[dict | None, dict]: diff --git a/tests/test_column_seams.py b/tests/test_column_seams.py new file mode 100644 index 00000000..bfbd722e --- /dev/null +++ b/tests/test_column_seams.py @@ -0,0 +1,533 @@ +"""The consumer columns' seams and openDox's defaults for them (plan 034 T084; +#1144 4.3 as T007 batch G's addendum reads; RULED R1Q10 (a), `5850003126`). + +`opendox.column_seams` declares four seams, the gate primitives, the doxBench +scope, kickoff and the cross-reference register, in `projection_seams`' +discipline, and `opendox.default_columns` is openDox's own default for each. +These cases hold: + +1. A BARE PROCESS, in which no entry point ran, meets `SeamNotRegistered` at + each seam, naming the seam and the call that registers one (4.2). A default + is a registration an entry point makes, never a fallback inside the seam. +2. EACH ENTRY POINT registers the four defaults where no host has + (`cli.build_parser()`, `cli.main()`, `serve.build_server()`, `serve.main()` + read with `ast`, and `cli.build_parser()` run in a child). A host's + registration made before a default is read replaces it, one made after is + refused, and a registration that lacks a name is refused naming it. +3. THE GATE DEFAULT carries the vocabulary-free primitives as real code, and + refuses the governed record functions and `GateConsole` by name, as + `GateRecordsNotRegistered` (a `GateRefused`). `gate_records_writable()` is + true only with a host's gate. +4. THE SCOPE DEFAULT projects each kind of tile with ITS OWN documents + editable and nothing else (RULED `5961651355`, "Tile's own documents + editable"), confines every path, and answers no session-created path; the + kickoff and register defaults answer nothing dispatched and no possibles. +5. A REGISTRATION THAT HAS THE NAMES BUT NOT THEIR SHAPE is refused at + registration: a gate whose `GateRefused` is not an exception class, and a + register whose adapter carries no callable `discover`. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +import textwrap +from pathlib import Path + +import pytest + +from opendox import column_seams as cs +from opendox import default_columns as dc +from opendox import projection_seams as ps +from opendox.boundary import BoundaryViolation, HumanGate, OutputBoundary +from opendox.doxbench_scope_types import ScopeConfinementError, ScopeKey + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" / "opendox" +SEAMS = (cs.gate, cs.scope, cs.kickoff, cs.register) + + +@pytest.fixture() +def isolated(): + """Each column seam empty, and put back whole afterwards.""" + held = [(seam._registered, seam._is_default, seam._default_read) for seam in SEAMS] + for seam in SEAMS: + seam.unregister() + try: + yield + finally: + for seam, (registered, is_default, read) in zip(SEAMS, held): + seam._registered, seam._is_default, seam._default_read = ( + registered, is_default, read) + + +def _child(program: str) -> subprocess.CompletedProcess: + return subprocess.run([sys.executable, "-c", textwrap.dedent(program)], + capture_output=True, text=True, cwd=str(ROOT)) + + +# --------------------------------------------------------------------------- +# 1 — a bare process refuses at each seam, naming it +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("name", ["gate", "scope", "kickoff", "register"]) +def test_a_bare_process_refuses_naming_the_seam_and_the_call(name) -> None: + done = _child(f""" + from opendox import column_seams as cs + try: + cs.{name}.current() + except cs.SeamNotRegistered as e: + print("REFUSED", e) + else: + print("ANSWERED") + """) + assert done.returncode == 0, done.stderr + out = done.stdout + assert out.startswith("REFUSED"), out + assert f"opendox.column_seams.{name}" in out, out + assert f"opendox.column_seams.{name}.register(" in out, out + assert "opendox.default_columns" in out, out + + +# --------------------------------------------------------------------------- +# 2 — the entry points register the defaults; hosts replace or are refused +# --------------------------------------------------------------------------- + +def _calls_register_defaults(function: ast.FunctionDef) -> bool: + return any(isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "register_defaults" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "column_seams" + for node in ast.walk(function)) + + +@pytest.mark.parametrize("module,function", [ + ("cli.py", "build_parser"), ("cli.py", "main"), + ("serve.py", "build_server"), ("serve.py", "main")]) +def test_each_entry_point_registers_the_column_defaults(module, function) -> None: + tree = ast.parse((SRC / module).read_text(encoding="utf-8")) + [found] = [node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == function] + assert _calls_register_defaults(found), ( + f"{module}:{function}() does not call column_seams.register_defaults()") + + +def test_building_the_parser_registers_openDoxs_own_defaults() -> None: + done = _child(""" + from opendox import cli, column_seams as cs, default_columns as dc + cli.build_parser() + print(cs.gate.current() is dc.GATE, cs.scope.current() is dc.SCOPE, + cs.kickoff.current() is dc.KICKOFF, + cs.register.current() is dc.REGISTER, + cs.gate_records_writable()) + """) + assert done.returncode == 0, done.stderr + assert done.stdout.split() == ["True", "True", "True", "True", "False"] + + +class _HostGate: + """A host's gate: openDox's default's names, with a record writer.""" + + def __init__(self) -> None: + for name in (*cs.GATE_CALLABLES, *cs.GATE_VALUES): + setattr(self, name, getattr(dc.GATE, name)) + self.written = [] + self.write_gate_action_record = lambda gate, records_dir, record: ( + self.written.append(record) or Path(records_dir) / "record.yaml") + + +def test_a_host_gate_before_a_read_replaces_the_default(isolated) -> None: + cs.register_defaults() + host = _HostGate() + assert cs.gate.register(host) is host + assert cs.gate.current() is host + assert cs.gate_records_writable() is True + + +def test_a_host_gate_after_the_default_was_read_is_refused(isolated) -> None: + cs.register_defaults() + assert cs.gate.current() is dc.GATE + with pytest.raises(cs.SeamAlreadyRegistered, + match="opendox.column_seams.gate.unregister"): + cs.gate.register(_HostGate()) + assert cs.gate_records_writable() is False + + +def test_a_registration_lacking_a_name_is_refused_naming_it(isolated) -> None: + class _Partial: + resolve_scope = staticmethod(dc.resolve_scope) + + with pytest.raises(TypeError, match="is_live_session_ref") as caught: + cs.scope.register(_Partial()) + assert "column_seams.scope.register()" in str(caught.value) + + +def test_a_gate_verb_on_the_default_gate_is_refused_not_a_traceback( + isolated, tmp_path, capsys) -> None: + """`cli._commission_cli`, the shared half a contributed gate verb runs, + over openDox's own gate default: the governed `GateConsole` refuses at + construction, inside the verb's refusal boundary, so the verb answers + ` refused: ...` and exit status 1 (Copilot review of + openDox-code#77, r4170914922).""" + import argparse + from opendox import cli + cs.register_defaults() + args = argparse.Namespace(repo_root=str(tmp_path), records_dir="records/", + actor="brett", outline=None, workflow=None, + note=None) + assert cli._commission_cli("propose", args, "some-topic") == 1 + err = capsys.readouterr().err + assert err.startswith("propose refused: "), err + assert "opendox.column_seams.gate.register(" in err, err + + +def test_a_gate_whose_refusal_is_not_an_exception_class_is_refused( + isolated) -> None: + """`except gate.GateRefused` needs a class: a function would pass the name + probe and raise `TypeError` at the first refusal a verb catches.""" + host = _HostGate() + host.GateRefused = lambda *args: None + with pytest.raises(TypeError, match="GateRefused must be an exception class"): + cs.gate.register(host) + assert cs.gate.is_registered() is False + + +def test_a_register_whose_adapter_cannot_discover_is_refused(isolated) -> None: + class _NoDiscover: + CrossReferenceIndexAdapter = staticmethod(lambda *args: None) + + with pytest.raises(TypeError, match="callable `discover`") as caught: + cs.register.register(_NoDiscover()) + assert "column_seams.register.register()" in str(caught.value) + assert cs.register.is_registered() is False + + +def test_gate_records_are_writable_only_with_a_hosts_gate(isolated) -> None: + assert cs.gate_records_writable() is False # nothing at all + cs.register_defaults() + assert cs.gate_records_writable() is False # openDox's default + cs.gate.unregister() + cs.gate.register(_HostGate()) + assert cs.gate_records_writable() is True + + +def test_the_defaults_carry_every_name_their_seams_require() -> None: + for default, names in ((dc.GATE, (*cs.GATE_CALLABLES, *cs.GATE_VALUES)), + (dc.SCOPE, cs.SCOPE_CALLABLES), + (dc.KICKOFF, cs.KICKOFF_CALLABLES), + (dc.REGISTER, cs.REGISTER_CALLABLES)): + missing = [name for name in names if not hasattr(default, name)] + assert missing == [], (default, missing) + + +# --------------------------------------------------------------------------- +# 3 — the gate default: real primitives, governed functions refused by name +# --------------------------------------------------------------------------- + +def test_the_human_gate_guard_passes_a_human_and_reports_anything_else(tmp_path) -> None: + human = HumanGate(tmp_path, ["records/"], human_actor="fixture") + assert dc.GATE.require_human_gate(human) is human + machinery = OutputBoundary(tmp_path, ["records/"], actor="agent") + with pytest.raises(BoundaryViolation): + dc.GATE.require_human_gate(machinery) + assert machinery.refusals, "the refusal was not reported on its own ledger" + + +def test_the_stamp_the_clock_the_prefix_and_the_ref_target() -> None: + assert dc.GATE._stamp("2026-10-02T20:00:00Z") == "20261002T200000Z" + assert dc.GATE._prefix("records") == "records/" + assert dc.GATE._prefix("records/") == "records/" + assert dc.GATE.ref_target_id("cluster/cl-a") == "cluster-cl-a" + assert len(dc.GATE._utcnow()) == len("2026-10-02T20:00:00Z") + + +def test_a_provenance_is_a_validated_type() -> None: + assert dc.GATE.HTTP_CONSOLE_TOKEN.as_record() == { + "surface": "http", "console_presence": "console-token"} + cli_tty = dc.GATE.Provenance(dc.GATE.SURFACE_CLI, dc.GATE.PRESENCE_TTY) + assert cli_tty.as_record() == {"surface": "cli", "console_presence": "tty"} + with pytest.raises(dc.GATE.GateRefused): + dc.GATE.Provenance("smoke-signal", dc.GATE.PRESENCE_TTY) + + +def test_the_first_edit_gate_declares_the_records_and_thread_trees(tmp_path) -> None: + from opendox.doxbench_threads import THREAD_PREFIX + + gate = dc.GATE.first_edit_gate_factory("fixture", "records/")(tmp_path) + assert isinstance(gate, HumanGate) + assert set(gate.output.allowlist) >= {"records/", THREAD_PREFIX} + assert gate.output.session_root == tmp_path.resolve() + + +@pytest.mark.parametrize("name", [ + "build_gate_action_record", "validate_gate_action_record", + "write_gate_action_record", "validate_demotion_execution_receipt", + "GateConsole"]) +def test_the_governed_functions_refuse_naming_the_seam(name) -> None: + with pytest.raises(dc.GateRecordsNotRegistered) as caught: + getattr(dc.GATE, name)(object(), "records/", {}) + assert isinstance(caught.value, dc.GATE.GateRefused) + text = str(caught.value) + assert "opendox.column_seams.gate" in text, text + assert "opendox.column_seams.gate.register(" in text, text + assert "model-binding add" in text, text + + +# --------------------------------------------------------------------------- +# 4 — the scope, kickoff and register defaults +# --------------------------------------------------------------------------- + +def _snapshot() -> dict: + return { + "generation": {"source_revision": "abc123"}, + "documents": [{"id": p, "path": p} for p in + ("a.md", "b.md", "c.md", "sel.md", "gone.md")], + "clusters": [ + {"id": "g1", "name": "Group one", "topics": ["barrel"], + "document_edges": [{"document": "a.md"}, {"document": "b.md"}]}, + {"id": "g2", "name": "Group two", "topics": ["shed"], + "document_edges": [{"document": "c.md"}, {"document": "gone.md"}]}], + "possibles": [{"id": "p1", "title": "A candidate", + "claiming_clusters": ["g1", "g2"]}], + "staged_topics": [{"staging_id": "s1", "files": ["sel.md"]}], + } + + +@pytest.fixture() +def corpus(tmp_path): + for name in ("a.md", "b.md", "c.md", "sel.md"): + (tmp_path / name).write_text("# x\n", encoding="utf-8") + ps.register_defaults() # the registry seam's containment rule + return tmp_path + + +def _key(kind: str, tile: str) -> ScopeKey: + return ScopeKey(repository="fixture", ref="main", tile_kind=kind, tile_id=tile) + + +@pytest.mark.parametrize("kind,tile,context,title", [ + ("cluster", "g1", ("a.md", "b.md"), "Group one"), + ("staged", "s1", ("sel.md",), "s1"), + ("possible", "p1", ("a.md", "b.md", "c.md"), "A candidate"), +]) +def test_each_tile_projects_its_own_documents_editable( + corpus, kind, tile, context, title) -> None: + """RULED `5961651355` ("Tile's own documents editable"): every section a + tile projects is its own, so its resolved documents are both readable and + editable, and the candidates are what the turn guard would accept.""" + projection = dc.resolve_scope(_snapshot(), _key(kind, tile), source_root=corpus) + assert projection.title == title + assert projection.context_paths == context + assert projection.editable_paths == context + assert projection.active_document_candidates == context + assert projection.outline_path is None + assert projection.source_revision == "abc123" + assert all(section.owned for section in projection.sections) + + +def test_nothing_outside_the_tile_is_editable(corpus) -> None: + """The group's own two, never the corpus's other documents.""" + projection = dc.resolve_scope(_snapshot(), _key("cluster", "g1"), source_root=corpus) + assert set(projection.editable_paths) == {"a.md", "b.md"} + for other in ("c.md", "sel.md"): + assert other not in projection.editable_paths + assert other not in projection.context_paths + + +def test_a_group_edge_names_its_document_by_id(corpus) -> None: + """A group's `document_edges[].document` is a document ID, and a valid + snapshot's ids need not equal its paths (Copilot review of + openDox-code#77, r4171136778): `notes/a` is the id of `a.md`. The tile + projects the document by its PATH, resolved and editable, keeping the + id; a candidate's claiming group the same.""" + snapshot = _snapshot() + snapshot["documents"] = [ + {"id": "notes/" + p.removesuffix(".md"), "path": p} + for p in ("a.md", "b.md", "c.md", "sel.md", "gone.md")] + for group in snapshot["clusters"]: + for edge in group["document_edges"]: + edge["document"] = "notes/" + edge["document"].removesuffix(".md") + group = dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + assert group.context_paths == ("a.md", "b.md") + assert group.editable_paths == ("a.md", "b.md") + assert [(row.id, row.path, row.resolved) for row in group.sections[0].documents] \ + == [("notes/a", "a.md", True), ("notes/b", "b.md", True)] + candidate = dc.resolve_scope(snapshot, _key("possible", "p1"), source_root=corpus) + assert candidate.editable_paths == ("a.md", "b.md", "c.md") + # a selection's files are PATHS, and resolve as before + staged = dc.resolve_scope(snapshot, _key("staged", "s1"), source_root=corpus) + assert staged.editable_paths == ("sel.md",) + + +def test_a_selection_file_is_a_path_where_it_spells_another_documents_id( + corpus) -> None: + """A selection's `files` are PATHS and a group's edges are IDS, and the + contract does not forbid one document's path from spelling another + document's id (Copilot review of openDox-code#77, r4173844321). Here + `sel.md` is the PATH of one document and the ID of another, `a.md`. The + selection's file is the document at `sel.md`, and a group edge naming + `sel.md` is the document whose id it is.""" + snapshot = _snapshot() + snapshot["documents"] = [ + {"id": "sel.md", "path": "a.md"}, # an id spelled as a path + {"id": "notes/sel", "path": "sel.md"}, + *({"id": p, "path": p} for p in ("b.md", "c.md", "gone.md"))] + snapshot["clusters"][0]["document_edges"] = [ + {"document": "sel.md"}, {"document": "b.md"}] + staged = dc.resolve_scope(snapshot, _key("staged", "s1"), source_root=corpus) + assert [(row.id, row.path, row.resolved) for row in staged.sections[0].documents] \ + == [("sel.md", "sel.md", True)] + assert staged.editable_paths == ("sel.md",) + group = dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + assert group.editable_paths == ("a.md", "b.md") + + +def test_a_listed_document_missing_from_the_tree_is_not_resolved(corpus) -> None: + projection = dc.resolve_scope(_snapshot(), _key("cluster", "g2"), source_root=corpus) + rows = {row.path: row.resolved for row in projection.sections[0].documents} + assert rows == {"c.md": True, "gone.md": False} + assert projection.context_paths == ("c.md",) + assert projection.editable_paths == ("c.md",), "an unresolved row is not editable" + + +def test_a_created_path_is_readable_and_never_editable(corpus) -> None: + projection = dc.resolve_scope(_snapshot(), _key("cluster", "g1"), + source_root=corpus, created_paths=["new.md"]) + assert projection.context_paths == ("a.md", "b.md", "new.md") + assert projection.editable_paths == ("a.md", "b.md") + + +@pytest.mark.parametrize("settings", [ + "ideation/dashboard/model-provider-bindings.yaml", + "ideation/dashboard/model-declarations.yaml", +]) +def test_opendoxs_own_settings_documents_are_never_editable( + corpus, settings) -> None: + """Adversarial review 2, M1: a group whose members include one of + openDox's own settings documents does not make it editable or owned. It + stays readable, in a section nothing owns.""" + from opendox import doxbench_binding, doxbench_intake + assert dc.SETTINGS_DOCUMENTS == {doxbench_binding.DEFAULT_BINDINGS_RELPATH, + doxbench_intake.DEFAULT_DECLARATIONS_RELPATH} + target = corpus / settings + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text("schema_version: 1\n", encoding="utf-8") + snapshot = _snapshot() + snapshot["documents"].append({"id": settings, "path": settings}) + snapshot["clusters"][0]["document_edges"].append({"document": settings}) + projection = dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + assert projection.editable_paths == ("a.md", "b.md") + assert settings not in projection.active_document_candidates + assert settings in projection.context_paths, "readable, never editable" + owned = {row.path for section in projection.sections if section.owned + for row in section.documents} + assert settings not in owned + (holder,) = [section for section in projection.sections + if settings in {row.path for row in section.documents}] + assert holder.key == "settings" and holder.owned is False + + +@pytest.mark.skipif(sys.platform == "win32", reason="creates symlinks") +@pytest.mark.parametrize("alias, link, to", [ + # a file alias, as review r4173903232 names it + ("alias.md", "alias.md", "ideation/dashboard/model-provider-bindings.yaml"), + # a directory link on the way to one + ("cfg/model-declarations.yaml", "cfg", "ideation/dashboard"), +]) +def test_an_in_root_alias_of_a_settings_document_is_never_editable( + corpus, alias, link, to) -> None: + """Copilot review of openDox-code#77, r4173903232: `resolve_within` + follows a symlink to the canonical file, but the row keeps its alias, so + an alias of a settings document compared unequal to every settings path + and stayed owned and editable. The file it REACHES is compared now: the + alias is readable, in the section nothing owns, and never editable.""" + for name in dc.SETTINGS_DOCUMENTS: + (corpus / name).parent.mkdir(parents=True, exist_ok=True) + (corpus / name).write_text("schema_version: 1\n", encoding="utf-8") + (corpus / link).parent.mkdir(parents=True, exist_ok=True) + (corpus / link).symlink_to(corpus / to) + snapshot = _snapshot() + snapshot["documents"].append({"id": alias, "path": alias}) + snapshot["clusters"][0]["document_edges"].append({"document": alias}) + projection = dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + assert projection.editable_paths == ("a.md", "b.md") + assert alias not in projection.active_document_candidates + owned = {row.path for section in projection.sections if section.owned + for row in section.documents} + assert alias not in owned + (holder,) = [section for section in projection.sections + if alias in {row.path for row in section.documents}] + assert holder.key == "settings" and holder.owned is False + assert alias in projection.context_paths, "readable, never editable" + + +def test_the_editable_set_refuses_a_settings_document_in_any_section() -> None: + """`editable_paths` itself, over an owned section that carries one.""" + from opendox.doxbench_scope_types import ScopeDocument, ScopeSection + section = ScopeSection( + key="k", label="l", note="n", inherited=False, owned=True, + documents=tuple(ScopeDocument(id=p, path=p, resolved=True) for p in + ("a.md", *sorted(dc.SETTINGS_DOCUMENTS)))) + assert dc.editable_paths([section]) == ("a.md",) + + +def test_the_editable_set_is_the_owned_sections_resolved_rows() -> None: + """`editable_paths` itself: owned sections only, resolved rows only, once + each and in order.""" + from opendox.doxbench_scope_types import ScopeDocument, ScopeSection + + def section(owned, *rows): + return ScopeSection( + key="k", label="l", note="n", inherited=False, owned=owned, + documents=tuple(ScopeDocument(id=p, path=p, resolved=r) + for p, r in rows)) + + assert dc.editable_paths([section(False, ("a.md", True))]) == () + assert dc.editable_paths([ + section(True, ("a.md", True), ("gone.md", False)), + section(False, ("b.md", True)), + section(True, ("c.md", True), ("a.md", True)), + ]) == ("a.md", "c.md") + + +def test_an_unknown_tile_is_none(corpus) -> None: + assert dc.resolve_scope(_snapshot(), _key("cluster", "nope"), source_root=corpus) is None + + +@pytest.mark.parametrize("path", ["../escape.md", "/etc/passwd", "a/../b.md", "a\\b.md", ""]) +def test_a_path_the_scope_cannot_name_safely_is_refused(corpus, path) -> None: + snapshot = _snapshot() + snapshot["clusters"][0]["document_edges"].append({"document": path}) + with pytest.raises(ScopeConfinementError): + dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + + +def test_a_symlink_out_of_the_root_is_not_resolved(corpus, tmp_path_factory) -> None: + outside = tmp_path_factory.mktemp("outside") / "secret.md" + outside.write_text("secret\n", encoding="utf-8") + (corpus / "link.md").symlink_to(outside) + snapshot = _snapshot() + snapshot["documents"].append({"id": "link.md", "path": "link.md"}) + snapshot["clusters"][0]["document_edges"].append({"document": "link.md"}) + projection = dc.resolve_scope(snapshot, _key("cluster", "g1"), source_root=corpus) + assert "link.md" not in projection.context_paths + + +def test_no_session_created_path_and_no_live_session_without_a_registry() -> None: + key = _key("cluster", "g1") + assert dc.session_created_paths_for_scope( + None, key, repository="fixture", ref="cluster/g1", source_root=".") == () + assert dc.is_live_session_ref(None, key, repository="fixture", + ref="cluster/g1") is False + + +def test_kickoff_and_register_answer_nothing(tmp_path) -> None: + assert dc.KICKOFF.dispatched_commissions(tmp_path, "create-project") == {} + assert dc.KICKOFF.dispatched_commission_rows(tmp_path, "edit-project") == [] + assert dc.KICKOFF.dispatched_propose_topics(tmp_path) == set() + assert dc.KICKOFF.discover_project_register(tmp_path) is None + assert tuple(dc.REGISTER.CrossReferenceIndexAdapter.discover(tmp_path).possibles()) == () diff --git a/tests/test_consumer_reach.py b/tests/test_consumer_reach.py index 0248a22f..0d0ab652 100644 --- a/tests/test_consumer_reach.py +++ b/tests/test_consumer_reach.py @@ -59,9 +59,9 @@ def _candidates(..., records_dir: str = gate_console.DEFAULT_RECORDS_DIR): import pytest -#: The package openDox must not require. Spelled once here rather than -#: imported from `opendox.consumer_reach`, because this file must hold even if -#: that module is the thing that broke. +#: The package openDox must not require. Spelled once here, and never imported +#: from the package under test, because this file must hold even if that +#: module is the thing that broke. CONSUMER_PACKAGE = "openxdox" ROOT = Path(__file__).resolve().parent.parent @@ -103,7 +103,8 @@ def _import_in_subprocess(module: str, *, consumer_blocked: bool, NEUTRAL_MODULES = ( "opendox.workbench", "opendox.serve_workbench", - "opendox.consumer_reach", + # `opendox.consumer_reach` stood here, the late seam itself, until plan + # 034 T084 deleted it: no reach is left for it to stand in for. # BUILD slice 2b. NINE default-argument reads of # `gate_console.DEFAULT_RECORDS_DIR` (:1740, :1859, :1878, :4179, :4217, # :4254, :4287, :4504, :4991) — evaluated where the `def` sits, so no @@ -153,6 +154,14 @@ def _import_in_subprocess(module: str, *, consumer_blocked: bool, "opendox.default_registry", "opendox.default_projection", "opendox.rfc3339", + # Plan 034 T084 (#1144 4.3; R1Q10 (a)). The consumer columns' seams and + # openDox's own defaults behind them, which retired the last stand-in, the + # gate column's. As with T055's four: a seam whose whole job is to let a + # HOST hand openDox its gate, its scope authority, its commission reader + # and its register is where a reach into `openxdox` would look reasonable, + # and neither module makes one. + "opendox.column_seams", + "opendox.default_columns", ) #: Modules that STILL require the consumer at import time, with the reason. They @@ -513,510 +522,33 @@ def test_the_runtime_extras_modules_are_the_runtimes_own() -> None: # 2 — the seam itself, exercised at run time # -------------------------------------------------------------------------- -def test_importing_the_seam_resolves_nothing() -> None: - """Constructing a stand-in performs no import. - - The whole value of the module is that `import opendox.consumer_reach` is - free; a stand-in that resolved eagerly would be an import statement wearing - a different hat. - """ - done = _import_in_subprocess("opendox.consumer_reach", consumer_blocked=True) - assert done.returncode == 0, done.stderr - - -def test_first_attribute_access_refuses_naming_the_layering() -> None: - from opendox import consumer_reach - - absent = consumer_reach.module("no_such_column", reason="a test's own") - with pytest.raises(consumer_reach.ConsumerReachUnavailable) as caught: - absent.anything - message = str(caught.value) - assert "openxdox.no_such_column" in message - assert "RULED OQ-2" in message, ( - "the refusal must name the LAYERING — which way the pin runs — rather " - "than reading as a missing-module accident") - assert isinstance(caught.value.__cause__, ModuleNotFoundError), ( - "the original ModuleNotFoundError is chained, so a reader still gets " - "the import machinery's own account beneath the layering one") - - -def test_a_consumer_module_that_exists_and_raises_is_re_raised_untouched() -> None: - """`except ImportError` wholesale would blame the layering for a bug. - - A consumer module that IS present and fails while executing — because one - of ITS dependencies is missing — must surface as that failure, not as - `ConsumerReachUnavailable`, or the reader is sent to the wrong repository. - """ - from opendox import consumer_reach - - reach = consumer_reach.module("cheerfully_broken", reason="a test's own") - broken = ModuleNotFoundError("No module named 'jsonschema'", name="jsonschema") - - def _raise(_dotted: str): - raise broken - - original = consumer_reach.importlib.import_module - consumer_reach.importlib.import_module = _raise - try: - with pytest.raises(ModuleNotFoundError) as caught: - reach.anything - finally: - consumer_reach.importlib.import_module = original - assert caught.value is broken - assert not isinstance(caught.value, consumer_reach.ConsumerReachUnavailable) - - -def test_resolution_forwards_to_the_real_module_and_caches(tmp_path: Path) -> None: - from opendox import consumer_reach - - module_object = type(sys)("openxdox.pretend") - module_object.ANSWER = 42 - module_object.verb = lambda x: x * 2 - reach = consumer_reach.module("pretend", reason="a test's own") - sys.modules["openxdox.pretend"] = module_object - # The fake PARENT is removed again below only if this test created it. - # Leaving an empty `openxdox` package in `sys.modules` would make every - # later test in the process see an importable-but-empty consumer instead - # of normal import behaviour — including this file's own seam tests. - parent_was_created = "openxdox" not in sys.modules - if parent_was_created: - sys.modules["openxdox"] = type(sys)("openxdox") - try: - assert reach.ANSWER == 42 - assert reach.resolve() is module_object - assert reach.resolve() is module_object, "the resolved module is cached" - assert reach.verb(3) == 6, ( - "a resolved module's function is the module's own, called through") - finally: - sys.modules.pop("openxdox.pretend", None) - if parent_was_created: - sys.modules.pop("openxdox", None) - - -def test_a_dunder_lookup_does_not_resolve_the_consumer() -> None: - """`copy`, `pickle`, `inspect` and pytest all probe for dunders. - - Resolving openXdox because something asked for `__wrapped__` would fire the - reach at a moment no verb chose — and, with the consumer absent, would turn - an innocuous introspection into `ConsumerReachUnavailable`. - """ - from opendox import consumer_reach - - reach = consumer_reach.module("never_resolved", reason="a test's own") - with pytest.raises(AttributeError): - reach.__wrapped__ - assert "unresolved" in repr(reach) - - -# THE VALUE AND CALLABLE STAND-INS ARE RETIRED (plan 034 T055). `constant` -# stood for ONE site, `cli.py`'s `SCANNED_ROOTS`, and `function` for six -# callables (`find_validator`, `corpus_root_refusal`, `generate_snapshot`, -# `is_rfc3339_datetime`, `hosted_ref_refused` among them); every one of them is -# read from a declared seam now, or is openDox's own, so the four cases that -# held `_LateConsumerValue` to the tuple it stood for went with it. -# `tests/test_projection_seams.py` holds the seams that replaced them. - - -@pytest.fixture() -def pretend_column(): - """A stand-in for a consumer module carrying one handler-method COLUMN. - - The methods are written the way the real columns are — plain functions on a - class, called with the live request handler as `self` — so what the test - exercises is the forwarding contract and not a mock's idea of it. - """ - module_object = type(sys)("openxdox.pretend_routes") - - class PretendRoutes: - def _serve_thing(self, path, *, keyed=False): - # Reads state off `self`, which is the whole point: the forwarder - # must pass the HANDLER, not the column, as `self`. - return f"{self.marker}:{path}:{keyed}" - - def _refuse_thing(self): - return f"{self.marker}:refused" - - module_object.PretendRoutes = PretendRoutes - sys.modules["openxdox.pretend_routes"] = module_object - parent_was_created = "openxdox" not in sys.modules - if parent_was_created: - sys.modules["openxdox"] = type(sys)("openxdox") - try: - yield module_object - finally: - sys.modules.pop("openxdox.pretend_routes", None) - if parent_was_created: - sys.modules.pop("openxdox", None) - - -def _late_handler(consumer_reach, methods=("_serve_thing", "_refuse_thing")): - """A `DashboardHandler`-shaped class over a late column, as `serve.py` builds one.""" - column = consumer_reach.route_column( - consumer_reach.module("pretend_routes", reason="a test's own"), - "PretendRoutes", methods) - - class Handler(column): - marker = "handler" - - return column, Handler - - -def test_a_late_column_is_built_without_resolving_the_consumer() -> None: - """The class statement runs at IMPORT time — this is the whole reason the - - column member exists. A base that resolved while being built would defer - nothing: `DashboardHandler`'s bases are evaluated when `serve.py` loads. - """ - from opendox import consumer_reach - - column, Handler = _late_handler(consumer_reach) - assert Handler.marker == "handler" - assert column.LATE_COLUMN == ("openxdox.pretend_routes", "PretendRoutes", - ("_serve_thing", "_refuse_thing")), ( - "the triple openXdox-code's drift guard reads to hold the two surfaces " - "together must name the module, the class and the method list") - assert column._serve_thing.__name__ == "_serve_thing", ( - "the forwarder keeps the method's NAME, because a contributed binding " - "is resolved against the bound class BY NAME at wiring time") - - -def test_the_forwarders_call_the_consumer_with_the_handler_as_self( - pretend_column) -> None: - """The contract: same function object, same `self`, same arguments. - - `route_extension.resolve_handlers` refuses a route that cannot be served - before a socket is opened, and it resolves the handler by name against the - BOUND CLASS — so a wrong method list or a forwarding signature that dropped - an argument would leave imports green and break requests, which is exactly - what this test is here to stop. - """ - from opendox import consumer_reach - - _column, Handler = _late_handler(consumer_reach) - handler = Handler() - - assert handler._serve_thing("/a/b") == "handler:/a/b:False", ( - "positional arguments forward, and `self` is the HANDLER — the column's " - "method reads `self.marker`, which only the handler has") - assert handler._serve_thing("/a/b", keyed=True) == "handler:/a/b:True", ( - "keyword arguments forward too") - assert handler._refuse_thing() == "handler:refused" - assert handler._serve_thing.__func__ is not \ - pretend_column.PretendRoutes._serve_thing, ( - "the BOUND method is the forwarder, not the column's function") - - -def test_a_late_column_answers_only_the_names_it_was_given(pretend_column) -> None: - """No `__getattr__`, deliberately, and the absence is asserted. - - A handler instance is probed for absent attributes constantly — `http.server` - asks `hasattr(self, "do_PUT")`, and `copy`, `pickle` and pytest all probe — - so a base that answered those by importing openXdox would fire the reach at - a moment no verb chose, and would raise `ConsumerReachUnavailable` where the - caller was testing for `AttributeError`. - """ - from opendox import consumer_reach - - _column, Handler = _late_handler(consumer_reach, methods=("_serve_thing",)) - handler = Handler() - - assert handler._serve_thing("/x") == "handler:/x:False" - with pytest.raises(AttributeError): - handler.do_PUT - with pytest.raises(AttributeError): - # Present on the consumer's column, absent from the NAMED list: a name - # left out of the list is left out of the class, not silently proxied. - handler._refuse_thing - assert not hasattr(handler, "_refuse_thing") - - -def test_a_late_column_with_no_consumer_refuses_at_the_call() -> None: - """Construction succeeds, the call refuses, and the refusal names the layering.""" - from opendox import consumer_reach - - column = consumer_reach.route_column( - consumer_reach.module("no_such_column", reason="a test's own"), - "NoRoutes", ("_serve_thing",)) - - class Handler(column): - marker = "handler" - - with pytest.raises(consumer_reach.ConsumerReachUnavailable) as caught: - Handler()._serve_thing("/x") - message = str(caught.value) - assert "openxdox.no_such_column" in message - assert "RULED OQ-2" in message - assert isinstance(caught.value.__cause__, ModuleNotFoundError) - - -def test_a_column_standing_in_for_nothing_is_refused() -> None: - """An empty method list would build a base that inherits nothing and hides it.""" - from opendox import consumer_reach - - with pytest.raises(ValueError, match="name the methods"): - consumer_reach.route_column( - consumer_reach.module("pretend_routes", reason="a test's own"), - "PretendRoutes", ()) - - -def test_the_two_live_columns_name_the_methods_serve_dispatches() -> None: - """The real bindings, held against the names `serve.py` and § 2.4 rely on. - - `_serve_index` is the projection column's ONE forwarded method since plan - 034 T055: the § 2.4 binding for `/snapshot-index.json` names it. The core - `/snapshot.json` arm's handlers, which travelled to the column at § 2.4 - PR 3 while their dispatch ARM stayed core, are `serve.py`'s own again. - """ - from opendox import consumer_reach - - gate_module, gate_class, gate_methods = \ - consumer_reach.LateGateRoutes.LATE_COLUMN - assert (gate_module, gate_class) == ("openxdox.serve_gate", "GateRoutes") - assert "_handle_gate_action" in gate_methods, ( - "the route the § 2.4 gate binding declares") - - proj_module, proj_class, proj_methods = \ - consumer_reach.LateProjectionRoutes.LATE_COLUMN - assert (proj_module, proj_class) == ("openxdox.serve_projection", - "ProjectionRoutes") - assert proj_methods == ("_serve_index",), ( - "the projection column forwards the ONE method its contributed " - "`/snapshot-index.json` binding names. The core `/snapshot.json` " - "arm's handlers are serve.py's own since plan 034 T055") - # § 3.4 SLICE S6, RULED Q4 (openxFactory#656 comment 5642758731): the - # `/source` pair is `serve.py`'s own FIXED CORE ARM now, so the three - # methods that answer it must NOT be forwarded into the consumer. Asserted - # as an ABSENCE and not left as silence: a forwarder left behind here would - # be invisible — the route would keep working wherever openXdox happens to - # be installed, which is every developer machine and neither claim this - # slice makes. - for departed in ("_keyed_source", "_serve_source", "_refuse_bare_source"): - assert departed not in proj_methods, ( - f"{departed} answers /source, which RULED Q4 makes openDox's own " - "core arm; it must be defined in serve.py, not forwarded to " - "openxdox.serve_projection") - # PLAN 034 T055: the same for the core `/snapshot.json` arm. Forwarded, - # its four handlers refused every `/snapshot.json` of a standalone server. - for departed in ("_query_key", "_read_snapshot", "_serve_snapshot", - "_hosted_entry_refused"): - assert departed not in proj_methods, ( - f"{departed} answers /snapshot.json, the neutral product's own core " - "arm; since plan 034 T055 it is defined in serve.py") - - -def test_the_prefix_is_refused_rather_than_doubled() -> None: - from opendox import consumer_reach - - with pytest.raises(ValueError, match="WITHOUT"): - consumer_reach.module("openxdox.gate_console", reason="a test's own") +# RETIRED BY PLAN 034 T084 (#1144 4.3, F4.1 whole). This section exercised +# `opendox.consumer_reach`: the late module stand-in, its refusal naming the +# layering, its caching, its dunder discipline, and the late route columns +# `LateGateRoutes` and `LateProjectionRoutes` with the method names +# `serve.py` dispatched through them. Every reach it stood in for is now read +# from a declared seam (`opendox.projection_seams`, `opendox.generator_seam`, +# `opendox.column_seams`), and the two columns are a host's, composed in +# through the handler-contribution facet (R1Q1 (a)), so the module is deleted. +# `tests/test_projection_seams.py`'s `test_the_stand_ins_module_is_retired` +# holds its absence, and `tests/test_source_core_arm.py` holds the handler's +# bases to openDox's own. # -------------------------------------------------------------------------- # 3 — the converted sites, and the blind spot that made this file necessary # -------------------------------------------------------------------------- -#: `module path -> the names this slice rebound to the late seam`. Each must be -#: reachable ONLY from a function body: a default argument, an annotation, a -#: decorator or a module-level expression would resolve the consumer at import -#: time and make the conversion a census trick. -CONVERTED_SITES = { - # RE-DERIVED BY PLAN 034 T034 from the tree, where phase 1's lanes joined: - # every module-level name bound to a `consumer_reach` stand-in, which - # `test_every_name_bound_to_the_seam_is_guarded` below now derives on every - # run. The table had fallen behind by five names in two files, and the - # guard never read them: - # * `branch_session.py`'s `gate_console`. This is one of the two reverts - # the guard is named for (the NINE default-argument sites this file's - # docstring gives), and it was the one module the table left out; - # * `cli.py`'s other four aliases, the two late callables BUILD slice 2b - # bound for the generate verbs and the `--generated-at` check, and the - # one late constant. - # None of the five is read at import time, so the tree was already right. - # - # NARROWED BY PLAN 034 T055 to the gate column alone. `cli.py`'s - # `snapshot_mod`, `corpus_root_refusal`, `generate_snapshot`, - # `is_rfc3339_datetime` and `SCANNED_ROOTS`, `serve.py`'s `registry_mod` - # and `hosted_ref_refused`, `serve_workbench.py`'s `registry_mod` and - # `workbench.py`'s `find_validator` bind no stand-in any more: the - # projection mechanism is read from declared seams - # (`opendox.projection_seams`, `opendox.generator_seam`), and the two - # `registry_mod`s are the registry seam's proxy, which - # `tests/test_projection_seams.py` holds to the same import-time rule. - # `serve.py` still names the two late COLUMNS as mixin bases, which a class - # statement needs before its first instance exists, so it binds no name - # here either. - "branch_session.py": ("gate_console",), - "cli.py": ("gate_mod",), -} - - -def _import_time_uses(path: Path, names: frozenset[str]) -> list[tuple[int, str]]: - """Every use of `names` that runs when the module is imported. - - The module body, module-level `if`/`try`/`with`, CLASS bodies, and — the - case the openXdox-side census cannot see — a function's DEFAULTS, - ANNOTATIONS and DECORATORS, which are evaluated where the `def` sits and - not where it is called. Only a function BODY defers. - """ - hits: list[tuple[int, str]] = [] - - def used(node: ast.AST, why: str) -> None: - for inner in ast.walk(node): - # READS only. The one module-level STORE of each of these names is - # the seam binding itself (`gate_mod = consumer_reach.gate_console`) - # — the line this slice wrote, which resolves nothing. - if isinstance(inner, ast.Name) and inner.id in names \ - and isinstance(inner.ctx, ast.Load): - hits.append((inner.lineno, f"{inner.id} ({why})")) - - def walk(body: list[ast.stmt], at_import_time: bool) -> None: - for node in body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - if at_import_time: - args = node.args - for default in [*args.defaults, *(d for d in args.kw_defaults if d)]: - used(default, "default argument") - for arg in [*args.posonlyargs, *args.args, *args.kwonlyargs, - args.vararg, args.kwarg]: - if arg is not None and arg.annotation is not None: - used(arg.annotation, "annotation") - for decorator in node.decorator_list: - used(decorator, "decorator") - if node.returns is not None: - used(node.returns, "return annotation") - continue - if not at_import_time: - continue - nested: list[ast.stmt] = [] - for _field, value in ast.iter_fields(node): - items = value if isinstance(value, list) else [value] - for item in items: - if isinstance(item, ast.stmt): - nested.append(item) - elif isinstance(item, ast.AST): - used(item, "module level") - walk(nested, True) - - walk(ast.parse(path.read_text(encoding="utf-8")).body, True) - return sorted(set(hits)) - - -def _module_level_statements(body: list[ast.stmt]): - """The statements a module runs when it is imported, in source order: its - body, and the bodies of a module-level `if`, `try`, `with`, `for`, - `while` or `match`, with their handlers and `else` blocks. A function's - body and a class's are not the module's names.""" - for node in body: - yield node - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): - continue - for _field, value in ast.iter_fields(node): - for item in value if isinstance(value, list) else []: - if isinstance(item, ast.stmt): - yield from _module_level_statements([item]) - elif isinstance(item, (ast.ExceptHandler, ast.match_case)): - yield from _module_level_statements(item.body) - - -def _names_bound_to_the_seam(path: Path) -> set[str]: - """Every name a module binds, at its top level, to a `consumer_reach` - stand-in. - - Every spelling of the seam counts. `from .consumer_reach import X` (`..` - in a subpackage) and `from opendox.consumer_reach import X` bind one - directly. Once the seam itself is reachable, so do `Y = .X`, its - annotated form `Y: T = .X`, and `Y = .f(...)`, which mints one - (`module`, `function`, `constant`). `` is a name bound to the - module (`from . import consumer_reach`, `import opendox.consumer_reach as - cr`, or `cr = consumer_reach` after either), or the package's attribute - `opendox.consumer_reach`, once `import opendox` or `import - opendox.consumer_reach` has bound `opendox`. Each is read wherever the - module runs it at import time, a module-level `if` or `try` included.""" - tree = ast.parse(path.read_text(encoding="utf-8")) - # The leading dots from this file to the `opendox` package: one for a - # module at the top of it, two in a subpackage, and so on. - level = len(path.relative_to(PACKAGE).parts) - seam_aliases: set[str] = set() - package_aliases: set[str] = set() - bound: set[str] = set() - statements = list(_module_level_statements(tree.body)) - for node in statements: - if isinstance(node, ast.ImportFrom): - package = (node.level == level and node.module is None) or \ - (node.level == 0 and node.module == "opendox") - seam = (node.level == level and node.module == "consumer_reach") or \ - (node.level == 0 and node.module == "opendox.consumer_reach") - if package: - seam_aliases |= {a.asname or a.name for a in node.names - if a.name == "consumer_reach"} - if seam: - bound |= {a.asname or a.name for a in node.names} - elif isinstance(node, ast.Import): - for alias in node.names: - if alias.name == "opendox.consumer_reach" and alias.asname: - seam_aliases.add(alias.asname) - elif alias.name == "opendox" or ( - alias.name.startswith("opendox.") and not alias.asname): - package_aliases.add(alias.asname or "opendox") - - def is_the_seam(expr: ast.expr | None) -> bool: - return (isinstance(expr, ast.Name) and expr.id in seam_aliases) or ( - isinstance(expr, ast.Attribute) and expr.attr == "consumer_reach" - and isinstance(expr.value, ast.Name) - and expr.value.id in package_aliases) - - for node in statements: - if not isinstance(node, (ast.Assign, ast.AnnAssign)): - continue - targets = node.targets if isinstance(node, ast.Assign) else [node.target] - names = {t.id for t in targets if isinstance(t, ast.Name)} - if is_the_seam(node.value): - seam_aliases |= names - continue - value = node.value.func if isinstance(node.value, ast.Call) else node.value - if isinstance(value, ast.Attribute) and is_the_seam(value.value): - bound |= names - return bound - - -def test_every_name_bound_to_the_seam_is_guarded() -> None: - """The guard's table is the tree's, name for name (plan 034 T034). - - A name bound to the seam and missing from `CONVERTED_SITES` is a name the - import-time guard never reads. A name the table keeps and no module binds - any more is a guard over nothing. The seam's own module is left out: it - DEFINES the stand-ins.""" - derived = {} - for path in sorted(PACKAGE.rglob("*.py")): - if path.name == "consumer_reach.py": - continue - bound = _names_bound_to_the_seam(path) - if bound: - derived[path.relative_to(PACKAGE).as_posix()] = bound - starred = sorted(module for module, names in derived.items() if "*" in names) - assert not starred, ( - f"{starred} import the seam's stand-ins with a wildcard. The guard " - "reads each converted name by name, and a wildcard gives it none to " - "read: import each stand-in by its name") - declared = {module: set(names) for module, names in CONVERTED_SITES.items()} - assert derived == declared, ( - f"the names each module binds to `consumer_reach` are {derived}, and " - f"CONVERTED_SITES guards {declared}. Add a new binding to the table, so " - "its import-time uses are refused, and take a retired one out") - - -@pytest.mark.parametrize("module_file", sorted(CONVERTED_SITES)) -def test_a_converted_name_is_never_used_at_import_time(module_file: str) -> None: - """The guard that would have caught the two reverts before they were made.""" - found = _import_time_uses(PACKAGE / module_file, - frozenset(CONVERTED_SITES[module_file])) - assert found == [], ( - f"src/opendox/{module_file} uses a late-bound consumer name where it " - f"runs AT IMPORT TIME: {found}. The stand-in would resolve `openxdox` " - "there, so removing the import statement would lower openXdox-code's " - "ratchet without removing the dependency — a census that reads better " - "than the tree. Defer the use, or leave the import alone and ask for " - "the declared-edit ruling") +# RETIRED BY PLAN 034 T084 (#1144 4.3). This section held `CONVERTED_SITES`, +# every module-level name bound to a `consumer_reach` stand-in, and refused any +# read of one at import time: a default argument, an annotation, a decorator or +# a module-level expression, the blind spot this file was written for. The last +# two, `branch_session.py`'s `gate_console` and `cli.py`'s `gate_mod`, are now +# proxies over `opendox.column_seams.gate`, so no name binds a stand-in. The +# same rule holds them where every seam proxy is held: +# `tests/test_projection_seams.py`'s +# `test_no_proxy_over_a_seam_is_read_at_import_time`, which names both, with no +# import-time read. def test_no_module_under_src_names_the_pre_carve_package_at_import_time() -> None: diff --git a/tests/test_installed_help.py b/tests/test_installed_help.py new file mode 100644 index 00000000..5dedcc79 --- /dev/null +++ b/tests/test_installed_help.py @@ -0,0 +1,112 @@ +"""The installed `opendox --help` names openDox and nothing else (plan 034 +T084; found by T099's PyPI writer). + +`opendox --help` is the first thing a published install prints. It used to +print `usage: ideation-dashboard` and `cli.py`'s module docstring, which is +openxFactory's pre-carve history: "validates it against the pinned openxFactory +validator", `python3 -m ideation_dashboard.cli`, `scripts/ideation_dashboard/`. +On PyPI that text would be the product's own description of itself. + +The case runs the INSTALLED console script, the file `pip` wrote into the +interpreter's scripts directory from `pyproject.toml`'s `[project.scripts]`, +as a user runs it: not `opendox.cli.main()` in process, which would pass +whatever the entry point string said. The suite always runs against an +installed package (`validate.yml` installs it with `pip install -e`), so a +missing script fails the case rather than skipping it. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import importlib.metadata +import os +import re +import subprocess +import sys +import sysconfig +from pathlib import Path + +#: Words that name the governed host or the pre-carve package, none of which +#: belongs in the neutral product's own help. +FOREIGN = re.compile(r"openxfactory|xfactory|ideation[-_ ]dashboard|" + r"ideation_dashboard|split-opendox|\bscripts/", + re.IGNORECASE) + + +def _console_script() -> Path: + """The installed `opendox` script, after checking that the entry point + `pip` wrote it from is `opendox.cli:main`.""" + points = [ep for ep in importlib.metadata.entry_points(group="console_scripts") + if ep.name == "opendox"] + assert [ep.value for ep in points] == ["opendox.cli:main"], points + name = "opendox.exe" if os.name == "nt" else "opendox" + for directory in (Path(sysconfig.get_path("scripts")), + Path(sys.executable).parent): + if (directory / name).is_file(): + return directory / name + raise AssertionError( + f"no installed `{name}` script beside {sys.executable}: the suite runs " + "against an installed package (`pip install -e .`)") + + +def _help() -> str: + done = subprocess.run([str(_console_script()), "--help"], + capture_output=True, text=True, timeout=120, + env={**os.environ, "COLUMNS": "100"}) + assert done.returncode == 0, done.stderr + assert done.stderr == "", done.stderr + return done.stdout + + +def test_the_installed_help_names_the_installed_command() -> None: + out = _help() + assert out.startswith("usage: opendox "), out.splitlines()[0] + assert "openDox" in out, out + + +def test_the_installed_help_names_no_host_and_no_pre_carve_package() -> None: + out = _help() + found = sorted({match.group(0) for match in FOREIGN.finditer(out)}) + assert found == [], ( + f"`opendox --help` names {found}, which is not openDox's own " + f"vocabulary:\n{out}") + + +def test_the_parser_carries_the_neutral_name_and_words() -> None: + """The same, in process, so a failure names the attribute that moved.""" + from opendox import cli + parser = cli.build_parser() + assert parser.prog == cli.PROG == "opendox" + assert parser.description == cli.PARSER_DESCRIPTION + assert parser.epilog == cli.PARSER_EPILOG + for text in (cli.PARSER_DESCRIPTION, cli.PARSER_EPILOG): + assert not FOREIGN.search(text), text + + +def test_the_servers_own_help_names_openDox_only() -> None: + """`python -m opendox.serve --help`, the server entry point's help, under + the same rule (adversarial review 2): it printed + `usage: ideation-dashboard-serve` and the module's pre-carve docstring.""" + from opendox import serve + done = subprocess.run([sys.executable, "-m", "opendox.serve", "--help"], + capture_output=True, text=True, timeout=120, + env={**os.environ, "COLUMNS": "100"}) + assert done.returncode == 0, done.stderr + assert done.stdout.startswith(f"usage: {serve.SERVE_PROG} "), \ + done.stdout.splitlines()[0] + found = sorted({m.group(0) for m in FOREIGN.finditer(done.stdout)}) + assert found == [], f"the server's help names {found}:\n{done.stdout}" + + +def test_the_servers_help_states_loopback_as_the_default_bind() -> None: + """Loopback is the server's DEFAULT bind, not a promise: `--host` takes any + address, and a hosted install serves through this entry point (Copilot + review of openDox-code#77, r4173844338). The help said every run served + "locally ... on a loopback address", which a hosted run is not. Where it + names loopback, it names the option that replaces it.""" + from opendox import serve + text = " ".join(serve.SERVE_DESCRIPTION.split()) + assert "loopback" in text, text + assert "unless --host" in text, text + assert not re.search(r"\blocally\b", text), text diff --git a/tests/test_lens_seed_actions.py b/tests/test_lens_seed_actions.py index 5df9a45f..517c17cb 100644 --- a/tests/test_lens_seed_actions.py +++ b/tests/test_lens_seed_actions.py @@ -445,8 +445,15 @@ def test_the_lens_of_a_real_standalone_serve_offers_neither_seed_action( capabilities, snapshot = served["capabilities"], served["snapshot"] assert capabilities["views"]["contributed_routes"] == [], ( "a serve with no host contributes no route") - assert capabilities["actions"]["gate"] is actor, ( - "the serve's gate verdict follows the identity it can resolve") + # SINCE T084 (plan 034; RULED openxFactory#656 `5920216845`, item 1) the + # gate flag is true only where a contributed binding answers a gate verb, + # and a serve with no host contributes none. So it reads false in both + # runs, and the identity is asserted where it lands, on the resolved + # actor. + assert capabilities["actions"]["gate"] is False, ( + "a serve with no gate route offers no gate action") + assert (capabilities["actor"] is not None) is actor, ( + "the serve's actor follows the identity it can resolve") # the keyword the most documents declare, so the radar has dots to draw carriers = Counter(topic for doc in snapshot["documents"] for topic in doc.get("topics", [])) diff --git a/tests/test_neutral_turn_scope.py b/tests/test_neutral_turn_scope.py new file mode 100644 index 00000000..df6a88ba --- /dev/null +++ b/tests/test_neutral_turn_scope.py @@ -0,0 +1,274 @@ +"""A tile's OWN documents are editable in openDox's default scope, and nothing +else is (plan 034 T084; RULED by Brett Heap, openxFactory#656 comment +`5961651355`, "Tile's own documents editable (Recommended)", which supersedes +the holder's read-only reading of R1Q10 (a)). + +WHY IT MATTERS. openDox's turn guard (`doxbench_turns._require_in_scope_and_ +editable`, FR-015) refuses a turn whose buffer names a path that is in scope +but not editable. Under a read-only default, every turn a lone openDox ran was +refused "readable but not editable", so its chat could never answer. The +default now marks the tile's own sections editable, which are a group's +members, a selection's files and a candidate's claiming groups' members, as +openXdox's authority marks its owned sections. The set is ONE named function, +`default_columns.editable_paths`. + +THE CASES run over a composed host in process: the plain fixture in a fresh +repository, a loopback bind, an authenticated actor, the binding +`opendox model-binding add` declares, and the port the entry points declare +over it (`doxbench_install.declared_model_port_factory`). The host also has +the released validators, as a plane with a readable contract has them. Case +4 runs the same turn on a standalone `python -m opendox.serve`, which has +openDox's own validators since T085 (openDox-code#71). + +1. A turn whose buffer names a document of the tile passes the guard and + reaches the model step. It asks for a model the catalog does not carry, so + step 7 answers `model_unavailable`, and nothing is spawned or contacted. +2. A turn naming a corpus document OUTSIDE the tile is still refused at the + guard (`turn_scope_refused`). +3. Save is still refused. "Editable" is the scope's word, and it grants no + write. Writing is the Save gate's, a host's gate route: this host + contributes none, so `POST /actions/gate/first-edit` answers + `unknown_action`. And the record a Save writes is a governed gate-action + record, which the gate seam's default refuses by name. + +4. STANDALONE, `python -m opendox.serve` as a child with neither sibling + importable, over the same checkout and binding: a turn over the tile's own + document passes the guard and reaches the model step (`model_unavailable`) + as case 1 does, with openDox's own validators (T085) and no stand-in. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import json +import os +import re +import threading +from pathlib import Path + +import pytest + +from standalone_child import Child, fresh_repository, git, run_module + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +WEB = ROOT / "src" / "opendox" / "web" + +#: The plain fixture's group of two, and a corpus document outside it. +TILE = {"repository": "fixture", "ref": "main", "tile_kind": "cluster", + "tile_id": "barrel-rain"} +OWN = "notes-rain-barrel-leak.md" +OUTSIDE = "notes-toolshed-inventory.md" + +#: The binding `opendox model-binding add` declares. Its broker is never run. +BINDING = ["--id", "scope-binding", "--label", "Scope binding", + "--provider", "scope-provider", + "--credential-ref", "opref-4f2a91c07be3d5a8140b6e77", + "--auth-kind", "api_key", + "--credential-approver", "fixture@example.invalid", + "--endpoint", "https://provider.invalid/turn", + "--dialect", "xfactory-prompt-v1", + "--", "scope-broker", "--home", "/srv/{binding_id}"] + + +class _Conforms: + @staticmethod + def iter_errors(_instance): + return iter(()) + + +class _EveryKind(dict): + """The released validators, as a plane that can read its contract has + them: one for every kind, and every instance conforms.""" + + def get(self, _kind, _default=None): + return _Conforms() + + +def _call(base, method, path, *, body=None, token=None): + from opendox import serve + connection = http.client.HTTPConnection(*base, timeout=30) + try: + headers = {"Content-Type": "application/json"} + if token: + headers[serve.CONSOLE_TOKEN_HEADER] = token + connection.request(method, path, body=body, headers=headers) + response = connection.getresponse() + raw = response.read().decode("utf-8", errors="replace") + try: + parsed = json.loads(raw) if raw else {} + except ValueError: + parsed = {} + return response.status, parsed if isinstance(parsed, dict) else {}, raw + finally: + connection.close() + + +def _turn(repo: Path, document: str) -> bytes: + """A well-formed v2 turn over `TILE`, bound to `document`, whose buffer + carries the document's committed text with its true content identity.""" + from opendox import doxbench_hash + from opendox.serve_wire import DOXBENCH_CHAT_TURN_V2_KIND + + def buffer(kind, path, content): + identity = doxbench_hash.content_identity(content, max_bytes=None).hex + return {"kind": kind, "repository": "fixture", "path": path, + "base_ref": "main", "base_revision": "0" * 40, + "base_hash": identity, "content_hash": identity, + "content": content, "dirty": False} + + text = (repo / document).read_text(encoding="utf-8") + return json.dumps({ + "schema_version": 1, "kind": DOXBENCH_CHAT_TURN_V2_KIND, + "client_turn_id": f"scope-{document}", "scope": TILE, + "working_subject": "", "message": "What does this note claim?", + "model_id": "a-model-the-catalog-does-not-carry", "transcript": [], + "last_assistant_turn_id": None, + "bound_buffer": document, + "buffers": [buffer("outline", None, "# outline\n"), + buffer("document", document, text)], + }).encode("utf-8") + + +@pytest.fixture() +def host(tmp_path): + """The composed host the module docstring describes, its actor `brett` + one of the suite's declared principals + (`session_fixtures.declared_gate_principals`). Yields + `(base, capabilities, repo)`.""" + from opendox import doxbench_install, serve + + repo = fresh_repository(PLAIN, tmp_path) + git(repo, "config", "user.name", "fixture") + git(repo, "config", "user.email", "fixture@example.invalid") + added, status = run_module(tmp_path, "opendox.cli", "model-binding", "add", + "--repo-root", str(repo), *BINDING) + assert status == 0, added.stderr_text() + out = tmp_path / "out" / "snapshot.json" + generated, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, generated.stderr_text() + httpd = serve.build_server( + WEB, out, repo, port=0, actor="brett", + schema_validator_factory=_EveryKind, + model_port_factory=doxbench_install.declared_model_port_factory( + doxbench_install.session_root_beside(out), checkout_root=repo)) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + try: + base = httpd.server_address[:2] + status, caps, raw = _call(base, "GET", "/capabilities") + assert status == 200, raw + assert caps["actions"]["session"] is True, caps + yield base, caps, repo + finally: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +def test_a_turn_over_the_tiles_own_document_reaches_the_model_step(host) -> None: + from opendox.serve_wire import DOXBENCH_ERR_MODEL_UNAVAILABLE + base, caps, repo = host + status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", + body=_turn(repo, OWN), + token=caps["console_token"]) + # past the guard (no "readable but not editable"), past identity, and + # answered by the model step: the catalog carries no such model + assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, (status, raw) + assert body.get("client_turn_id") == f"scope-{OWN}", body + + +def test_a_turn_over_a_document_outside_the_tile_is_refused(host) -> None: + from opendox.serve_wire import DOXBENCH_ERR_TURN_SCOPE_REFUSED + base, caps, repo = host + assert (repo / OUTSIDE).is_file() + status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", + body=_turn(repo, OUTSIDE), + token=caps["console_token"]) + assert body.get("error") == DOXBENCH_ERR_TURN_SCOPE_REFUSED, (status, raw) + + +def test_save_is_still_the_gates_and_refused_by_name(host) -> None: + """Editable is not saveable: no Save route answers without a host's gate, + and the governed record a Save writes is refused naming the seam.""" + from opendox import column_seams + from opendox.default_columns import GateRecordsNotRegistered + base, caps, _repo = host + assert caps["actions"]["gate"] is False, caps + status, body, raw = _call(base, "POST", "/actions/gate/first-edit", + body=b"{}", token=caps["console_token"]) + assert status == 404 and body.get("error") == "unknown_action", raw + gate = column_seams.gate.current() + with pytest.raises(GateRecordsNotRegistered) as refused: + gate.build_gate_action_record( + actor="brett", action=gate.ACTION_EDIT_DOCUMENT, + at="2026-10-02T00:00:00Z", provenance=gate.HTTP_CONSOLE_TOKEN) + assert "opendox.column_seams.gate" in str(refused.value) + assert "opendox.column_seams.gate.register(" in str(refused.value) + + +def test_the_tiles_own_documents_are_exactly_the_editable_set(host) -> None: + """The projection the guard reads, over the same snapshot: the group's + two members, and nothing else of the corpus's eight.""" + from opendox import column_seams + from opendox.doxbench_scope_types import ScopeKey + base, _caps, repo = host + snapshot = json.loads((repo.parent / "out" / "snapshot.json") + .read_text(encoding="utf-8")) + projection = column_seams.scope.current().resolve_scope( + snapshot, ScopeKey(**TILE), source_root=repo) + own = ("notes-rain-barrel-leak.md", "notes-rain-barrel-overflow.md") + assert projection.context_paths == own + assert projection.editable_paths == own + assert projection.active_document_candidates == own + assert OUTSIDE not in projection.editable_paths + + +_SERVE_URL = re.compile(r"^serving ideation dashboard at " + r"(http://([0-9.]+):([0-9]+))/index\.html$") + + +def test_a_standalone_turn_over_the_tiles_own_document_is_answered( + tmp_path, monkeypatch) -> None: + """Case 4. The child's environment carries no `GIT_*` and no `XF_*`, so + its actor is the one its repository's identity names (the suite's own + roster of several principals would resolve none).""" + from opendox.serve_wire import DOXBENCH_ERR_MODEL_UNAVAILABLE + for name in list(os.environ): + if name.startswith(("GIT_", "XF_")): + monkeypatch.delenv(name) + monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull) + monkeypatch.setenv("GIT_CONFIG_SYSTEM", os.devnull) + repo = fresh_repository(PLAIN, tmp_path) + git(repo, "config", "user.name", "fixture") + git(repo, "config", "user.email", "fixture@example.invalid") + added, status = run_module(tmp_path, "opendox.cli", "model-binding", "add", + "--repo-root", str(repo), *BINDING) + assert status == 0, added.stderr_text() + out = tmp_path / "out" / "snapshot.json" + generated, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, generated.stderr_text() + child = Child(tmp_path, "opendox.serve", "--snapshot", str(out), + "--checkout-root", str(repo), "--port", "0") + try: + match = child.wait_for_line(_SERVE_URL) + base = (match.group(2), int(match.group(3))) + status, caps, raw = _call(base, "GET", "/capabilities") + assert status == 200 and caps["actions"]["session"] is True, raw + status, body, raw = _call(base, "POST", "/actions/workbench/chat-turn", + body=_turn(repo, OWN), + token=caps["console_token"]) + # past the validators, the guard and identity, answered by the model + # step: never `turn_scope_refused`, never a dropped connection + assert body.get("error") == DOXBENCH_ERR_MODEL_UNAVAILABLE, (status, raw) + assert body.get("client_turn_id") == f"scope-{OWN}", body + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert child.refused() == [], child.refused() diff --git a/tests/test_profile_registration.py b/tests/test_profile_registration.py index 18662836..70bd1484 100644 --- a/tests/test_profile_registration.py +++ b/tests/test_profile_registration.py @@ -10,8 +10,9 @@ 1. NOTHING RESOLVES AT IMPORT TIME. The whole value of a lazy proxy is that importing it cannot fail for want of a host, so the check is an import in a - SUBPROCESS that then reads the registry — `consumer_reach`'s own - `test_importing_the_seam_resolves_nothing` for the same reason. + SUBPROCESS that then reads the registry, as `consumer_reach`'s + `test_importing_the_seam_resolves_nothing` did for the same reason, until + plan 034 T084 retired that seam. 2. THE UNREGISTERED READ REFUSES, AND THE MESSAGE NAMES THE CALL. A refusal whose text does not name the fix is a stack trace with extra steps, so the assertion is on the CONTENT — the registration call, the ruling, the runbook, @@ -544,8 +545,8 @@ def _import_time_nodes(body: list[ast.stmt]): """Every node evaluated when the module is IMPORTED. A function's BODY defers; its decorators and default arguments do not, and a - class body runs outright. `tests/test_consumer_reach.py::_import_time_uses` - draws the line in the same place for the consumer seam, and for the same + class body runs outright. `tests/test_projection_seams.py::_import_time_reads` + draws the line in the same place for every seam proxy, and for the same reason: `ast.walk` over a module descends into function bodies and would call every deferred binding an import-time one. """ diff --git a/tests/test_projection_seams.py b/tests/test_projection_seams.py index 01bad88b..a062f1d9 100644 --- a/tests/test_projection_seams.py +++ b/tests/test_projection_seams.py @@ -67,7 +67,6 @@ from opendox import serve from opendox import workbench from opendox import branch_session as bs -from opendox import consumer_reach from opendox.boundary import BoundaryViolation, OutputBoundary ROOT = Path(__file__).resolve().parent.parent @@ -1550,7 +1549,12 @@ def test_openDoxs_own_kind_meets_openDoxs_own_validator(tmp_path, capsys) -> Non assert cli._validate(written, _validate_args(tmp_path)) == 1 err = capsys.readouterr().err assert "REJECTED" in err and "This is the SNAPSHOT" in err - assert "[envelope-keys] : 'documents' is required" in err, err + # ONE line per broken rule, with its count (T084; RULED 5920216845 item + # 3), and each further place it is broken beneath it, so every missing + # key is still named. + assert "6 × [envelope-keys] : " in err, err + assert err.count("[envelope-keys]") == 1, err + assert ": 'documents' is required" in err, err assert "6 violation(s) of the opendox-snapshot contract, by opendox.validator" in err assert "validation SKIPPED" not in err @@ -1723,15 +1727,23 @@ def test_an_explicit_manifest_validator_script_still_runs(tmp_path) -> None: # 9 — no proxy over a seam is read at import time # --------------------------------------------------------------------------- +#: The modules whose seams are `projection_seams._Seam`s, so whose proxies +#: this rule holds: the projection mechanism's, and the consumer columns' +#: (`opendox.column_seams`, plan 034 T084), which took over the import-time +#: guard `tests/test_consumer_reach.py` kept over the `consumer_reach` +#: stand-ins those proxies replace. +SEAM_MODULES = ("projection_seams", "column_seams") + + def _proxy_bindings(tree: ast.Module) -> set[str]: - """Module-level names bound to `projection_seams..proxy`.""" + """Module-level names bound to `..proxy`.""" bound = set() for node in tree.body: if isinstance(node, ast.Assign) and isinstance(node.value, ast.Attribute) \ and node.value.attr == "proxy" \ and isinstance(node.value.value, ast.Attribute) \ and isinstance(node.value.value.value, ast.Name) \ - and node.value.value.value.id == "projection_seams": + and node.value.value.value.id in SEAM_MODULES: bound |= {t.id for t in node.targets if isinstance(t, ast.Name)} return bound @@ -1789,18 +1801,20 @@ def test_no_proxy_over_a_seam_is_read_at_import_time() -> None: if names: found[path.relative_to(PACKAGE).as_posix()] = ( sorted(names), _import_time_reads(tree, names)) - assert found == {"serve.py": (["registry_mod"], []), + assert found == {"branch_session.py": (["gate_console"], []), + "cli.py": (["gate_mod"], []), + "serve.py": (["registry_mod"], []), "serve_workbench.py": (["registry_mod"], [])}, found -def test_the_retired_stand_ins_are_gone_from_consumer_reach() -> None: - for name in ("snapshot", "snapshot_registry", "corpus_root", "generator", - "find_validator", "corpus_root_refusal", "generate_snapshot", - "is_rfc3339_datetime", "hosted_ref_refused", "scanned_roots", - "function", "constant"): - assert not hasattr(consumer_reach, name), name - assert name not in consumer_reach.__all__, name - assert consumer_reach.LateProjectionRoutes.LATE_COLUMN[2] == ("_serve_index",) +def test_the_stand_ins_module_is_retired() -> None: + """T055 retired the projection mechanism's stand-ins, and T084 the last + three: the gate console's module stand-in and the gate and projection + columns' late bases. So `consumer_reach` itself is gone (F4.1 whole: "the + file is absent"), and nothing in the package can import it.""" + import importlib.util + assert not (PACKAGE / "consumer_reach.py").exists() + assert importlib.util.find_spec("opendox.consumer_reach") is None # --------------------------------------------------------------------------- diff --git a/tests/test_reach_sweep.py b/tests/test_reach_sweep.py index ad993a96..c4c72cc2 100644 --- a/tests/test_reach_sweep.py +++ b/tests/test_reach_sweep.py @@ -47,7 +47,8 @@ because it could hide a reach into openxFactory. So is a star import from a package a sibling lives under (`from scripts import *`), which may import any submodule the package's `__all__` lists. A computed name is not refused, -because `consumer_reach`'s seam imports one. A relative call is read against +because a late seam may import one (`consumer_reach`'s did, until plan 034 +T084 retired it). A relative call is read against `globals()` or `__package__` as the module's own only where the module never rebinds either; where it does, the call is refused too. A name in a comment, a docstring or a string is not an import. The openxFactory ban goes one step @@ -587,7 +588,8 @@ def test_a_deferred_reach_into_the_consumer_passes_all_three(monkeypatch, tmp_pa def test_an_importing_call_it_cannot_read_is_refused(monkeypatch, tmp_path): """A spread that hides an importing call's module could hide a reach into openxFactory, so the sweep refuses it. A computed name is another - matter (`consumer_reach`'s seam imports one), and is not refused.""" + matter (a late seam may import one, as `consumer_reach`'s did until plan + 034 T084), and is not refused.""" (tmp_path / "src").mkdir() (tmp_path / "src" / "spreading.py").write_text( "import importlib\n\n\ndef verb(names):\n" diff --git a/tests/test_rejection_report.py b/tests/test_rejection_report.py new file mode 100644 index 00000000..fefecdc8 --- /dev/null +++ b/tests/test_rejection_report.py @@ -0,0 +1,218 @@ +"""The rejection report: every broken rule, once, with its count (plan 034 +T084; RULED openxFactory#656 `5920216845`, item 3, *"Show every rule, grouped +(Recommended)"*). + +`cli._report_non_conformance` is what the generate verbs print when the +validator registered for a snapshot's kind rejects it. It printed the LAST 20 +LINES of the validator's output, so a snapshot that broke one rule a hundred +times and a second rule once showed twenty copies of the first and never named +the second. Now each broken rule id is printed ONCE, with its exact count and +where it is first broken, in the order the validator found them, and the +validator's own summary line follows. + +The cases: + +1. THE RULING'S CASE. A rejected snapshot that breaks one rule several times + and a second rule once, run through `cli._validate`, the function both + generate verbs call, with openDox's own validator registered for the + snapshot's kind: each rule id appears ONCE, with its exact count. So an + implementation that always prints `1`, or never groups a repeated id, + fails it. The snapshot is the one `python -m opendox.cli generate` writes + over a corpus with four empty titles and summaries (rule A, four times), + with ONE more break of a second rule written into it, because openDox's + projection does not let a corpus break that second rule at all. +2. THE VERB ITSELF, in a child with neither sibling importable + (`tests/standalone_child.py`): `generate --strict` over that corpus exits 1 + and names its one rule once, with the count 4. +3. A VALIDATOR THAT NAMES NO RULE ID (a host's, say) still has its own last + lines printed, as before: there is nothing to group, and nothing is hidden. + +A module of its own, clear of `tests/test_post_render_validator.py` (which +T085 edits). F7.2's assertions there still hold: the fixture's rule id is +printed with where it is first broken, and so is the validator's summary. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + +import pytest + +from opendox import cli +from opendox import projection_seams as ps +from standalone_child import fresh_repository, run_module + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" + +#: The rule the corpus below breaks four times, and the rule the written +#: snapshot then breaks once more. +REPEATED = "title-and-summary-are-text" +ONCE = "repository-is-text" + +#: Three empty titles and one empty summary: four breaks of `REPEATED`. +_EMPTIED = { + "notes-rain-barrel-leak.md": "title", + "notes-rain-barrel-overflow.md": "title", + "notes-toolshed-inventory.md": "title", + "grouping-compost-corner.md": "summary", +} + +#: One reported rule: ` × [] : `. +_GROUPED = re.compile(r"^ (?P[0-9]+) × \[(?P[^\]]+)\] (?P.+)$") + + +def _emptied_corpus(parent: Path) -> Path: + edits = {} + for name, field in _EMPTIED.items(): + text = (PLAIN / name).read_text(encoding="utf-8") + edits[name] = re.sub(rf"(?m)^{field}: .*$", f"{field}:", text, count=1) + assert edits[name] != text, f"{name} carries no {field}: line to empty" + return fresh_repository(PLAIN, parent, edits=edits) + + +@pytest.fixture() +def seams(): + """openDox's own defaults at the projection seams, and nothing left + behind: the seams are put back exactly as each case found them.""" + held = {name: getattr(ps, name) for name in ("registry", "corpus_root", "writer")} + found = {name: (seam._registered, seam._is_default, seam._default_read) + for name, seam in held.items()} + kinds = dict(ps.validators._registered) + read = set(ps.validators._default_read) + for seam in held.values(): + seam.unregister() + ps.validators.unregister() + ps.register_defaults() + try: + yield + finally: + for name, seam in held.items(): + seam._registered, seam._is_default, seam._default_read = found[name] + ps.validators._registered.clear() + ps.validators._registered.update(kinds) + ps.validators._default_read.clear() + ps.validators._default_read.update(read) + + +def _written_snapshot(tmp_path: Path) -> Path: + """The snapshot the real verb writes over the emptied corpus, unvalidated.""" + repo = _emptied_corpus(tmp_path) + out = tmp_path / "snapshot.json" + child, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, child.stderr_text() + assert child.refused() == [], child.refused() + return out + + +def _grouped(err: str) -> list[tuple[str, int, str]]: + return [(m["rule"], int(m["count"]), m["rest"]) + for m in map(_GROUPED.match, err.splitlines()) if m] + + +def test_each_broken_rule_is_printed_once_with_its_exact_count( + tmp_path, seams, capsys) -> None: + """The ruling's case: one rule broken four times, a second once.""" + written = _written_snapshot(tmp_path) + snapshot = json.loads(written.read_text(encoding="utf-8")) + snapshot["repository"] = 7 # ONE break of `ONCE` + written.write_text(json.dumps(snapshot), encoding="utf-8") + args = argparse.Namespace(no_validate=False, strict=True, + repo_root=str(tmp_path / PLAIN.name)) + assert cli._validate(written, args) == 1 + err = capsys.readouterr().err + grouped = _grouped(err) + assert sorted((rule, count) for rule, count, _ in grouped) == sorted([ + (ONCE, 1), (REPEATED, 4)]), err + # ONCE EACH: no rule id is named on any other line of the report. + for rule in (REPEATED, ONCE): + assert len(re.findall(re.escape(f"[{rule}]"), err)) == 1, err + # where each is FIRST broken, in the validator's own words + assert dict((rule, rest) for rule, _, rest in grouped)[REPEATED].startswith( + "/documents/"), err + assert dict((rule, rest) for rule, _, rest in grouped)[ONCE].startswith( + "/repository:"), err + assert "5 violation(s) of 2 rule(s)" in err, err + # the validator's own summary line still follows + assert "5 violation(s) of the opendox-snapshot contract" in err, err + assert "the pinned validator REJECTED" in err and "This is the SNAPSHOT" in err + + +def test_the_verb_names_its_one_rule_once_with_its_count(tmp_path) -> None: + """`python -m opendox.cli generate --strict`, with neither sibling + importable, over the corpus that breaks one rule four times.""" + repo = _emptied_corpus(tmp_path) + out = tmp_path / "snapshot.json" + child, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--strict") + err = child.stderr_text() + assert status == 1, err + assert child.refused() == [], child.refused() + assert [(rule, count) for rule, count, _ in _grouped(err)] == [ + (REPEATED, 4)], err + assert len(re.findall(re.escape(f"[{REPEATED}]"), err)) == 1, err + assert "4 violation(s) of 1 rule(s)" in err, err + + +def test_a_report_that_names_no_rule_still_prints_its_own_last_lines( + capsys) -> None: + """A validator whose output names no rule id has nothing to group, so its + own last lines are printed, as before, and nothing is hidden.""" + lines = [f"line {n}: not conformant" for n in range(30)] + result = ps.ValidationResult(False, 1, "\n".join(lines) + "\n", "", + "a host's validator") + cli._report_non_conformance(Path("snapshot.json"), result) + err = capsys.readouterr().err + assert _grouped(err) == [], err + shown = [line.strip() for line in err.splitlines()[1:]] + assert shown == lines[-20:], err + + +def test_a_rule_broken_in_several_places_shows_each_place_beneath_it(capsys) -> None: + """Grouping keeps the order the validator found the rules in, counts every + line, names where each rule is first broken on the rule's own line, and + shows the next places beneath it without repeating the id: one rule can be + broken in different ways, and a count beside the first place alone would + read as that place repeated.""" + out = "\n".join([ + "[b-rule] /x/0: first b", + "[a-rule] /y: only a", + "[b-rule] /x/1: second b", + "[b-rule] /x/2: third b", + "4 violation(s) of the k contract, by v", + ]) + "\n" + result = ps.ValidationResult(False, 1, out, "", "v") + cli._report_non_conformance(Path("snapshot.json"), result) + err = capsys.readouterr().err + assert _grouped(err) == [("b-rule", 3, "/x/0: first b"), + ("a-rule", 1, "/y: only a")], err + body = err.splitlines() + at = body.index(" 3 × [b-rule] /x/0: first b") + assert body[at + 1:at + 4] == [" /x/1: second b", + " /x/2: third b", + " 1 × [a-rule] /y: only a"], err + assert err.count("[b-rule]") == 1 and err.count("[a-rule]") == 1, err + assert "4 violation(s) of 2 rule(s)" in err, err + assert err.rstrip().endswith("4 violation(s) of the k contract, by v"), err + + +def test_a_rule_broken_in_many_places_says_how_many_more(capsys) -> None: + """Past `_PLACES_SHOWN` places a rule says how many more it has, so the + report stays short and the count stays exact.""" + many = cli._PLACES_SHOWN + 7 + out = "".join(f"[c-rule] /z/{n}: broken\n" for n in range(many)) + result = ps.ValidationResult(False, 1, out, "", "v") + cli._report_non_conformance(Path("snapshot.json"), result) + err = capsys.readouterr().err + assert _grouped(err) == [("c-rule", many, "/z/0: broken")], err + shown = [line for line in err.splitlines() if line.startswith(" /z/")] + assert shown == [f" /z/{n}: broken" for n in range(1, cli._PLACES_SHOWN)], err + assert "… and 7 more of this rule" in err, err diff --git a/tests/test_run_dir_lifetime.py b/tests/test_run_dir_lifetime.py new file mode 100644 index 00000000..5baff93b --- /dev/null +++ b/tests/test_run_dir_lifetime.py @@ -0,0 +1,101 @@ +"""`generate-and-open`'s minted run directory is removed when the run is done +with it, and is named for openDox (plan 034 T084, adversarial review 2, G7). + +With no `--run-dir`, the verb mints a temporary directory, writes the snapshot +there and serves it. It used to be minted as `ideation-dashboard-*`, which is +openxFactory's pre-carve name, and it was never removed: every run left one +under the system's temporary directory. Now it is minted as +`cli.RUN_DIR_PREFIX` (`opendox-`), and it is removed however the run ends: a +served run stopped by an interrupt, a `--no-serve` run, a refusal. A +`--run-dir` the caller names is the caller's, and is kept. + +Each case is a child process, `python -m opendox.cli generate-and-open` over +the plain fixture, with its own TMPDIR so its temporary directories can be +counted. It is a HOSTED install, with a hosted install's settings given on +purpose (`Child(extra_env=)`), so no database is started: the run directory's +lifetime is the same in both install shapes. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from opendox.runtime import config as runtime_config +from standalone_child import Child, fresh_repository + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +PREFIX = runtime_config.PREFIX + +#: A hosted install's settings, as `tests/test_served_install_block.py` +#: gives them. Nothing in these cases connects to either database. +HOSTED = { + PREFIX + "INSTALL_MODE": runtime_config.INSTALL_MODE_HOSTED, + PREFIX + "DATABASE_URL": "postgresql://serve@127.0.0.1:1/opendox", + PREFIX + "MIGRATION_DATABASE_URL": "postgresql://migrate@127.0.0.1:1/opendox", + PREFIX + "OIDC_AUDIENCE": "fixture", + PREFIX + "OIDC_ISSUER": "https://issuer.example.invalid/realms/fixture", +} + +_URL = re.compile(r"^(http://([0-9.]+):([0-9]+))/index\.html$") + + +def _setup(tmp_path: Path) -> tuple[Path, Path, dict]: + repo = fresh_repository(PLAIN, tmp_path) + scratch = tmp_path / "tmp" + scratch.mkdir() + return repo, scratch, {**HOSTED, "TMPDIR": str(scratch)} + + +def _minted(scratch: Path) -> list[Path]: + return sorted(p for p in scratch.iterdir() if p.is_dir()) + + +def test_a_served_run_names_its_run_dir_for_opendox_and_removes_it_at_stop( + tmp_path) -> None: + from opendox import cli + repo, scratch, env = _setup(tmp_path) + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--port", "0", extra_env=env) + try: + child.wait_for_line(_URL) + (minted,) = _minted(scratch) + assert cli.RUN_DIR_PREFIX == "opendox-" + assert minted.name.startswith("opendox-"), minted.name + assert (minted / "snapshot.json").is_file(), "it is the run's own" + assert child.interrupt() == 0, child.stderr_text() + finally: + child.kill() + assert _minted(scratch) == [], "the minted run directory outlived the run" + assert child.refused() == [], child.refused() + + +def test_a_no_serve_run_removes_its_minted_run_dir(tmp_path) -> None: + repo, scratch, env = _setup(tmp_path) + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--no-serve", "--port", "0", extra_env=env) + try: + assert child.wait() == 0, child.stderr_text() + finally: + child.kill() + assert _minted(scratch) == [] + + +def test_a_run_dir_the_caller_names_is_kept(tmp_path) -> None: + repo, scratch, env = _setup(tmp_path) + run_dir = tmp_path / "kept" + child = Child(tmp_path, "opendox.cli", "generate-and-open", + "--repo-root", str(repo), "--repository", "fixture", + "--no-open", "--no-serve", "--port", "0", + "--run-dir", str(run_dir), extra_env=env) + try: + assert child.wait() == 0, child.stderr_text() + finally: + child.kill() + assert (run_dir / "snapshot.json").is_file() + assert _minted(scratch) == [] diff --git a/tests/test_source_core_arm.py b/tests/test_source_core_arm.py index 745f9d57..4d579781 100644 --- a/tests/test_source_core_arm.py +++ b/tests/test_source_core_arm.py @@ -35,8 +35,9 @@ So this module is the RUNNABLE half, in the shape its four neighbours on the explicit list `validate` ran until plan 034 T036 already use (`test_leg_shape.py`, `test_consumer_reach.py`, `test_web_boundary.py`, `test_view_registry.py`): it -PARSES `src/opendox/serve.py` and reads the live `opendox.consumer_reach`, and -it imports `opendox.serve` nowhere. It was written while `opendox.serve` could +PARSES `src/opendox/serve.py`, and it imports `opendox.serve` nowhere +(it read the live `opendox.consumer_reach` too, until plan 034 T084 retired +that module). It was written while `opendox.serve` could not be imported at either leg: `from ideation_dashboard import serve_openxfactory_lanes` named openxFactory's PRE-CARVE package, a `stays_openxfactory_adapter` row (RULING DQ-1) present at neither destination, @@ -51,11 +52,12 @@ 1. THE ROUTE IS DECLARED HERE, with the pre-carve strings. Two constants and three methods, defined in `serve.py` rather than forwarded — the difference between "openDox owns this route" and "openDox can reach a leg that does". -2. THE COLUMN NO LONGER CARRIES THEM. Asserted on the LIVE - `consumer_reach.LateProjectionRoutes`, as an absence: a forwarder left behind - would be invisible, because the route would go on working wherever openXdox - happens to be installed — which is every developer machine, and neither claim - this slice makes. +2. THE COLUMN NO LONGER CARRIES THEM. Asserted as an absence, on the + handler's own bases since plan 034 T084 retired `consumer_reach`'s + `LateProjectionRoutes` (it was asserted on that live stand-in before): a + forwarder left behind would be invisible, because the route would go on + working wherever openXdox happens to be installed — which is every + developer machine, and neither claim this slice makes. 3. THE ORDER IS THE ONE THE BINDINGS HAD. `collect_bindings` groups every EXACT binding ahead of every PREFIX one, so `/source` refused with a message and `/source/` (empty tail) 404'd with divergence headers and a zero-length body. @@ -99,9 +101,9 @@ `--noconftest` SAFE, deliberately, like its neighbours on the explicit list `validate` ran until plan 034 T036: -nothing here needs a fixture, a path insertion or an installed consumer, and the -one import (`opendox.consumer_reach`) is the module whose whole point is that -importing it resolves nothing. +nothing here needs a fixture, a path insertion or an installed consumer, and it +imports nothing of the package's (its one import, `opendox.consumer_reach`, +went with that module at plan 034 T084). A CREATED file: no carve-manifest row (RULED OQ-C) — it declares what a destination assembles, which the manifest never carries. @@ -194,33 +196,43 @@ def test_the_three_handlers_are_defined_on_this_handler(name): "layer that PINS openDox") -@pytest.mark.parametrize("name", SOURCE_METHODS) -def test_the_consumer_column_no_longer_forwards_them(name): - """The absence, on the LIVE seam — see this module's point 2.""" - from opendox import consumer_reach +#: `DashboardHandler`'s bases since plan 034 T084: openDox's own two route +#: mixins and the stdlib handler. The gate and projection columns' late +#: stand-ins (`consumer_reach.LateGateRoutes`, `LateProjectionRoutes`) are +#: gone with `consumer_reach`, and those columns are a host's, composed in +#: through the handler-contribution facet (R1Q1 (a)). +HANDLER_BASES = ("serve_workbench.WorkbenchRoutes", "serve_project.ProjectRoutes", + "http.server.SimpleHTTPRequestHandler") + - _module, _cls, methods = consumer_reach.LateProjectionRoutes.LATE_COLUMN - assert name not in methods, ( - f"consumer_reach.LateProjectionRoutes still forwards {name} into " - "openxdox.serve_projection. A forwarder left behind keeps the route " - "working on any machine that happens to have openXdox installed, which " - "is how a move looks complete and is not") +@pytest.mark.parametrize("name", SOURCE_METHODS) +def test_no_consumer_column_is_a_base_to_forward_them(name): + """The absence, on the handler's own bases (this module's point 2): with + no late column among them, nothing can forward `name` into openXdox's + projection column, so the route is answered here or nowhere.""" + bases = tuple(ast.unparse(base) for base in _handler_class().bases) + assert bases == HANDLER_BASES, ( + f"DashboardHandler's bases are {bases}. A late consumer column among " + f"them could forward {name} into openxdox.serve_projection, which keeps " + "the route working on any machine that happens to have openXdox " + "installed: how a move looks complete and is not") + assert _method(name) is not None SNAPSHOT_METHODS = ("_query_key", "_read_snapshot", "_serve_snapshot", "_hosted_entry_refused") -def test_the_column_keeps_only_its_own_contributed_route(): +def test_the_contributed_index_route_stays_the_columns(): """`_serve_index` stays the projection column's: its contributed - `/snapshot-index.json` binding names it. Since plan 034 T055 it is the ONE - method forwarded. The core `/snapshot.json` arm's four handlers, which S6 - left on the column, are this handler's own, because forwarded they refused - every `/snapshot.json` of a standalone server.""" - from opendox import consumer_reach - - _module, _cls, methods = consumer_reach.LateProjectionRoutes.LATE_COLUMN - assert methods == ("_serve_index",), methods + `/snapshot-index.json` binding names it, and since plan 034 T084 the + column arrives with the binding through the handler-contribution facet, + not as a base here. The core `/snapshot.json` arm's four handlers, which S6 + left on the column, are this handler's own (T055), because forwarded they + refused every `/snapshot.json` of a standalone server.""" + assert _method("_serve_index") is None, ( + "DashboardHandler defines _serve_index, which is the projection " + "column's handler for its own contributed /snapshot-index.json binding") @pytest.mark.parametrize("name", SNAPSHOT_METHODS) diff --git a/tests/test_standalone_generate_path.py b/tests/test_standalone_generate_path.py index 567484b7..aab9581e 100644 --- a/tests/test_standalone_generate_path.py +++ b/tests/test_standalone_generate_path.py @@ -152,7 +152,11 @@ def _assert_the_server_answers(base: tuple[str, int], written: Path, assert status == 200, status capabilities = json.loads(body) assert capabilities["refresh"]["binding"] == "regenerate" - assert capabilities["actions"]["refresh"] is True + # FALSE STANDALONE (plan 034 T084; #1144 4.3 as T007 batch L's addendum + # reads, RULED openxFactory#656 5920216845 item 1): the plane would + # regenerate, but `POST /actions/refresh` is a host's contributed route, + # and a standalone server carries none, so no refresh is offered. + assert capabilities["actions"]["refresh"] is False document = "notes-toolshed-inventory.md" status, kind, body = _get(base, f"/source/{document}") assert status == 200, status diff --git a/tests/test_static_content_types.py b/tests/test_static_content_types.py new file mode 100644 index 00000000..cff4a808 --- /dev/null +++ b/tests/test_static_content_types.py @@ -0,0 +1,174 @@ +"""The static bundle is served with its own content types, whatever the +host's `mimetypes` table says (plan 034 T084, the holder's addition for #1144 +10.2, "reachable in a browser from an openDox-only install"; T075's finding on +openDox-code#73). + +`SimpleHTTPRequestHandler.guess_type` reads the handler's `extensions_map` +first and the platform's `mimetypes` table only for an extension that map +lacks. A host whose table maps `.js` to `text/plain` would serve every ES +module of the console as text, and a browser refuses to run a module served +so. Windows reads its table from the registry, which is how such a host +arises. `serve.STATIC_CONTENT_TYPES` pins the bundle's extensions. + +1. With a HOSTILE table (every guess `text/plain`), every file of the bundle + is served with its pinned type, the same type a sound table gives. +2. An extension the pin does not carry still falls back to the platform's + table: the pin narrows nothing else. +3. Every extension the shipped bundle carries is pinned, so a file of a new + kind added to `src/opendox/web/` fails here until it is. The extensionless + package marker `vendor/.gitkeep` ships too, and is set aside with its + reason checked (`TYPELESS_MARKERS`): it has no type to pin. + +A CREATED FILE: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import http.client +import mimetypes +import shutil +import threading +from pathlib import Path + +import pytest + +from standalone_child import fresh_repository, run_module + +ROOT = Path(__file__).resolve().parent.parent +PLAIN = ROOT / "tests" / "fixtures" / "plain-documents" +WEB = ROOT / "src" / "opendox" / "web" + +#: The bundle's EXTENSIONLESS PACKAGE MARKERS, outside these content-type +#: checks. `vendor/.gitkeep` SHIPS: `pyproject.toml`'s package data carries +#: `web/**/.*` beside `web/**` (plan 034 T075), and +#: `tests_runtime/test_served_bundle.py` fetches it from an installed entry +#: point with every other bundle file. It is an empty file that keeps +#: `vendor/` in the tree, with no extension and so no content type to pin, +#: and no page loads it. So it is set aside here BY THAT REASON, which +#: `test_the_set_aside_markers_are_empty_and_extensionless` holds (Copilot +#: review of openDox-code#77, "previously missed": the name `UNSHIPPED` +#: claimed the wheel left it out, which T075 made untrue). +TYPELESS_MARKERS = {".gitkeep"} + + +def _bundle() -> list[Path]: + """Every file of the bundle that carries a content type: every shipped + file but the typeless markers.""" + return sorted(p for p in WEB.rglob("*") + if p.is_file() and p.name not in TYPELESS_MARKERS) + + +def _hostile(monkeypatch) -> None: + """A platform table that answers `text/plain` for everything.""" + monkeypatch.setattr(mimetypes, "guess_type", + lambda *_a, **_k: ("text/plain", None)) + + +def _content_type(base, path: str) -> tuple[int, str]: + connection = http.client.HTTPConnection(*base, timeout=30) + try: + connection.request("GET", path) + response = connection.getresponse() + response.read() + return response.status, response.getheader("Content-Type") or "" + finally: + connection.close() + + +@pytest.fixture() +def serve_dir(tmp_path): + """`serve(web_dir)`: a server over `web_dir` and the plain fixture's + snapshot, on a thread. Yields the callable; returns `(host, port)`.""" + from opendox import serve + + repo = fresh_repository(PLAIN, tmp_path) + out = tmp_path / "out" / "snapshot.json" + child, status = run_module( + tmp_path, "opendox.cli", "generate", "--repo-root", str(repo), + "--repository", "fixture", "--output", str(out), "--no-validate") + assert status == 0, child.stderr_text() + servers = [] + + def start(web_dir: Path): + httpd = serve.build_server(web_dir, out, repo, port=0) + worker = threading.Thread(target=httpd.serve_forever, daemon=True) + worker.start() + servers.append((httpd, worker)) + return httpd.server_address[:2] + + try: + yield start + finally: + for httpd, worker in servers: + httpd.shutdown() + httpd.server_close() + worker.join(timeout=10) + + +def test_the_bundle_keeps_its_types_under_a_hostile_table( + serve_dir, monkeypatch) -> None: + from opendox import serve + _hostile(monkeypatch) + assert mimetypes.guess_type("app.js")[0] == "text/plain" # it is hostile + base = serve_dir(WEB) + served = {} + for path in _bundle(): + status, ctype = _content_type(base, "/" + path.relative_to(WEB).as_posix()) + assert status == 200, path + served[path.relative_to(WEB).as_posix()] = ctype + expected = {name: serve.STATIC_CONTENT_TYPES[Path(name).suffix] + for name in served} + assert served == expected + assert served["index.html"] == "text/html" + assert {served[n] for n in served if n.endswith(".js")} == {"text/javascript"} + + +#: The one pinned extension the standard library's built-in table lacks, +#: with its registered type (RFC 8081). +NOT_BUILT_IN = {".woff2": "font/woff2"} + + +def test_the_pinned_types_are_the_standard_librarys_own() -> None: + """Pinning moves nothing on a host whose table was already right: each + pinned type is the one the standard library's BUILT-IN table gives (a + fresh `MimeTypes()`, which reads no system file and no registry).""" + from opendox import serve + built_in = mimetypes.MimeTypes() + for ext, ctype in serve.STATIC_CONTENT_TYPES.items(): + expected = NOT_BUILT_IN.get(ext) or built_in.guess_type("file" + ext)[0] + assert ctype == expected, ext + + +def test_an_extension_outside_the_pin_still_reads_the_platform_table( + serve_dir, monkeypatch, tmp_path) -> None: + web = tmp_path / "web" + shutil.copytree(WEB, web) + (web / "notes.txt").write_text("plain\n", encoding="utf-8") + _hostile(monkeypatch) + base = serve_dir(web) + assert _content_type(base, "/notes.txt") == (200, "text/plain") + monkeypatch.setattr(mimetypes, "guess_type", + lambda *_a, **_k: ("text/x-from-the-table", None)) + assert _content_type(base, "/notes.txt") == (200, "text/x-from-the-table") + assert _content_type(base, "/index.html") == (200, "text/html") + + +def test_the_set_aside_markers_are_empty_and_extensionless() -> None: + """What `TYPELESS_MARKERS` sets aside is what its reason says, and no + more: each name is present in the bundle, and every file by it is empty + and has no extension, so no file with a type escapes the checks.""" + markers = [p for p in WEB.rglob("*") + if p.is_file() and p.name in TYPELESS_MARKERS] + assert {p.name for p in markers} == TYPELESS_MARKERS, markers + for path in markers: + assert path.suffix == "", path + assert path.stat().st_size == 0, path + + +def test_every_extension_the_bundle_ships_is_pinned() -> None: + from opendox import serve + shipped = {path.suffix for path in _bundle()} + assert shipped, "the bundle is empty" + assert shipped <= set(serve.STATIC_CONTENT_TYPES), ( + f"the bundle ships {sorted(shipped - set(serve.STATIC_CONTENT_TYPES))}, " + "which serve.STATIC_CONTENT_TYPES does not pin")