diff --git a/pyproject.toml b/pyproject.toml index 10a82163..880ab584 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -238,6 +238,18 @@ where = ["src"] # what that census counts rather than whatever happens to be on disk. [tool.setuptools.package-data] opendox = ["web/**"] +# THE VALIDATOR'S PACKAGED COPIES (plan 034 T057; #1144 7.1, as T007's batch G +# amends it; R1Q12 (a), openxFactory#656 comment 5850003126). openDox's own +# validator reads its spec leg's four schemas from `opendox/contracts/`, and +# 7.1 settles that they travel as PACKAGE DATA, "so `pip install openDox-code` +# puts them on disk beside the validator". Without this line a wheel ships the +# package's `__init__.py` and no record and no copy, and every installed +# validator refuses, naming the absent file. The two patterns name the record +# and the schema copies and nothing else, so no other file under the directory +# ships as data. The bundle's line above stays as it is: +# `test_gate_loop_contributed` holds it verbatim, and +# `tests/test_validator_input_set.py` holds this one to the record. +"opendox.contracts" = ["copies.yaml", "schemas/*.schema.yaml"] [tool.pytest.ini_options] # The ROOTDIR ANCHOR. With no pytest ini table anywhere, pytest infers a diff --git a/src/opendox/contracts/__init__.py b/src/opendox/contracts/__init__.py new file mode 100644 index 00000000..823e7a30 --- /dev/null +++ b/src/opendox/contracts/__init__.py @@ -0,0 +1,251 @@ +"""THE PACKAGED COPIES of openDox's spec-leg contracts, and their identity. + +WHY THIS PACKAGE EXISTS. Plan 034's T057 realizes #1144's 7.1, which T007's +batch G amends on R1Q11 (a) and R1Q12 (a) (`openxFactory#656` comment +`5850003126`): *"openDox's validator validates its spec leg's FOUR kinds ... +The code leg carries digest-checked copies of the four, which a test holds to +the spec-leg commit the openDox root pins."* 7.1 also settles how the four +reach an install. They ship as PACKAGE DATA, *"so `pip install openDox-code` +puts them on disk beside the validator and the assembly root remains their +source of truth for editing"*. So a code-leg checkout with no assembly root +around it still has them, and so does an install. `opendox.validator` reads +them from here, and T085's doxBench validators will read the same copies. + +WHAT IS HERE. + +* `schemas/`: the four copies. Each one is byte for byte the spec leg's file + of the same name, `contracts/schemas/.schema.yaml` in + opensoft/openDox-spec. +* `copies.yaml`: the record. It names the spec-leg commit the copies were taken + at, and each copy's id, spec-leg path and sha256. + +PRESENCE IS NOT IDENTITY. A copy is read only through `verified_bytes()`. It +recomputes the copy's sha256 and compares it with the record BEFORE a byte of +the copy is parsed, and it refuses, with `CopyRefused`, a copy that differs +from its digest, a copy that is absent, and a copy whose digest the record +leaves empty. That is `neutral-product-pin`'s rule for a vendored contract +(*"A vendored foreign contract is digest-verified before it is read"*): a +recomputed digest can never equal an empty recorded one, so an empty digest is +drift, and a file that merely exists proves nothing. + +CONSUMED, NOT OWNED. openDox-spec owns these four schemas. The code leg +releases none of them, and a copy is changed in the spec leg and then copied +here again, never edited here. The copies, the record, and the `commit` it +names move together, in one commit. + +IMPORT WEIGHT. The standard library, and PyYAML (the package's one runtime +dependency) when the record is read. Importing this package reads no file and +names no sibling. + +A CREATED FILE: it has no row in openxFactory's +`docs/opendox-carve-manifest.yaml`, because the manifest declares what LEAVES +openxFactory and never what a destination assembles (RULED OQ-C). +""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from importlib import resources +from pathlib import PurePosixPath +from typing import Any + +__all__ = [ + "COPY_IDS", + "COPY_KIND", + "CopyRefused", + "PackagedCopy", + "Record", + "RECORD_NAME", + "SPEC_LEG", + "load", + "record", + "verified_bytes", +] + +#: The record's file name, beside this module. +RECORD_NAME = "copies.yaml" + +#: The record's `kind`. +COPY_KIND = "packaged-contract-copies" + +#: The repository every copy comes from: openDox's own spec leg. +SPEC_LEG = "opensoft/openDox-spec" + +#: Where a copy sits under this package: `schemas/`. +SCHEMA_DIR = "schemas" + +#: THE INPUT SET, as a record must hold it: openDox's own spec leg's four +#: schemas (7.1, as T007's batch G amends it). A record that names any other +#: copy, or leaves one of these out, is refused, like a record naming another +#: leg. So no edit to the record lets a copy of `gate-intent` or of the +#: possibles register (7.1b) be served, even with its file beside the others. +COPY_IDS = frozenset({"ideation-workbench", "opendox-snapshot", + "xfactory-workbench-chat-turn", "xfactory-workbench-model-catalog"}) + +_ID = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") +_COMMIT = re.compile(r"[0-9a-f]{40}") +_SHA256 = re.compile(r"[0-9a-f]{64}") + + +class CopyRefused(RuntimeError): + """A packaged copy, or the record that pins the copies, cannot be trusted. + + Raised before any byte of the copy is parsed. The message names the copy, + what was found, and the one remedy: copy the spec leg's file again at the + recorded commit, and record its digest in the same commit.""" + + +@dataclass(frozen=True) +class PackagedCopy: + """One copy, as the record declares it.""" + + id: str + path: str # its path in the spec leg: contracts/schemas/.schema.yaml + sha256: str # its digest at the recorded commit + + @property + def resource(self) -> str: + """Where the copy sits under this package.""" + return f"{SCHEMA_DIR}/{PurePosixPath(self.path).name}" + + +@dataclass(frozen=True) +class Record: + """`copies.yaml`, read and checked.""" + + spec_leg: str + commit: str + copies: tuple[PackagedCopy, ...] + + @property + def ids(self) -> tuple[str, ...]: + return tuple(copy.id for copy in self.copies) + + def copy(self, copy_id: str) -> PackagedCopy: + for copy in self.copies: + if copy.id == copy_id: + return copy + raise CopyRefused( + f"{copy_id!r} is not one of the packaged copies {list(self.ids)} " + f"({RECORD_NAME} records no such copy, so none is read)") + + +def _refuse(detail: str) -> CopyRefused: + return CopyRefused( + f"{RECORD_NAME} cannot be trusted: {detail}. The record is written " + f"with the copies it pins, in one commit, from {SPEC_LEG} at the " + "commit the openDox root pins") + + +def _read_package_file(name: str) -> bytes: + """`name`'s bytes from the package, or `CopyRefused`. + + EVERY `OSError` IS A REFUSAL (the T058 writer's measurement, from Copilot + at openDox-code#68, r4139734412). A file that is absent is refused as + missing. One that is present and cannot be read, such as a record at mode + 000, raised `PermissionError` straight out of `validator_for()`, so + `generate --strict` ended in a traceback where it owes a refusal.""" + try: + return resources.files(__name__).joinpath(name).read_bytes() + except (FileNotFoundError, IsADirectoryError, NotADirectoryError) as exc: + raise CopyRefused( + f"opendox.contracts has no {name}: the package was built or " + f"installed without it ({type(exc).__name__})") from exc + except OSError as exc: + raise CopyRefused( + f"opendox.contracts has {name}, and it cannot be read " + f"({type(exc).__name__}: {exc.strerror or exc})") from exc + + +def record() -> Record: + """The record, read from the package and checked field by field. + + Refuses, with `CopyRefused`, a record it cannot hold every copy to: a + missing or malformed field, an unknown field, a repeated id, a path that + is not the id's schema path in the spec leg, and an empty or malformed + digest.""" + import yaml + + try: + data = yaml.safe_load(_read_package_file(RECORD_NAME)) + except (yaml.YAMLError, RecursionError, ValueError) as exc: + # RecursionError: YAML nested past Python's limit, which no read ends. + # ValueError: a literal PyYAML cannot construct (an integer past + # Python's 4300 digits, or an impossible date). + raise _refuse(f"it is not YAML this module can read " + f"({exc.__class__.__name__})") from exc + if not isinstance(data, dict): + raise _refuse(f"it is a {type(data).__name__}, not a mapping") + expected = {"schema_version", "kind", "spec_leg", "commit", "copies"} + if set(data) != expected: + # A YAML key need not be text, so the keys are ordered by their repr. + raise _refuse(f"its keys are {sorted(data, key=repr)}, not {sorted(expected)}") + if data["schema_version"] != 1 or isinstance(data["schema_version"], bool): + raise _refuse(f"schema_version is {data['schema_version']!r}, not 1") + if data["kind"] != COPY_KIND: + raise _refuse(f"kind is {data['kind']!r}, not {COPY_KIND!r}") + if data["spec_leg"] != SPEC_LEG: + raise _refuse(f"spec_leg is {data['spec_leg']!r}, not {SPEC_LEG!r}") + commit = data["commit"] + if not isinstance(commit, str) or not _COMMIT.fullmatch(commit): + raise _refuse(f"commit is {commit!r}, not a full 40-hex commit id") + entries = data["copies"] + if not isinstance(entries, list) or not entries: + raise _refuse("copies is not a non-empty list") + copies: list[PackagedCopy] = [] + for index, entry in enumerate(entries): + where = f"copies[{index}]" + if not isinstance(entry, dict) or set(entry) != {"id", "path", "sha256"}: + raise _refuse(f"{where} is not a mapping of exactly id, path and sha256") + copy_id, path, digest = entry["id"], entry["path"], entry["sha256"] + if not isinstance(copy_id, str) or not _ID.fullmatch(copy_id): + raise _refuse(f"{where}.id is {copy_id!r}, not a lowercase hyphenated id") + if path != f"contracts/schemas/{copy_id}.schema.yaml": + raise _refuse(f"{where}.path is {path!r}, not " + f"'contracts/schemas/{copy_id}.schema.yaml'") + if not isinstance(digest, str) or not _SHA256.fullmatch(digest): + # An EMPTY digest lands here too: it is drift, never a pass. + raise _refuse(f"{where}.sha256 is {digest!r}, not a 64-hex sha256") + if any(copy.id == copy_id for copy in copies): + raise _refuse(f"{where}.id {copy_id!r} is recorded twice") + copies.append(PackagedCopy(copy_id, path, digest)) + recorded = {copy.id for copy in copies} + if recorded != COPY_IDS: + raise _refuse(f"it records {sorted(recorded)}, not openDox's four, " + f"{sorted(COPY_IDS)} (7.1; 7.1b keeps every other schema out)") + return Record(SPEC_LEG, commit, tuple(copies)) + + +def verified_bytes(copy_id: str) -> bytes: + """The copy's bytes, once they are proved to be the recorded ones. + + Reads the copy, recomputes its sha256, and refuses with `CopyRefused` + unless it equals the record's digest. Nothing is parsed before that + comparison, so a caller never reads a copy whose identity is unproved.""" + pins = record() + pinned = pins.copy(copy_id) + data = _read_package_file(pinned.resource) + actual = hashlib.sha256(data).hexdigest() + if actual != pinned.sha256: + raise CopyRefused( + f"the packaged copy of {copy_id} ({pinned.resource}) is not the " + f"file the record pins: its sha256 is {actual}, and {RECORD_NAME} " + f"records {pinned.sha256} for {pinned.path} in {SPEC_LEG} at " + f"{pins.commit}. A copy is never edited in place: copy the spec " + "leg's file again, and record its digest in the same commit") + return data + + +def load(copy_id: str) -> Any: + """The copy, parsed, after `verified_bytes()` has proved its identity.""" + import yaml + + data = verified_bytes(copy_id) + try: + return yaml.safe_load(data) + except (yaml.YAMLError, RecursionError, ValueError) as exc: + raise CopyRefused( + f"the packaged copy of {copy_id} matches its digest but is not " + f"YAML ({exc.__class__.__name__})") from exc diff --git a/src/opendox/contracts/copies.yaml b/src/opendox/contracts/copies.yaml new file mode 100644 index 00000000..cff23db1 --- /dev/null +++ b/src/opendox/contracts/copies.yaml @@ -0,0 +1,45 @@ +# THE RECORD of openDox's packaged contract copies (plan 034 T057; #1144 7.1, +# as T007's batch G amends it; R1Q12 (a), opensoft/openxFactory#656 comment +# 5850003126). +# +# WHAT IT SAYS. Each schema under `schemas/` beside this file is, byte for byte, +# the file its `path` names in `spec_leg` at `commit`, and `sha256` is that +# file's digest. `opendox.contracts.verified_bytes` recomputes the digest before +# a copy is read, and refuses a copy that differs, a copy that is absent, and a +# copy whose digest is left empty. +# +# WHERE THE VALUES CAME FROM, read with `git show : | sha256sum` +# in opensoft/openDox-spec. +# +# * `commit` is the spec-leg commit the openDox root pins: T053 as landed, +# openDox-spec#16's squash f7ee3c76, which the root's `contracts/spec-pin.yaml` +# and `spec` gitlink name since opensoft/openDox#14 (dox-v1.1). It is the one +# commit that carries all four files. +# * Each digest is the one the root's `contracts/manifest.yaml` records for that +# file at that commit. Three of the four files are unchanged from the root's +# previous spec pin, 8fe8c4c7. The fourth is T053's `opendox-snapshot`. +# +# `commit` moved here from #16's head, cd49eb25, in lockstep with the root's +# spec pin. f7ee3c76 has cd49eb25's tree, so no digest moved. A digest moves +# only when its file changes. +# +# NEVER EDIT A COPY OR A DIGEST IN PLACE. A copy changes in the spec leg. It +# arrives here when the spec leg's file is copied at the pinned commit and its +# digest recorded, in one commit. +schema_version: 1 +kind: packaged-contract-copies +spec_leg: opensoft/openDox-spec +commit: "f7ee3c763b3af4581daf1cd54406e5111e9358e6" +copies: + - id: ideation-workbench + path: contracts/schemas/ideation-workbench.schema.yaml + sha256: "d30438491119c20928fbe4e85088fc33682829eeb6558d87dafce651000faafc" + - id: opendox-snapshot + path: contracts/schemas/opendox-snapshot.schema.yaml + sha256: "f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a" + - id: xfactory-workbench-chat-turn + path: contracts/schemas/xfactory-workbench-chat-turn.schema.yaml + sha256: "350bfedc02696e7281a42c0bdc9a25059bf7af14d16d89d9f07018d3e691dc1d" + - id: xfactory-workbench-model-catalog + path: contracts/schemas/xfactory-workbench-model-catalog.schema.yaml + sha256: "e563cc9fc6ede03dfd62537935d0ae0842617d7de46702aee6ad9026aa021635" diff --git a/src/opendox/contracts/schemas/ideation-workbench.schema.yaml b/src/opendox/contracts/schemas/ideation-workbench.schema.yaml new file mode 100644 index 00000000..219c760f --- /dev/null +++ b/src/opendox/contracts/schemas/ideation-workbench.schema.yaml @@ -0,0 +1,227 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "ideation-workbench.schema.yaml" +title: "Ideation-workbench user-assembled reference-set manifest" +contract_schema_version: 1 +description: >- + A user-assembled temporary reference set for the ideation-area dashboard + (`add-ideation-dashboard`; promoted spec requirements "Workbench reference + sets" and "Keyword lens set-builder"). A set is seeded from a cluster card, + ad-hoc from the doc list, or from a saved keyword-lens recipe, and supports + bounded actions — scratch NotebookLM notebook creation, on-demand readiness + scoring, scoped doc-health, and draft-organize — each recorded in + `action_history`. Saved sets persist as gitignored schema-versioned + manifests under `ideation/workbench/` in the owning repository; + `ideation-workbench` manifests found in TRACKED repository content are + invalid (spec scenario "A workbench manifest is committed") — this schema + cannot express that rule structurally (a manifest is well-formed regardless + of where it sits), so enforcement is validator-side, checking the manifest's + path against the repo's tracked-file list, not this document's shape. + + Not deterministic: unlike `ideation-dashboard-snapshot` (a generated + projection required to be byte-identical for an unchanged tree), a workbench + manifest is live human session state — `created`/`updated` are ordinary + wall-clock timestamps written by whatever workbench action last touched the + set, with no source-revision anchor and no byte-identity requirement. + + Manual membership overrides are evidence, never a silent set edit: a + `manual-include` member and every `excluded` entry MUST carry a recorded + `reason` (spec scenario "A manual override is recorded"); an override without + one is invalid (enforced below via `allOf` and in the array item shape). + + Forward-compatible / additive: consumers MUST ignore unknown properties. A + later delta may add new action kinds, seed kinds, or recipe fields without a + `schema_version` bump; only a breaking change (a removed or retyped field) + requires one. This is why no object here sets `additionalProperties: false`. +type: object +required: + - schema_version + - kind + - repository + - name + - created + - updated + - members + - seed +properties: + schema_version: {const: 1} + # Normative kind literal from the promoted spec ("Workbench reference sets"); + # hyphenated per that requirement, not the underscored `xfactory_*` in-file + # kind convention (same divergence as the sibling snapshot schema). + kind: {const: ideation-workbench} + repository: + type: string + minLength: 1 + # Owning repository (`ideation/workbench/` is per-repo); mirrors the + # snapshot's `repository` field for cross-reference. + name: + type: string + minLength: 1 + # The set's human name, chosen at creation; not a stable id. + created: + type: string + format: date-time + # Wall-clock creation stamp — session state, not a determinism anchor. + updated: + type: string + format: date-time + # Wall-clock stamp of the most recent workbench action against this set. + members: + type: array + items: {$ref: "#/$defs/member"} + excluded: + type: array + # Recipe-matching docs the human removed from the forming/saved set — each + # entry is negative evidence and REQUIRES a reason (spec scenario "A manual + # override is recorded"); absent/empty means no exclusions recorded. + items: {$ref: "#/$defs/excluded_entry"} + seed: {$ref: "#/$defs/seed"} + recipe: + # D13 keyword-lens query behind an intensional set (spec: "Keyword lens + # set-builder" — "cluster-as-recipe persistence"). Optional for sets seeded + # any other way (a manually built set may later have a recipe saved against + # it), but REQUIRED when `seed.kind` is `recipe` (allOf below) — a + # recipe-seeded set without its stored query would be neither re-runnable + # nor auditable (task 2.7). + $ref: "#/$defs/recipe" + action_history: + type: array + items: {$ref: "#/$defs/action_history_entry"} + notebook: {$ref: "#/$defs/notebook_binding"} +allOf: + # seed.kind = cluster-seeded names the seeding cluster (task 2.2). + - if: + properties: {seed: {properties: {kind: {const: cluster-seeded}}}} + required: [seed] + then: + properties: {seed: {required: [cluster_id]}} + # seed.kind = recipe stores the seeding query: the manifest MUST carry the + # recipe block so the set stays re-runnable and auditable (task 2.7; spec: + # "the workbench manifest stores the query ... so the set re-runs as the + # corpus grows"). Cross-property (seed ↔ recipe), so it lives here at the + # top level, not inside a $def. + - if: + properties: {seed: {properties: {kind: {const: recipe}}}} + required: [seed] + then: + required: [recipe] +$defs: + # --- membership ("Workbench reference sets" / "Keyword lens set-builder") --- + member: + type: object + required: [document, via] + properties: + document: + type: string + minLength: 1 + # Doc reference: the snapshot's `document.id` when the doc already has + # one, else a repo-relative path for docs not yet captured in a + # snapshot. Distinguishes from `via` below, which records HOW the + # member entered this set, not WHICH document it is. + via: + type: string + enum: [recipe-match, manual-include, cluster-seed] + # Per-member entry mechanism — distinct from the set-level `seed.kind` + # below (a `cluster-seed` set can still gain `manual-include` members). + reason: + type: string + minLength: 1 + # REQUIRED when via=manual-include (allOf below); the recorded "why" + # behind a human override — never a silent set edit. + allOf: + - if: {properties: {via: {const: manual-include}}} + then: {required: [reason]} + excluded_entry: + type: object + required: [document, reason] + properties: + document: {type: string, minLength: 1} # same reference convention as member.document + reason: + type: string + minLength: 1 + # ALWAYS required — negative evidence for a recipe-matching doc the + # human removed (spec: overrides are captured evidence, never silent). + # --- seed provenance ("Workbench reference sets") --- + seed: + type: object + required: [kind] + properties: + kind: + type: string + enum: [cluster-seeded, ad-hoc, recipe] + # How the WHOLE set originated: from a cluster card, ad-hoc from the + # doc list, or from a saved keyword-lens recipe ("add as cluster"). + cluster_id: + type: string + minLength: 1 + # REQUIRED when kind=cluster-seeded (allOf above); the seeding + # cluster's id from the dashboard snapshot. + # --- recipe (D13; "Keyword lens set-builder" — cluster-as-recipe persistence) --- + recipe: + type: object + required: [checked] + properties: + checked: + type: array + minItems: 1 + items: {type: string, minLength: 1} + # Checked keywords stratifying the forming set (check-to-stratify); + # at least one — a zero-keyword query is not an intensional definition. + pinned: + type: array + items: {type: string, minLength: 1} + # Pinned (required) keywords (pin-to-require). VALIDATOR RULE (task + # 2.4/2.7 examples), not expressible cleanly here: every pinned + # keyword MUST also appear in `checked` — this schema does not encode + # the subset constraint. + last_run: + type: object + required: [source_revision] + properties: + source_revision: + type: string + minLength: 1 + # Snapshot `generation.source_revision` this recipe was last + # evaluated against — the re-run anchor (spec scenario "A recipe + # re-runs after corpus growth"). + at: {type: string, format: date-time} # wall-clock stamp of that evaluation + new_candidates: + type: array + items: {type: string, minLength: 1} + # Docs newly matching the recipe since last_run, surfaced without + # altering recorded overrides; cleared (emptied) once reviewed. + # --- action history ("Workbench reference sets") --- + action_history_entry: + type: object + required: [action, at] + properties: + action: + type: string + enum: [notebook, readiness, doc-health, draft-organize, compose-possible, derive-possibles, add-as-cluster] + # Bounded workbench actions; `compose-possible`/`derive-possibles` are + # recorded here once their gating deltas land (tasks.md 3.9) — the + # vocabulary is reserved now so this schema does not need a bump then. + # `add-as-cluster` (added additively, codexFactory 002-ideation-dashboard + # T024/polish): the lens "add as cluster" action — saves the + # recipe-seeded workbench set and records this honest action name, + # with the human-seen cross-reference-queue submission itself still + # PENDING the sibling contract (change task 3.5's `pending_review` + # disposition, tracked as codexFactory T028, not yet landed). + at: {type: string, format: date-time} + reference: + type: string + minLength: 1 + # Optional job/artifact ref produced by the action (e.g. a doc-health + # run id, a readiness score ref, the draft-organize skeleton path). + # --- scratch-notebook binding ("Workbench reference sets") --- + notebook_binding: + type: object + required: [alias] + properties: + alias: + type: string + pattern: "^xf-wb-.+$" + # Scratch NotebookLM notebook bound to this set. A derived artifact: + # deleted by the sync orphan sweep when this manifest is deleted + # (spec scenario "A workbench manifest is deleted"), never the + # reverse — removing a source from the notebook only drops it from + # `members` and leaves the corpus document untouched. diff --git a/src/opendox/contracts/schemas/opendox-snapshot.schema.yaml b/src/opendox/contracts/schemas/opendox-snapshot.schema.yaml new file mode 100644 index 00000000..2be5018f --- /dev/null +++ b/src/opendox/contracts/schemas/opendox-snapshot.schema.yaml @@ -0,0 +1,443 @@ +# openDox's own neutral snapshot contract (plan 034, T053). +# +# WHY THIS FILE IS JSON. It is YAML whose body is one JSON object, and that is +# deliberate. The leg's required `validate` check installs pytest and nothing +# else, so `tests/test_opendox_snapshot_contract.py` reads this file with +# Python's built-in `json` module once these comment lines are set aside. +# JSON is YAML, so every YAML loader in the family reads the same object, and +# the same test proves that PyYAML agrees wherever PyYAML is installed. This +# leg's negative chat-turn examples already take this form. +# +# SECTIONS. openDox's views render six stations from five sections, as +# openDox's own STAGE_FIELDS declares them: source reads documents, grouping +# reads clusters, candidate reads possibles, selection reads staged_topics, +# and the submission and completion stations read the changes entries whose +# status is active and archived. Every station section is required, and it +# is an empty list when its station holds nothing. keyword_index is optional: +# without it, a reader derives the keyword rail from the documents' topics. +# +# CLOSED VALUES, all neutral. A document's stage is one of the six station +# role keys, a candidate's state is one of unselected, selected, declined and +# replaced, and a changes entry's status is active or archived. openDox's +# views match them through SNAPSHOT_VALUES, whose defaults T054 moves to +# these values. +# +# DETERMINISTIC, which is the generator's to keep and no schema can check: the +# same tree yields a byte-identical snapshot, so nothing in it records when +# the generator ran. generated_at, when present, is fixed by the source +# revision (its commit date, or a date recorded with it), never read from the +# clock. FORWARD-COMPATIBLE. A reader ignores unknown properties, so no object +# sets additionalProperties false, and an additive field needs no +# schema_version bump. +# +# RULES. Every rule has an identifier. A subschema names, in x-rule, the rule +# its keywords enforce; x-rules lists every rule with its class; and a +# validator's refusal names the rule it broke. A shape rule is enforced by +# this schema's own keywords. A reference rule is a cross-reference that no +# JSON Schema keyword can state, so a validator enforces it. +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "opendox-snapshot.schema.yaml", + "title": "openDox neutral snapshot", + "contract_schema_version": 1, + "description": "openDox's own snapshot: what its neutral generator writes over a plain repository, and what its views read, with no consumer installed (plan 034 T053, on R1Q11 (a) and R1Q12 (a): opensoft/openxFactory issue 656, comment 5850003126). openXdox's governed generator keeps its own contract, openXdox-spec's ideation-dashboard-snapshot, which this schema leaves unchanged; a contributed generator declares which of the two kinds it writes (T052). The file's comment header, the section descriptions and the x-rules catalog say the rest.", + "x-rule": "envelope-keys", + "type": "object", + "required": [ + "schema_version", + "kind", + "repository", + "generation", + "documents", + "clusters", + "possibles", + "staged_topics", + "changes" + ], + "properties": { + "schema_version": {"x-rule": "schema-version-is-1", "const": 1}, + "kind": {"x-rule": "kind-is-opendox-snapshot", "const": "opendox-snapshot"}, + "repository": {"x-rule": "repository-is-text", "type": "string", "minLength": 1}, + "generation": {"$ref": "#/$defs/generation"}, + "documents": { + "description": "The source station, and the product's whole document list: every document the corpus adapter lists, in the station its stage names. Every entry carries its stage, and no reader supplies one: the generator writes source for a document that declares no stage.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/document"} + }, + "clusters": { + "description": "The grouping station: the groups that form around topics documents share, each with one edge per member document.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/group"} + }, + "possibles": { + "description": "The candidate station.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/candidate"} + }, + "staged_topics": { + "description": "The selection station.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/selection"} + }, + "changes": { + "description": "The submission station (status active) and the completion station (status archived), which share this one section.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/submission"} + }, + "keyword_index": { + "description": "Optional. The keyword rail's seed. When present, every topic the documents carry has one entry, and each entry counts the documents that carry its keyword.", + "x-rule": "section-is-a-list", + "type": "array", + "items": {"$ref": "#/$defs/keyword_entry"} + } + }, + "$defs": { + "id": {"x-rule": "id-is-text", "type": "string", "minLength": 1}, + "path": { + "description": "A path relative to the repository root, as the corpus adapter lists it.", + "x-rule": "path-is-repo-relative", + "type": "string", + "pattern": "^(?!/)(?![A-Za-z]:)(?![\\s\\S]*[\\\\\\u0000-\\u001f\\u007f-\\u009f])(?!(?:[\\s\\S]*/)?\\.\\.(?:/|$))[\\s\\S]+$" + }, + "topic": { + "x-rule": "topic-is-trimmed-text", + "type": "string", + "pattern": "^(?![\\s\\ufeff])(?![\\s\\S]*[\\s\\ufeff]$)[^\\u0000-\\u001f\\u007f-\\u009f]+$" + }, + "stage_role": { + "description": "The station a document sits in: one of the six station role keys, in spine order, exactly openDox's display_profile.STAGE_ROLES. The set is closed. A declared value outside it is not a declaration: the generator reads that document as a source and writes stage source, so the value never reaches a snapshot.", + "x-rule": "stage-is-a-station-role", + "enum": ["source", "grouping", "candidate", "selection", "submission", "completion"] + }, + "candidate_state": { + "description": "A candidate's state, in openDox's own words for the candidate station (NEUTRAL_DISPLAY's candidate vocabulary). A candidate is unselected until an act selects, declines or replaces it.", + "x-rule": "candidate-state-is-known", + "enum": ["unselected", "selected", "declined", "replaced"] + }, + "submission_status": { + "description": "The station a changes entry sits in: active for submission, archived for completion, as openDox's STAGE_FIELDS declares them.", + "x-rule": "submission-status-is-known", + "enum": ["active", "archived"] + }, + "generation": { + "description": "The generation stamp. source_revision is the determinism anchor: the tree revision the snapshot projects.", + "x-rule": "generation-anchored", + "type": "object", + "required": ["source_revision"], + "properties": { + "source_revision": {"x-rule": "generation-anchored", "type": "string", "minLength": 1}, + "generated_at": { + "x-rule": "generated-at-is-rfc3339", + "type": "string", + "format": "date-time", + "pattern": "^(?![\\s\\S]*[\\u0000-\\u001f\\u007f-\\u009f])(?!0000)(?:[0-9]{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12][0-9]|3[01])|(?:0[469]|11)-(?:0[1-9]|[12][0-9]|30)|02-(?:0[1-9]|1[0-9]|2[0-8]))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|(?:[02468][048]|[13579][26])00)-02-29)[Tt](?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\\.[0-9]+)?(?:[Zz]|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$" + }, + "generator_version": {"x-rule": "generation-anchored", "type": "string", "minLength": 1} + } + }, + "document": { + "description": "One document the corpus adapter lists. title and summary are the default adapter's small neutral field set (R1Q13 (a)). Neither is required, and the generator writes null for one the document does not give. topics, which every entry carries, are the topics the generator assigns: the ones the document declares, or the ones its topic rule derives when it declares none, and an empty list when there are none.", + "x-rule": "document-keys", + "type": "object", + "required": ["id", "path", "stage", "topics"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "path": {"$ref": "#/$defs/path"}, + "stage": {"$ref": "#/$defs/stage_role"}, + "title": { + "x-rule": "title-and-summary-are-text", + "type": ["string", "null"], + "minLength": 1 + }, + "summary": { + "x-rule": "title-and-summary-are-text", + "type": ["string", "null"], + "minLength": 1 + }, + "topics": { + "x-rule": "topics-are-unique", + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/topic"} + } + } + }, + "group": { + "description": "One group in the grouping station. document_edges holds one edge per member document, naming the topics that matched; the funnel draws them.", + "x-rule": "group-keys", + "type": "object", + "required": ["id", "name", "topics", "document_edges"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "name": {"x-rule": "group-keys", "type": "string", "minLength": 1}, + "topics": { + "x-rule": "group-has-a-topic", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/topic"} + }, + "document_edges": { + "x-rule": "group-keys", + "type": "array", + "items": {"$ref": "#/$defs/edge"} + } + } + }, + "edge": { + "x-rule": "edge-keys", + "type": "object", + "required": ["document", "matched_topics"], + "properties": { + "document": {"$ref": "#/$defs/id"}, + "matched_topics": { + "x-rule": "edge-keys", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": {"$ref": "#/$defs/topic"} + } + } + }, + "candidate": { + "description": "One candidate in the candidate station. claiming_clusters holds the groups that claim it; pick holds the selection a selected candidate went to.", + "x-rule": "candidate-keys", + "type": "object", + "required": ["id", "title", "state"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "title": {"x-rule": "candidate-keys", "type": "string", "minLength": 1}, + "claim": {"x-rule": "candidate-keys", "type": "string", "minLength": 1}, + "state": {"$ref": "#/$defs/candidate_state"}, + "claiming_clusters": { + "x-rule": "candidate-keys", + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/id"} + }, + "pick": { + "x-rule": "selected-candidate-has-pick", + "type": "object", + "required": ["staging_id"], + "properties": {"staging_id": {"$ref": "#/$defs/id"}} + }, + "reason": {"x-rule": "closed-candidate-has-reason", "type": "string", "minLength": 1} + }, + "allOf": [ + { + "if": {"required": ["state"], "properties": {"state": {"const": "selected"}}}, + "then": {"x-rule": "selected-candidate-has-pick", "required": ["pick"]} + }, + { + "if": {"required": ["state"], "properties": {"state": {"enum": ["declined", "replaced"]}}}, + "then": {"x-rule": "closed-candidate-has-reason", "required": ["reason"]} + } + ] + }, + "selection": { + "description": "One selection in the selection station. files lists what it is made of, and target_change names the changes entry it went on to.", + "x-rule": "selection-keys", + "type": "object", + "required": ["staging_id"], + "properties": { + "staging_id": {"$ref": "#/$defs/id"}, + "files": { + "x-rule": "selection-keys", + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/path"} + }, + "target_change": {"$ref": "#/$defs/id"} + } + }, + "submission": { + "description": "One entry of the submission or the completion station. files lists what it is made of, as a selection's files do, and the tile opens onto them.", + "x-rule": "submission-keys", + "type": "object", + "required": ["id", "status"], + "properties": { + "id": {"$ref": "#/$defs/id"}, + "status": {"$ref": "#/$defs/submission_status"}, + "files": { + "x-rule": "submission-keys", + "type": "array", + "uniqueItems": true, + "items": {"$ref": "#/$defs/path"} + } + } + }, + "keyword_entry": { + "description": "declared_doc_count counts the documents whose topics carry the keyword.", + "x-rule": "keyword-entry-keys", + "type": "object", + "required": ["keyword", "declared_doc_count"], + "properties": { + "keyword": {"$ref": "#/$defs/topic"}, + "declared_doc_count": {"x-rule": "keyword-entry-keys", "type": "integer", "minimum": 0} + } + } + }, + "x-rules": [ + { + "id": "envelope-keys", + "class": "shape", + "says": "A snapshot is an object carrying schema_version, kind, repository, generation and the five station sections: documents, clusters, possibles, staged_topics and changes." + }, + {"id": "schema-version-is-1", "class": "shape", "says": "schema_version is 1."}, + { + "id": "kind-is-opendox-snapshot", + "class": "shape", + "says": "kind is opendox-snapshot. The governed generator's ideation-dashboard-snapshot is a different contract, and this schema refuses it." + }, + { + "id": "repository-is-text", + "class": "shape", + "says": "repository, the canonical id of the repository the snapshot projects, is non-empty text." + }, + { + "id": "generation-anchored", + "class": "shape", + "says": "generation is an object carrying source_revision, the revision of the tree the snapshot projects, as non-empty text; generator_version, when present, is non-empty text too." + }, + { + "id": "generated-at-is-rfc3339", + "class": "shape", + "says": "generation.generated_at, when present, is an RFC 3339 date-time on a day the calendar has (the pattern knows each month's length and the leap years), with no control character. It is narrower than RFC 3339 in two places. Its year is never 0000, which Python's datetime cannot hold. Its seconds run from 00 to 59 and are never a leap second's 60, which a git commit date cannot hold and neither Python's datetime nor a browser's Date can read. jsonschema's date-time checker refuses both. It is fixed by the source revision, never read from the clock." + }, + { + "id": "section-is-a-list", + "class": "shape", + "says": "Each section is a list: documents, clusters, possibles, staged_topics, changes and, when present, keyword_index. A station with nothing in it is an empty list." + }, + { + "id": "id-is-text", + "class": "shape", + "says": "Every entry id, and every reference to one, is non-empty text." + }, + { + "id": "document-keys", + "class": "shape", + "says": "A document is an object carrying id, path, stage and topics." + }, + { + "id": "path-is-repo-relative", + "class": "shape", + "says": "A path is relative to the repository root: it does not start with a slash or with a drive letter and a colon (C:/x or C:x, which Windows joins onto a root as a path outside it), holds no backslash and no control character (U+0000 to U+001F, U+007F to U+009F), and has no .. segment." + }, + { + "id": "stage-is-a-station-role", + "class": "shape", + "says": "A document's stage is one of the six station role keys: source, grouping, candidate, selection, submission, completion." + }, + { + "id": "title-and-summary-are-text", + "class": "shape", + "says": "A document's title and summary are each non-empty text, or null." + }, + { + "id": "topics-are-unique", + "class": "shape", + "says": "A document's topics are a list that names each topic once." + }, + { + "id": "topic-is-trimmed-text", + "class": "shape", + "says": "A topic is non-empty text with no leading or trailing whitespace and no control character (U+0000 to U+001F, U+007F to U+009F). Whitespace is what both Python and a browser count as whitespace, U+FEFF included, so both refuse the same topics." + }, + { + "id": "group-keys", + "class": "shape", + "says": "A group (a clusters entry) is an object carrying id, name, topics and document_edges. Its name is non-empty text, and its edges are a list." + }, + { + "id": "group-has-a-topic", + "class": "shape", + "says": "A group's topics name at least one topic, each once: a group forms around topics that its documents share." + }, + { + "id": "edge-keys", + "class": "shape", + "says": "A group's document edge is an object that names the document and at least one matched topic, each once." + }, + { + "id": "candidate-keys", + "class": "shape", + "says": "A candidate (a possibles entry) is an object carrying id, title and state. Its title, and its claim when present, are non-empty text; claiming_clusters, when present, names each group once." + }, + { + "id": "candidate-state-is-known", + "class": "shape", + "says": "A candidate's state is one of unselected, selected, declined and replaced." + }, + { + "id": "selected-candidate-has-pick", + "class": "shape", + "says": "A selected candidate carries pick, an object whose staging_id names the selection it went to." + }, + { + "id": "closed-candidate-has-reason", + "class": "shape", + "says": "A declined or replaced candidate carries reason, non-empty text that says why." + }, + { + "id": "selection-keys", + "class": "shape", + "says": "A selection (a staged_topics entry) is an object carrying staging_id; files, when present, lists repository-relative paths, each once." + }, + { + "id": "submission-keys", + "class": "shape", + "says": "A changes entry, a submission or a completed item, is an object carrying id and status; files, when present, lists repository-relative paths, each once." + }, + { + "id": "submission-status-is-known", + "class": "shape", + "says": "A changes entry's status is active (the submission station) or archived (the completion station)." + }, + { + "id": "keyword-entry-keys", + "class": "shape", + "says": "A keyword_index entry is an object carrying keyword and declared_doc_count, a whole number no less than 0." + }, + { + "id": "ids-are-unique", + "class": "reference", + "says": "Within each section, entry ids are unique: documents, clusters, possibles and changes by id, and staged_topics by staging_id." + }, + { + "id": "edge-names-a-document", + "class": "reference", + "says": "Every group edge names a document in documents." + }, + { + "id": "one-edge-per-document", + "class": "reference", + "says": "Within one group, the edges name each document once: one edge per member document. A document may feed several groups." + }, + { + "id": "candidate-names-a-group", + "class": "reference", + "says": "Every group that a candidate's claiming_clusters names is in clusters." + }, + { + "id": "pick-names-a-selection", + "class": "reference", + "says": "A candidate's pick.staging_id names a selection in staged_topics." + }, + { + "id": "target-names-a-submission", + "class": "reference", + "says": "A selection's target_change names an entry in changes." + }, + { + "id": "keyword-index-matches-topics", + "class": "reference", + "says": "keyword_index, when present, agrees with the documents: every topic a document carries has an entry, no keyword has two, and each entry's declared_doc_count is the number of documents that carry its keyword, 0 for a keyword that none carries." + } + ] +} diff --git a/src/opendox/contracts/schemas/xfactory-workbench-chat-turn.schema.yaml b/src/opendox/contracts/schemas/xfactory-workbench-chat-turn.schema.yaml new file mode 100644 index 00000000..9efaa118 --- /dev/null +++ b/src/opendox/contracts/schemas/xfactory-workbench-chat-turn.schema.yaml @@ -0,0 +1,414 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "xfactory-workbench-chat-turn.schema.yaml" +title: "doxBench grounded chat turn (request / success / fixed failure)" +contract_schema_version: 1 +description: >- + The POST /actions/workbench/chat-turn wire family + (add-workbench-integrated-editor-chat task 2.1; consumer contract + codexFactory specs/010-doxbench-editor-chat/contracts/chat-turn.md). One + file, three closed envelopes discriminated by `kind`: the request + (workbench-chat-turn-v2), the validated success + (workbench-chat-turn-v2-success), and the fixed redacted failure + (workbench-chat-turn-v2-failure). Every object + is closed; a failure NEVER carries prompt text, buffer text, provider + payload, credential, endpoint, secret name, or exception detail — only + limit failures carry measured data, and only numeric dimension/values (the + delegated validator enforces that pairing). `working_subject` is free-form + NON-IDENTITY authoring focus (FR-012): a string, never an actor, customer, + patient, tenant, authority, credential, or routing key — identity-shaped + values are refused by type alone, and no identity-bearing sibling field + exists to smuggle one. Content identity is exact-UTF-8 lowercase SHA-256 + over the full text (R4); the validator recomputes request-buffer hashes, so + a mismatched content_hash is refused rather than trusted. The outline plus + at least one loaded document buffer accompany every request (FR-013): both + complete, never truncated, selected, summarized, or omitted. A path that + does not exist yet is `null`, never a fabricated or guessed string + (`buffer_state.path`, the not-yet-created artifact, data-model S4): a + REQUIRED key with a nullable value — absent and null are + different facts, and only null is expressible. + + THE DEPRECATED v1 FAMILY IS REMOVED (contract-v3.0, + retire-doxbench-chat-turn-v1). Until this release the file also carried three + co-resident v1 envelopes — `workbench-chat-turn`, + `workbench-chat-turn-success` and `workbench-chat-turn-failure` — added at + contract-v1.31, DEPRECATED at contract-v1.34 by the widened family above, and + declared removable at a stated target through a top-level + `deprecated_envelopes` block. Thirteen minors and one major passed with the + contract validator warning on every packaged instance, far past the "at least + one full published release" floor the versioning policy sets. The three + `$defs`, their `oneOf` entries and the whole `deprecated_envelopes` block + leave with them: a block declaring the deprecation of kinds this file no + longer defines names nothing. The shared definitions the removed envelopes + reached are RETAINED, measured from the surviving family's own reference + closure rather than assumed. A consumer pinned below contract-v3.0 keeps + validating v1 instances against the bytes its pin names — compatibility flows + from the consumer, and no new release reaches backwards into an old pin. The + migration path is `contracts/CHANGELOG.md` and + `docs/contract-versioning-policy.md` § Deprecations Executed. + + THE ASSEMBLED CONTEXT'S POSTURE (contract-v1.40, add-doxbench-editing-phase-b + task 10.7). `success_v2` gains ONE optional property, `context_packet`, and + the widened family gains nothing else: the posture (`full | reduced`) under + which the turn's bounded context packet was assembled, plus the reduction's + own reason when there is one. It exists so the ratified "with the reduced + posture STATED" clause is readable by a consumer and showable to the human + rather than stated only inside the packet. ADDITIVE: the key is OPTIONAL, its + ABSENCE is not a posture claim (it means a producer older than v1.40), and no + existing shape changes. (That release also recorded a limitation of the then + DEPRECATED v1 success envelope, which was deliberately not widened; the + envelope is gone at contract-v3.0 and the limitation with it.) +oneOf: + - $ref: "#/$defs/request_v2" + - $ref: "#/$defs/success_v2" + - $ref: "#/$defs/failure_v2" +$defs: + content_hash: + type: string + pattern: "^[0-9a-f]{64}$" + confined_path: + # Repository-relative, confined: never absolute, never an escape. The + # delegated validator re-checks `..` traversal segment-wise. + type: string + minLength: 1 + maxLength: 512 + pattern: "^[^/].*$" + scope_key: + type: object + additionalProperties: false + required: [repository, ref, tile_kind, tile_id] + properties: + repository: { type: string, minLength: 1, maxLength: 128 } + ref: { type: string, minLength: 1, maxLength: 256 } + tile_kind: { enum: [cluster, possible, staged] } + tile_id: { type: string, minLength: 1, maxLength: 256 } + buffer_state: + # One COMPLETE authoring buffer (FR-005/FR-013): descriptor + full text + + # both identities. `base_hash` is the loaded base's identity; the + # stale-authority comparisons happen against `content_hash`. + type: object + additionalProperties: false + required: [kind, repository, path, base_ref, base_revision, + base_hash, content_hash, content, dirty] + properties: + kind: { enum: [outline, document] } + repository: { type: string, minLength: 1, maxLength: 128 } + # Nullable: a not-yet-created artifact has no path until its first Save + # (the null-path -> create lifecycle, data-model S4). + path: + oneOf: + - { type: "null" } + - { $ref: "#/$defs/confined_path" } + base_ref: { type: string, minLength: 1, maxLength: 256 } + base_revision: { type: string, minLength: 1, maxLength: 128 } + base_hash: { $ref: "#/$defs/content_hash" } + content_hash: { $ref: "#/$defs/content_hash" } + content: { type: string, maxLength: 1048576 } + dirty: { type: boolean } + transcript_turn: + type: object + additionalProperties: false + required: [role, content] + properties: + role: { enum: [human, assistant] } + content: { type: string, maxLength: 65536 } + turn_id: { type: string, minLength: 1, maxLength: 128 } + typed_proposal: + # A human-reviewable replacement for exactly ONE buffer, bound to the + # content identity the model saw (FR-026..FR-029). Untyped replacement + # content — a proposal with no target — is refused at the schema. + # + # RETAINED AT contract-v3.0 AND NO LONGER REACHED, deliberately and on the + # record (retire-doxbench-chat-turn-v1 task 2.1). The measured reference + # closure of the surviving family does NOT contain this definition: + # `keyed_typed_proposal` below RESTATES the shape with a buffer-key target + # rather than `$ref`-ing this one, so removing the v1 envelopes leaves this + # block unreferenced. The ratified requirement names `typed_proposal` among + # the shared definitions that "do NOT leave with the envelopes", on a + # factual premise the measurement contradicts. Task 2.1 rules that "any + # surprise is a finding, not a licence", so the surprise is RECORDED here + # and the definition STAYS: the realization does not remove bytes the + # ratified text names as retained. Disposing of it is the cut's decision, + # taken against this measurement rather than against the premise. + type: object + additionalProperties: false + required: [target, base_hash, summary, content] + properties: + target: { enum: [outline, document] } + base_hash: { $ref: "#/$defs/content_hash" } + summary: { type: string, minLength: 1, maxLength: 500 } + content: { type: string, maxLength: 1048576 } + # ---- the widened, co-resident family (contract-v1.34) ---- + buffer_key: + # A buffer's KEY in the widened family (design D1): the permanently + # reserved `outline`, the reserved `document` key that holds the ONE + # not-yet-created artifact of the create flow, or a document's own + # repository-relative path. Both reserved spellings already satisfy the + # confined-path shape, so ONE constraint expresses all three and no `oneOf` + # can match a key twice. WHICH keys a given turn actually holds is the + # request's own statement, checked by the delegated validator against the + # buffer set the request supplies — a schema cannot know a loaded set. + $ref: "#/$defs/confined_path" + keyed_observed_hashes: + # The recomputed identity of EVERY buffer the request supplied, keyed by + # buffer key. v1 required exactly the two keys `outline` and `document` + # because the set held exactly those two buffers; the widened record states + # one identity per loaded document, so a reader can ask which text the model + # saw for any of them. `outline` stays REQUIRED: its key is permanently + # reserved and every turn carries it. + type: object + minProperties: 2 + maxProperties: 25 + required: [outline] + propertyNames: { $ref: "#/$defs/buffer_key" } + additionalProperties: { $ref: "#/$defs/content_hash" } + keyed_typed_proposal: + # v1's `typed_proposal` with its two-value `target` enum widened to a BUFFER + # KEY. Every other field, bound and rule is unchanged. A target the request + # did not supply is unroutable and is refused by the delegated validator + # rather than guessed at. + type: object + additionalProperties: false + required: [target, base_hash, summary, content] + properties: + target: { $ref: "#/$defs/buffer_key" } + base_hash: { $ref: "#/$defs/content_hash" } + summary: { type: string, minLength: 1, maxLength: 500 } + content: { type: string, maxLength: 1048576 } + selected_model: + # WHAT THE HUMAN CHOSE, beside `model_id`'s WHAT ANSWERED. The two differ + # exactly when the chosen catalog entry is a ROUTING RULE this capability + # owns (an `auto` entry) rather than a provider model, which is why + # `routing_rule` is stated rather than inferred from the two ids being + # unequal. `data_handling` is the chosen entry's own catalog badge, carried + # so a transcript states the handling posture the human was shown at send + # time rather than the one the catalog happens to declare when it is read + # back. The resolved model is NOT repeated here: it is `model_id`, once. + type: object + additionalProperties: false + required: [requested_model_id, routing_rule, data_handling] + properties: + requested_model_id: { type: string, minLength: 1, maxLength: 128 } + routing_rule: { type: boolean } + data_handling: { type: string, minLength: 1, maxLength: 500 } + context_packet: + # WHAT THE TURN RAN UNDER (contract-v1.40, add-doxbench-editing-phase-b task + # 10.7). The ratified knowledge-service requirement ends "Where the knowledge + # service is unavailable the turn SHALL degrade to a declared reduced packet + # — the selected thread and the loaded buffers, with the reduced posture + # STATED". Until this release the posture was stated only INSIDE the + # assembled context, which no reader of the record and no human on the + # surface could consult: `success_v2` is a CLOSED envelope and had no field + # for it. This is that field. + # + # ITS VOCABULARY IS THE PACKET'S OWN, deliberately, so no third spelling of + # the same fact exists: `posture` and `reduced_reason` are the two members + # of `ContextPacket` this states, with the SAME two-value posture and the + # SAME truth-pairing the packet enforces at construction — a reduced packet + # STATES its reason and a full packet carries none. That pairing is not + # advisory here either: the two conditionals below refuse each half of it. + # + # WHY ONE OBJECT rather than two sibling keys on the envelope, on the + # `selected_model` precedent one $def above: the posture and its reason are + # one fact about one thing, grouping keeps the "present iff" rule local to + # the object that owns it, and it keeps the ENVELOPE's key set identical for + # a full turn and a reduced one — which is the ratified independence claim + # ("MUST NOT ... make the editors unusable") read on the wire, where the two + # turns differ in what the record SAYS and not in the shape it arrives in. + # + # `reduced_reason` is FREE PROSE with the same 500-`maxLength` ceiling every + # other authored string in this family carries (`data_handling`, a proposal + # `summary`, a failure `message`). THE UNIT IS CODE POINTS, which is what + # JSON Schema's `maxLength` counts — so a 500-character CJK reason is + # conformant at roughly 1,500 UTF-8 bytes, and a consumer sizing a buffer + # must size it in bytes rather than in `maxLength`. Producers are expected + # to refuse an over-long reason BEFORE emitting it rather than discovering + # the ceiling at validation; this repository's own producer does, in the one + # place the reason is carried onto the record. It is a statement about the + # ASSEMBLY, not + # about content: it names why the packet is reduced and, in the reasons this + # capability ships, says in as many words that nothing unbounded was + # substituted and no rail was bypassed. The delegated validator scans it for + # public-only violations exactly as it scans a failure's `message`. + type: object + additionalProperties: false + required: [posture] + properties: + posture: { enum: [full, reduced] } + reduced_reason: { type: string, minLength: 1, maxLength: 500 } + allOf: + # A REDUCTION NOBODY CAN READ IS A SILENT DEGRADATION -- the packet's own + # words, and its own refusal, restated on the wire. + - if: + required: [posture] + properties: { posture: { const: reduced } } + then: + required: [reduced_reason] + # AND A FULL PACKET CARRIES NO REASON. A record that declared `full` while + # naming a reduction would state two contradictory facts and let a reader + # pick; the packet refuses that construction, so the record refuses it too. + # This is a SEPARATE conditional and not the inverse of the one above: + # they guard different instances, and each has its own packaged negative. + - if: + required: [posture] + properties: { posture: { const: full } } + then: + not: { required: [reduced_reason] } + provider_retry: + # A MID-TURN RE-MINT AND THE PAID RETRY IT BOUGHT (contract-v1.45, + # add-doxchat-model-intake task 3.6; handed here by + # add-model-provider-broker task 2.4). + # + # Brett ruled on 2026-08-26 that when a minted token expires part-way + # through a turn the dashboard re-mints and retries ONCE, "with the re-mint + # and the paid retry VISIBLY RECORDED in the turn record" — a second paid + # call the human cannot see is exactly the decision that ruling was made to + # avoid. Three records already existed on the server side (the port's + # content-free mint ledger, the console's stderr notice, and the broker's own + # audit trail correlated by `--retry-of`) and the browser can read NONE of + # them, so the fact never reached the person paying for it. This is the field + # that carries it to them. + # + # THE REDACTED FACT AND NOTHING MORE. It states that the turn re-minted once + # and made one further paid provider call, and it names the re-mint's audit + # reference. It carries no token, no token prefix, no token hash, no + # provider status, no provider words, no endpoint and no timing that would + # let one be reconstructed — the same redaction discipline the failure + # envelope keeps, for the same reason: this record is stored, mirrored into a + # thread sidecar and rendered in a page. + # + # `audit_ref` IS DISCLOSABLE BY CONSTRUCTION rather than by care: the + # broker's own declaration records no token material against an audit + # reference, and it is the identifier the broker's trail is keyed by — so a + # reader of this record and a reader of `broker-audit.jsonl` can be shown to + # be reading about the same issuance, which is what makes "visibly recorded" + # mean something on both sides of the seam instead of only on the server's. + # + # `retried: true` is a CONST rather than a boolean anyone may set false. A + # turn that did not retry omits the whole object; a `retried: false` would be + # a second, weaker spelling of an absence that is already unambiguous, and + # two spellings of one fact is how a consumer comes to read the wrong one. + # + # WHAT `at_most_once` RECORDS: that the ruling's bound HELD. A second expiry + # inside one turn does not buy a third call — it surfaces the standard + # refusal, and that turn produces a FAILURE envelope rather than this one — + # so a success record carrying this object is a record of exactly one retry. + # Stated on the record rather than left to a reader who would have to know + # the port's internals to infer it. + type: object + additionalProperties: false + required: [retried, at_most_once] + properties: + retried: { const: true } + at_most_once: { const: true } + audit_ref: { type: string, minLength: 1, maxLength: 200 } + request_v2: + # The widened request. It carries the outline plus every loaded document, + # and it DECLARES which of them the chat is bound to. `active_document_path` + # is deliberately absent: the binding is stated, never inferred from an + # adjacent field that answers a different question (design D17), and the two + # questions had different answers exactly when a human worked the outline + # with a document loaded. + type: object + additionalProperties: false + required: [schema_version, kind, client_turn_id, scope, bound_buffer, + working_subject, message, model_id, last_assistant_turn_id, + transcript, buffers] + properties: + schema_version: { const: 1 } + kind: { const: workbench-chat-turn-v2 } + client_turn_id: { type: string, minLength: 1, maxLength: 128 } + scope: { $ref: "#/$defs/scope_key" } + # The DECLARED binding: what the chat is working ON. It MUST name one of + # the buffers this same request supplies (delegated validator), and it + # never narrows what the turn is GROUNDED on — every loaded buffer rides + # the request regardless of which one is bound. + bound_buffer: { $ref: "#/$defs/buffer_key" } + working_subject: { type: string, maxLength: 512 } + message: { type: string, minLength: 1, maxLength: 65536 } + model_id: { type: string, minLength: 1, maxLength: 128 } + last_assistant_turn_id: + oneOf: [ { type: "null" }, { type: string, maxLength: 128 } ] + transcript: + type: array + maxItems: 128 + items: { $ref: "#/$defs/transcript_turn" } + buffers: + # The outline plus one to twenty-four document buffers. The lower bound + # is unchanged from v1 (one outline and at least one document buffer, + # which the delegated validator pairs as before); the upper bound is the + # surface's own declared loaded-set bound plus the reserved outline, so + # a request can never carry more buffers than a human is allowed to + # load. Reaching the bound REFUSES the load with the measured bound + # stated; nothing is evicted, because every loaded buffer may hold + # unsaved human text. + type: array + minItems: 2 + maxItems: 25 + items: { $ref: "#/$defs/buffer_state" } + success_v2: + # The widened, validated success — the turn's durable RECORD. It names the + # bound buffer (from the request's DECLARED binding, never derived), states + # every buffer's observed identity by key, and carries the selected-model + # metadata beside the model that answered. + type: object + additionalProperties: false + required: [schema_version, kind, client_turn_id, assistant_turn_id, + model_id, selected_model, bound_buffer, observed_hashes, + assistant_prose, proposals] + properties: + schema_version: { const: 1 } + kind: { const: workbench-chat-turn-v2-success } + client_turn_id: { type: string, minLength: 1, maxLength: 128 } + assistant_turn_id: { type: string, minLength: 1, maxLength: 128 } + # The model that ANSWERED. With a routing-rule entry this is the model the + # rule resolved to, which is what makes a transcript state which model + # produced which turn instead of leaving it to be inferred. + model_id: { type: string, minLength: 1, maxLength: 128 } + selected_model: { $ref: "#/$defs/selected_model" } + # WHAT THE TURN RAN UNDER (contract-v1.40, task 10.7). OPTIONAL, which is + # what makes this release additive: a record produced before v1.40 omits + # it and stays valid, and no reader of an older record has to change. + # OMISSION IS NOT A POSTURE CLAIM -- it means the producer predates this + # release, never that the context was full. A consumer that needs the + # posture must read this key and treat its absence as unknown. + context_packet: { $ref: "#/$defs/context_packet" } + # WHAT THE TURN COST (contract-v1.45, add-doxchat-model-intake task 3.6). + # OPTIONAL, which is what makes this release additive, and PRESENT ONLY + # WHEN IT HAPPENED: a turn that minted once and answered once carries no + # such key, and its absence is not a claim — it means "nothing to report, + # or a producer older than v1.45". Present on the v2 envelope ONLY: the v1 + # family is deprecated and its promise is byte-identical stability. + provider_retry: { $ref: "#/$defs/provider_retry" } + bound_buffer: { $ref: "#/$defs/buffer_key" } + observed_hashes: { $ref: "#/$defs/keyed_observed_hashes" } + assistant_prose: { type: string, maxLength: 900000 } + # At most one proposal per supplied buffer (the delegated validator holds + # that rule, which is expressed over the REQUEST's own buffer count); the + # transport ceiling here is the largest buffer set a request may carry. + proposals: + type: array + maxItems: 25 + items: { $ref: "#/$defs/keyed_typed_proposal" } + failure_v2: + # The fixed redacted failure for a v2 turn. Structurally identical to the v1 + # failure and deliberately so — a refusal discloses nothing whichever family + # it answers — but it carries its own `kind`, so the whole v1 family can be + # deprecated as a unit and a v2 turn is never answered in a deprecated + # envelope. + type: object + additionalProperties: false + required: [schema_version, kind, client_turn_id, error, message] + properties: + schema_version: { const: 1 } + kind: { const: workbench-chat-turn-v2-failure } + client_turn_id: { type: string, minLength: 1, maxLength: 128 } + error: { type: string, pattern: "^[a-z][a-z0-9_]{2,63}$" } + message: { type: string, minLength: 1, maxLength: 500 } + limit: + type: object + additionalProperties: false + required: [dimension, measured, maximum] + properties: + dimension: { type: string, minLength: 1, maxLength: 64 } + measured: { type: integer, minimum: 0 } + maximum: { type: integer, minimum: 0 } diff --git a/src/opendox/contracts/schemas/xfactory-workbench-model-catalog.schema.yaml b/src/opendox/contracts/schemas/xfactory-workbench-model-catalog.schema.yaml new file mode 100644 index 00000000..e4399c59 --- /dev/null +++ b/src/opendox/contracts/schemas/xfactory-workbench-model-catalog.schema.yaml @@ -0,0 +1,230 @@ +$schema: "https://json-schema.org/draft/2020-12/schema" +$id: "xfactory-workbench-model-catalog.schema.yaml" +title: "doxBench approved model catalog (wire envelope)" +contract_schema_version: 1 +description: >- + The GET /workbench/model-catalog success envelope + (add-workbench-integrated-editor-chat task 2.1; consumer contract + codexFactory specs/010-doxbench-editor-chat/contracts/model-catalog.md). + PUBLIC-ONLY BY CONSTRUCTION: every object is closed + (additionalProperties: false), so credentials, raw endpoints, secret or + environment-variable names, request templates, provider-native options, and + deployment resource names are structurally impossible rather than merely + discouraged; the delegated validator additionally scans string values for + credential/endpoint spellings. An empty `models` array is a SUCCESSFUL + editor-only posture (FR-025/SC-008), never an error. Model ids are opaque + UI handles, not provider model names; selecting one grants no lasting + authority (the id is revalidated on every turn). Instance kinds use the + retained `workbench-*` identifier family (compatibility-migration checklist + NOTES); the file name carries the manifest's `xfactory-` artifact prefix. + Since contract-v1.38 an entry MAY additionally declare itself a ROUTING + RULE (`routing_rule`/`routes_to`/`resolved_model_id`, all optional and all + three travelling together) — see `$defs/model_entry`. That growth changes + nothing about the credential posture: a routing entry names only opaque + catalog ids, and an API-backed entry's credential is provisioned into the + `doxbench-bridge` harness profile by the ratified broker lane + (`add-model-provider-broker`). The adapter neither holds nor fetches a raw + secret — its child environment is an allowlist no credential-shaped + variable can pass — and a self-hosted, keyless provider needs none. No + field here has ever carried a credential and none may be added that could. +type: object +additionalProperties: false +required: [schema_version, kind, models] +properties: + schema_version: { const: 1 } + kind: { const: workbench-model-catalog } + models: + type: array + maxItems: 64 + items: { $ref: "#/$defs/model_entry" } +$defs: + model_entry: + # The public allowlist, exactly: the SEVEN REQUIRED base fields + # (data-model.md Section 6) plus, since contract-v1.38, the THREE OPTIONAL + # routing-declaration fields below, and since contract-v2.2 the ONE + # OPTIONAL `modalities` capability declaration. An entry that declares + # none of the four optional fields is byte-for-byte the entry this schema + # has always accepted, which is what makes each growth additive. + type: object + additionalProperties: false + required: [model_id, label, provider_class, available, + input_limit_bytes, output_limit_bytes, data_handling] + properties: + model_id: + type: string + minLength: 1 + maxLength: 128 + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" + label: { type: string, minLength: 1, maxLength: 200 } + provider_class: { type: string, minLength: 1, maxLength: 64 } + available: { type: boolean } + # A catalog entry may only NARROW the server ceilings (plan.md + # Constraints): request bound 1,048,576 bytes; output bound 900,000. + # + # For an AVAILABLE ROUTING entry these must additionally not exceed the + # limits of `resolved_model_id`'s entry — the model that ANSWERS — because + # the effective turn limit is computed from the SELECTED entry, which for + # a routed turn is the rule. The bound is the RESOLVED model's alone and + # deliberately NOT the minimum over `routes_to`: an un-resolved + # destination takes no turn while the rule resolves elsewhere. Delegated + # to the validator, which the shape cannot express. + input_limit_bytes: { type: integer, minimum: 1, maximum: 1048576 } + output_limit_bytes: { type: integer, minimum: 1, maximum: 900000 } + # For a ROUTING entry this text is a SEPARATOR-JOINED LIST of badge + # segments, and it MUST carry the badge of every model the rule may route + # to as one of them. THE SEPARATOR IS SPACE SLASH SPACE (" / "). + # + # The covering rule is SEGMENT MEMBERSHIP, not substring containment: for + # each id in `routes_to`, that entry's own `data_handling` must equal one + # segment of this one, compared with whitespace collapsed, case folded and + # trailing `.;,` dropped — and with interior characters never rewritten, + # so `on-tenant` and `non-tenant` stay different. Extra segments are + # permitted: a rule may carry its own lead-in beside the badges it must + # carry. A ROUTED entry whose own badge contains the separator is refused, + # because such a badge could never be one segment. The delegated validator + # (`scripts/validate-ideation-dashboard-contracts.py`) enforces all of + # this; the shape can express none of it. + # + # The 500-byte ceiling is unchanged and therefore bounds how many distinct + # badges one rule can carry — a rule whose list does not fit is a rule + # that must be split, or whose members' badges must be written more + # tightly. + data_handling: { type: string, minLength: 1, maxLength: 500 } + # --- the input-modality declaration (contract-v2.2, additive) --- + # + # OPTIONAL, and it says WHAT KIND of input the model accepts — the one + # thing `input_limit_bytes` and `output_limit_bytes` cannot say, because + # they describe how MUCH a model accepts and never what kind. It exists + # so a routing decision can ask whether a candidate can carry what a turn + # actually contains, rather than inferring capability from a model's name + # or from `provider_class`, which is a governance classification. + # + # ABSENCE IS NOT A CLAIM IN EITHER DIRECTION. An entry that declares + # nothing is a producer that predates this field, exactly as an absent + # `context_posture` means "a producer older than contract-v1.40" in the + # chat-turn family rather than a stated posture. A reader treats an + # undeclared entry as text-only FOR ROUTING — the safe reading — while + # recording that no declaration was made, so a conservative default is + # never mistaken for a stated capability. Requiring the field would have + # invalidated every catalog released before it, which an additive growth + # must not do. + # + # THE VOCABULARY IS CLOSED AND EXTENDS ONLY BY THE CHANGE THAT GOVERNS A + # NEW MEMBER — the rule `admission_surface` states for the client-identity + # roster. `image` enters because a turn carrying an image is the named + # near-term consumer. Audio, video, tool-calling, structured output, + # latency class and cost class do NOT enter: nothing consumes them, and a + # vocabulary guessed ahead of its consumers is one nothing validates + # against. INPUT ACCEPTANCE ONLY — output modality and tool/structured + # output are different questions with different consumers, and one set + # answering several would mean different things to different readers. + # + # `contains: {const: text}` is load-bearing and is NOT redundant with + # `minItems`: without it this shape would accept `modalities: [image]`, + # which the catalog TYPE refuses, leaving the WIRE GATE THE WEAKEST ONE — + # the very divergence class this release closes in the other direction. + # A chat turn always carries text, so a model that cannot accept text is + # not routable here at all. + # + # THE TYPE IS THE ONLY OTHER GATE, and that is worth stating exactly. The + # delegated validator does NOT restate the modality rules — they are all + # expressible here, so it enforces them BY APPLYING THESE BYTES. This + # clause is therefore the whole of the file-side refusal, not a second + # opinion beside one: remove it and the packaged image-only negative + # stops being refused at all. + # + # No `maxItems`: `uniqueItems` over a two-member closed enum already + # bounds the array at two, and a `maxItems` literal would be a second + # place to edit when the vocabulary grows by a governing change. + modalities: + type: array + minItems: 1 + uniqueItems: true + items: + enum: [text, image] + contains: + const: text + description: >- + The input modalities this model accepts, from the closed vocabulary + `text` and `image`. Optional; absence means the producer predates + the field and is read as text-only for routing while being recorded + as no declaration. Where present the set is non-empty, carries no + repeats, and must contain `text`. + # --- the routing-rule declaration (contract-v1.38, additive) --- + # + # OPTIONAL, and all three travel together (see dependentRequired and the + # two conditionals below). Present-and-true means this entry is a ROUTING + # RULE this capability owns — an `auto` entry that maps a turn to a model + # by role — rather than a directly-answering provider model. ABSENT means + # exactly what absence has always meant: a plain model, with no new + # obligation of any kind. + # + # They carry NO provider surface. `routes_to` and `resolved_model_id` are + # opaque catalog handles drawn from this same catalog's `model_id` values, + # so a routing declaration can name nothing a plain entry could not + # already name. An API-backed entry's credential comes from the ratified + # broker lane (`add-model-provider-broker`), which provisions the + # `doxbench-bridge` profile; the adapter holds and fetches no secret, and + # a keyless self-hosted provider needs none. + routing_rule: + type: boolean + description: >- + True IFF this entry is a routing rule rather than a directly + answering model. A routing entry must declare `routes_to` and + `resolved_model_id`; a plain entry must declare neither. + routes_to: + type: array + minItems: 1 + maxItems: 64 + uniqueItems: true + description: >- + Every model this rule MAY route to, as `model_id` references into + this same catalog. The delegated validator resolves each one and + refuses a dangling reference, a self-reference, a target that is + itself a routing rule, a target whose own `data_handling` this + entry's `data_handling` does not carry as a " / "-separated SEGMENT, + and a target whose badge holds that separator itself. + items: + type: string + minLength: 1 + maxLength: 128 + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" + resolved_model_id: + type: string + minLength: 1 + maxLength: 128 + pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" + description: >- + The model this rule CURRENTLY resolves to — the one that ANSWERS the + turn, is recorded as the turn's `model_id` beside the requested id in + `selected_model`, and is the model the turn's thread sidecar names. + Must be a member of `routes_to` — so the badge covering above has + necessarily checked it — must be available whenever the routing entry + itself is available, and must accept at least the limits the routing + entry declares. All three are delegated-validator rules: the shape has + no operator for any of them. + dependentRequired: + # Neither routing field may ride on an entry that does not declare what + # it is, and neither is meaningful without the other. + routes_to: [routing_rule, resolved_model_id] + resolved_model_id: [routing_rule, routes_to] + allOf: + # BOTH conditionals constrain ONLY the three fields this release adds: + # each `if` requires `routing_rule` to be PRESENT, so an entry that + # declares no routing rule matches neither and is judged exactly as it + # was before contract-v1.38. + - if: + required: [routing_rule] + properties: + routing_rule: { const: true } + then: + required: [routes_to, resolved_model_id] + - if: + required: [routing_rule] + properties: + routing_rule: { const: false } + then: + not: + anyOf: + - required: [routes_to] + - required: [resolved_model_id] diff --git a/src/opendox/validator.py b/src/opendox/validator.py new file mode 100644 index 00000000..16c97c56 --- /dev/null +++ b/src/opendox/validator.py @@ -0,0 +1,1234 @@ +"""OPENDOX'S OWN VALIDATOR: its own document kinds, read from its own packaged +copies of their schemas. + +WHY THIS FILE EXISTS. #1144's requirement 7 asks for *"a validator openDox can +run"*, and its 7.1 says to *"NARROW THE INPUT SET FIRST, then acquire what +remains"*: openDox's validator validates openDox's OWN document kinds, whose +schemas its own spec leg owns. T007's batch G amends 7.1 on R1Q11 (a) and +R1Q12 (a) (`openxFactory#656` comment `5850003126`), so the set is FOUR +schemas: 7.1's three, `ideation-workbench`, `xfactory-workbench-chat-turn` and +`xfactory-workbench-model-catalog`, and the neutral snapshot contract, +`opendox-snapshot`, which T053 adds to openDox-spec. This module is plan 034's +T057. `opendox.contracts` beside it carries the four as package data, each one +digest-checked before it is read. + +THE INPUT SET, AND WHAT IS LEFT OUT (7.1, 7.1b). `KIND_ENTRIES` maps each +instance kind openDox validates to the copy that holds its schema. Nothing else +is in it. + +* openXdox-spec's three, `ideation-dashboard-snapshot`, its `-index` and + `gate-action-record`, *"belong to the CONSUMER's validator and openDox never + needs them"* (7.1). The consumer locates them itself (7.3, plan 034's T061). +* openxFactory's four are not in it either, and 7.1b names two of them as + unavailable by any route. `gate-intent` is an intent-plane schema, which + requirement 1 keeps with openxFactory. `ideation-possibles-register` is + filed `stays_openxfactory_adapter`, as openxFactory's own candidate + register. Vendoring either would meet requirement 7 by breaching + requirement 1. No openDox verb needs one, so neither is carried. + +WHY THE CONSUMER'S SCRIPT IS NOT REUSED (7.1a). The validator the carve left, +`scripts/validate-ideation-dashboard-contracts.py`, lives in openXdox-code. It +was run at openXdox-code `4610bca5` for this record. + +1. It finds its schemas from its own position. `ROOT` is + `Path(__file__).resolve().parents[1]`, and it reads `ROOT / "contracts"`, + or the directory `CONTRACTS_DIR` names. #1144 measured the first half + (*"run from openXdox-code it exits 2 with `ERROR .../contracts/schemas not + found`"*), and C3's openXdox-code#28 has added the second since. Run at + `4610bca5` with no `CONTRACTS_DIR`, it still exits 2: `ERROR harness + failure: .../contracts/schemas carries none of the family's 10 schemas`. + An installed openDox carries neither the script nor a `contracts/` beside + it, and no environment variable is part of `pip install`, which is the + checkout requirement 7 means (7.1). That is 7.1a's defect, and it is the + consumer's to fix for its own set (T061). +2. It names TEN schemas, of three owners (`SCHEMA_FILENAMES`). openDox's + validator reads four, all openDox-spec's (7.1). +3. It is the CONSUMER's file. openDox pins nothing of openXdox's, and a reach + into it is the direction requirements 2 and 5 close. +4. It needs `jsonschema`, `referencing` and `rfc3339-validator`. openDox-code + declares PyYAML alone, and this module needs no more. +5. It knows no `opendox-snapshot`. Given openDox-spec's contracts through + `CONTRACTS_DIR`, it refuses openDox-spec's own six-station example with + exit 1, `ERROR [kind] ...: unrecognized document (no known kind ...)`, + because its `KIND_TO_SCHEMA` has no entry for the neutral kind. +6. It is a script, run as a subprocess over a file. openDox-code has no + `scripts/` directory (7.2), and this validator is a library call that + answers violations. + +NEW SURFACE (7.2). This file is CREATED at the code leg, not relocated, and 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 adds no verb, and #1144 adds none. Validation is the +post-render step inside the generate verbs, which plan 034's T058 routes here. + +THE EVALUATOR. It evaluates JSON Schema draft 2020-12, and exactly the keywords +the four copies use (`KEYWORDS`), as that draft defines them. JSON equality +holds throughout: `true` is not `1`, `1` is `1.0`, and key order does not +count. `format` is ASSERTED, as the consumer's validator asserts it with its +format checker, and `date-time` is the one format the four use. A copy that +uses a keyword, a format, a dialect or a reference this module does not +evaluate is REFUSED when its validator is built (`SchemaNotEvaluable`). It is +never evaluated with that keyword left out, which would pass whatever the +keyword refuses. So is a copy that gives an evaluated keyword a value of +another shape (`_SHAPES`: `uniqueItems: "yes"`, `type: {}`, a negative +`maxLength`), an embedded resource (`$id` or `$schema` below the root, which +would move where its references resolve), a subschema that contains itself, +and a cycle of references that never moves into the instance (`$ref: "#"`), +which no evaluation ends. A recursive schema that moves into the instance +before it recurs (a tree's children, as items) is evaluated. It recurs once per +level of the instance, so an instance nested past what Python's recursion limit +lets the walk reach cannot be walked to its end. Such an instance is judged, +never crashed on: it breaks `DEPTH_RULE`, at the root, so it is never valid, +and whatever the walk found before the limit stands. The build walks +every subschema and every reference's target, so nothing the evaluator can +reach escapes those checks, and a malformed copy is reported as unavailable +instead of crashing the build or an evaluation, or misjudging an instance. + +RULE IDENTIFIERS. Every violation names the rule it broke. The neutral snapshot +contract gives each rule an id, carried as `x-rule` by the subschema that +enforces it, and that id is the violation's `rule`. A subschema that names none +(the three older contracts do not use the convention) is identified by the JSON +Schema keyword that failed. `Violation.line()` renders +`[] : `, with `where` a JSON pointer into the instance, so +a refusal carries the identifier a caller can match. T051's `EXPECTED_RULE` is +one such identifier, and F7.2 greps the verbs' stderr for it. + +THE NEUTRAL SNAPSHOT'S REFERENCE RULES. Seven of the contract's 32 rules are +cross-references that no JSON Schema keyword can state, so this module +implements them (`REFERENCE_RULES`). The contract catalogues each rule with its +class in `x-rules`. A snapshot validator is REFUSED when that catalog names a +reference rule this module does not implement, or when this module implements +one the catalog does not name. So a validator never enforces less than the +contract says, or more. + +IDENTITY BEFORE USE, ON EVERY CALL. `validator_for()` has +`opendox.contracts.verified_bytes()` prove each copy before its bytes are +parsed. A compiled validator is cached under the digest that proof returned, +never under a name or a time. So a changed byte is refused on the very call +that sees it, as openxFactory's `doxbench_contracts.validators()` refuses one. + +jsonschema's SHAPE, FOR THE doxBench SEAM. `KindValidator.iter_errors()` +yields violations whose `validator` (the failed keyword) and `absolute_path` +read as a `jsonschema` error's do. Those are the two fields +`serve_workbench`'s readers of the doxBench-validators seam read. +`validators()` answers one validator per wire kind: the model catalog's whole +document, and each chat-turn envelope's own `$defs` entry, as openxFactory's +`doxbench_contracts` builds them. So T085 can register it +(`serve_wire.register_doxbench_validators`). The doxBench kinds' semantic +rules are T085's. Here they are validated structurally, as openxFactory's +`validators()` validates them. + +IMPORT WEIGHT. The standard library, and `opendox.contracts`, whose record and +copies are read with PyYAML only when a validator is built. It names no +sibling. +""" + +from __future__ import annotations + +import calendar +import hashlib +import math +import re +import reprlib +import threading +from dataclasses import dataclass +from typing import Any, Callable, Iterable, Iterator, Mapping + +from opendox import contracts + +__all__ = [ + "DEPTH_RULE", + "DIALECT", + "FORMATS", + "KEYWORDS", + "KINDS", + "KIND_ENTRIES", + "KindValidator", + "REFERENCE_RULES", + "SchemaNotEvaluable", + "UnknownKind", + "ValidatorUnavailable", + "Violation", + "report", + "validate", + "validator_for", + "validators", +] + +#: The one dialect the four copies declare, and the one this module evaluates. +DIALECT = "https://json-schema.org/draft/2020-12/schema" + +#: The rule an instance breaks when it nests deeper than the evaluation can +#: walk. It is this evaluator's own limit, and no contract's rule. A recursive +#: schema that moves into the instance recurs once per level, so a deep enough +#: instance reaches Python's recursion limit. It is judged, not crashed on +#: (`KindValidator.iter_errors`): one violation at the root, so it is never +#: valid, which is failing closed. +DEPTH_RULE = "evaluation-depth" + +#: THE INPUT SET (7.1, as batch G amends it): each instance kind openDox +#: validates, and where its schema is, as (packaged copy id, JSON pointer into +#: that copy). "" is the copy's whole document. The chat-turn copy holds three +#: envelopes, and each wire kind is validated against its own envelope, as +#: openxFactory's `doxbench_contracts.CHAT_TURN_DEFS` names them. +KIND_ENTRIES: Mapping[str, tuple[str, str]] = { + "ideation-workbench": ("ideation-workbench", ""), + "opendox-snapshot": ("opendox-snapshot", ""), + "workbench-chat-turn-v2": ("xfactory-workbench-chat-turn", "/$defs/request_v2"), + "workbench-chat-turn-v2-failure": ("xfactory-workbench-chat-turn", + "/$defs/failure_v2"), + "workbench-chat-turn-v2-success": ("xfactory-workbench-chat-turn", + "/$defs/success_v2"), + "workbench-model-catalog": ("xfactory-workbench-model-catalog", ""), +} + +#: The instance kinds, sorted. +KINDS: tuple[str, ...] = tuple(sorted(KIND_ENTRIES)) + +#: The keywords that can FAIL, and that this module evaluates. +_ASSERTING = frozenset({ + "const", "dependentRequired", "enum", "format", "maxItems", "maxLength", + "maxProperties", "maximum", "minItems", "minLength", "minProperties", + "minimum", "pattern", "required", "type", "uniqueItems"}) +#: The keywords that route a value into subschemas. +_APPLYING = frozenset({ + "$ref", "additionalProperties", "allOf", "anyOf", "contains", "if", "items", + "not", "oneOf", "properties", "propertyNames", "then"}) +#: The keywords that only carry or annotate. +_ANNOTATING = frozenset({ + "$defs", "$id", "$schema", "contract_schema_version", "description", + "title", "x-rule", "x-rules"}) +#: Every keyword this module evaluates or knows to carry nothing. A copy that +#: uses any other is refused (`SchemaNotEvaluable`). +KEYWORDS = _ASSERTING | _APPLYING | _ANNOTATING + +_TYPES = frozenset({"array", "boolean", "integer", "null", "number", "object", + "string"}) + +#: How much of a value a violation's detail quotes. +_BRIEF = 80 + + +class ValidatorUnavailable(RuntimeError): + """openDox's validator cannot run for a kind. + + Either a packaged copy failed its identity check (`opendox.contracts` + refused it), or it uses something this module does not evaluate. No + verdict was reached, and no verdict is never a pass: a caller reports the + validator as unavailable, and `--strict` makes that fatal (T058).""" + + +class SchemaNotEvaluable(ValidatorUnavailable): + """A packaged copy uses a keyword, format, dialect or reference this + module does not evaluate, gives a keyword a value of a shape it does not + evaluate, or declares reference rules it does not implement. It is + refused when its validator is built.""" + + +class UnknownKind(ValueError): + """The instance's kind is not one of openDox's own kinds (7.1). + + Raised instead of answering an empty list, which would read as valid. An + unknown kind has no schema here, so it has no verdict.""" + + +@dataclass(frozen=True) +class Violation: + """One broken rule, at one place in the instance.""" + + rule: str # the broken rule's identifier + path: tuple[str | int, ...] # where, as the instance's own keys and indices + keyword: str # the schema keyword that failed, or "reference" + detail: str # what was found + + @property + def where(self) -> str: + """`path` as a JSON pointer ("" is the instance itself). A part that + is not text or an index is shown as `_shown` shows it, so a key of any + size is named and never crashes the pointer (an int past 4300 digits + has no decimal text; Copilot at openDox-code#58 2b8ad245, + r4139823704).""" + return "".join("/" + _part_text(part).replace("~", "~0").replace("/", "~1") + for part in self.path) + + # jsonschema's names for the same fields, for the doxBench seam's readers. + @property + def validator(self) -> str: + return self.keyword + + @property + def absolute_path(self) -> tuple[str | int, ...]: + return self.path + + @property + def message(self) -> str: + return self.detail + + def line(self) -> str: + """`[] : `, the form a refusal prints.""" + return f"[{self.rule}] {self.where or ''}: {self.detail}" + + +def report(violations: Iterable[Violation]) -> list[str]: + """Each violation's `line()`, in the order they were found.""" + return [violation.line() for violation in violations] + + +# --------------------------------------------------------------------------- +# JSON values +# --------------------------------------------------------------------------- + +#: A repr that stops at a few levels, for a value too deep for `repr()`. +_SHALLOW = reprlib.Repr() +_SHALLOW.maxlevel = 3 + + +def _shown(value: Any) -> str: + """`repr(value)`, or a stand-in where it cannot be made: a value nested + past Python's limit shows its first levels, and an int past 4300 digits + (which only Python, never YAML or JSON, can hand in) shows its size.""" + try: + return repr(value) + except (RecursionError, ValueError): + try: + return _SHALLOW.repr(value) + except (RecursionError, ValueError): + if isinstance(value, int): + return f"" + return f"" + + +def _part_text(part: Any) -> str: + """A path part as pointer text: text as it is, anything else as `str()` + gives it, or as `_shown` does where `str()` cannot.""" + if isinstance(part, str): + return part + try: + return str(part) + except (RecursionError, ValueError): + return _shown(part) + + +def _brief(value: Any) -> str: + text = _shown(value) + return text if len(text) <= _BRIEF else text[:_BRIEF - 3] + "..." + + +def _brief_items(values: list[Any]) -> str: + """A list shown item by item, each as `_shown` shows it, and cut as + `_brief` cuts. So one key too large to show is named by its size, and the + others still read as themselves (Copilot at openDox-code#58 2b8ad245, + r4139823779). An ordinary list reads exactly as `_brief` shows it.""" + text = "[" + ", ".join(_shown(value) for value in values) + "]" + return text if len(text) <= _BRIEF else text[:_BRIEF - 3] + "..." + + +def _is_type(value: Any, name: str) -> bool: + if name == "object": + return isinstance(value, dict) + if name == "array": + return isinstance(value, list) + if name == "string": + return isinstance(value, str) + if name == "null": + return value is None + if name == "boolean": + return isinstance(value, bool) + if isinstance(value, bool): # JSON true is not the number 1 + return False + if name == "integer": + return isinstance(value, int) or (isinstance(value, float) and value.is_integer()) + return isinstance(value, (int, float)) # "number"; _TYPES was checked at build + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def _token(tag: str, text: str) -> str: + return f"{tag}{len(text)}:{text}" + + +def _canon_scalar(value: Any) -> str | None: + """A value that is not a list or a mapping, canonical; None for one that is.""" + if isinstance(value, bool): + return _token("b", "1" if value else "0") + if isinstance(value, int) or (isinstance(value, float) and value.is_integer()): + # 1 is 1.0. Hex, because decimal text of an int stops at 4300 digits. + return _token("n", format(int(value), "x")) + if isinstance(value, float): + return _token("f", value.hex()) + if isinstance(value, str): + return _token("s", value) + if value is None: + return _token("z", "") + if isinstance(value, (list, dict)): + return None + # Never equal to a JSON value. Where no repr can be made, the object is its + # own identity, equal to itself alone. + try: + return _token("o", repr(value)) + except (RecursionError, ValueError): + return _token("o", f"<{type(value).__name__} {id(value)}>") + + +#: What a list or mapping that contains itself canonicalizes to. A YAML alias +#: can build one, and no JSON value is one. No token begins with "!". +_ITSELF = "!itself" + + +def _canon(value: Any) -> str: + """JSON equality: `true` is not `1`, `1` is `1.0`, and key order is noise. + + The canon is text: every value is a tag, a length and its payload, and a + list or mapping is its children's canons, the mapping's sorted, so two + canons are equal exactly when their values are. It is built without + recursing, and compared and hashed as text, so a value of any depth is + judged without exhausting Python's stack (nested tuples compare + recursively, and would). A list or mapping inside itself ends as + `_ITSELF`.""" + scalar = _canon_scalar(value) + if scalar is not None: + return scalar + done: list[str] = [] # finished canons, in the order met + open_ids: set[int] = set() # the containers on the current path + work: list[tuple[bool, Any]] = [(False, value)] + while work: + closing, item = work.pop() + if closing: + open_ids.discard(id(item)) + done.append(_close(item, done)) + continue + scalar = _canon_scalar(item) + if scalar is not None: + done.append(scalar) + elif id(item) in open_ids: + done.append(_ITSELF) + else: + open_ids.add(id(item)) + work.append((True, item)) + work.extend((False, child) for child in reversed( + list(item.values()) if isinstance(item, dict) else item)) + return done[0] + + +def _close(container: list[Any] | dict[Any, Any], done: list[str]) -> str: + """The canon of `container`, from its children's canons at the end of + `done`, which it takes off.""" + count = len(container) + children = done[len(done) - count:] + del done[len(done) - count:] + if isinstance(container, list): + return f"a{count}:" + "".join(children) + # A key is text in JSON. A YAML instance can carry another scalar as a key, + # and a key is a scalar, so each key is its own canon: a text key is never + # the number it spells. + return f"d{count}:" + "".join(sorted( + _canon_scalar(key) + child for key, child in zip(container, children))) + + +def _holds_itself(value: Any) -> bool: + """Whether a list or mapping inside `value` contains itself.""" + path: set[int] = set() + work: list[tuple[bool, Any]] = [(False, value)] + while work: + leaving, node = work.pop() + if leaving: + path.discard(id(node)) + elif isinstance(node, (list, dict)): + if id(node) in path: + return True + path.add(id(node)) + work.append((True, node)) + work.extend((False, child) + for child in (node.values() if isinstance(node, dict) else node)) + return False + + +def _first_non_json(value: Any) -> str | None: + """What in `value` is not a JSON value, or None where all of it is: text, + a whole or finite number, true, false, null, and lists and mappings of + them whose keys are text. Walked without recursing; the caller has + already refused a value that contains itself.""" + work: list[Any] = [value] + while work: + node = work.pop() + if node is None or isinstance(node, (bool, str)): + continue + if _is_number(node): + if not _is_bound(node): + return f"the non-finite number {node!r}" + continue + if isinstance(node, list): + work.extend(node) + elif isinstance(node, dict): + for key, child in node.items(): + if not isinstance(key, str): + return f"a mapping key that is not text ({_brief(key)})" + work.append(child) + else: + return f"a {type(node).__name__} ({_brief(node)})" + return None + + +_DATE_TIME = re.compile( + r"([0-9]{4})-(0[1-9]|1[0-2])-([0-9]{2})T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]" + r"(?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])") + + +def _is_date_time(value: str) -> bool: + """An RFC 3339 date-time, as the consumer's validator checks one. + + That is `jsonschema`'s `date-time` checker, which is `rfc3339-validator` + over the upper-cased value: a year other than 0000, a day the month has, + and no leap second. It differs in one place, deliberately: the whole value + must match. `rfc3339-validator` anchors its pattern with `$`, which also + matches before a final newline, so it admits `...Z` followed by a + newline, and this does not.""" + match = _DATE_TIME.fullmatch(value.upper()) + if match is None: + return False + year, month, day = (int(group) for group in match.groups()) + return year != 0 and 1 <= day <= calendar.monthrange(year, month)[1] + + +#: The formats this module asserts. A copy naming another is refused. +FORMATS: Mapping[str, Callable[[str], bool]] = {"date-time": _is_date_time} + + +# --------------------------------------------------------------------------- +# the neutral snapshot's reference rules +# --------------------------------------------------------------------------- + +def _entries(snap: Any, section: str) -> list[tuple[int, dict[str, Any]]]: + items = snap.get(section) if isinstance(snap, dict) else None + if not isinstance(items, list): + return [] # absent or not a list is a shape rule's to report + return [(i, entry) for i, entry in enumerate(items) if isinstance(entry, dict)] + + +def _ids(snap: Any, section: str, key: str = "id") -> set[str]: + return {entry[key] for _i, entry in _entries(snap, section) + if isinstance(entry.get(key), str)} + + +def _broken(rule: str, path: tuple[str | int, ...], detail: str) -> Violation: + return Violation(rule, path, "reference", detail) + + +def _ids_are_unique(snap: Any) -> Iterator[Violation]: + for section, key in (("documents", "id"), ("clusters", "id"), ("possibles", "id"), + ("staged_topics", "staging_id"), ("changes", "id")): + seen: set[str] = set() + for i, entry in _entries(snap, section): + value = entry.get(key) + if isinstance(value, str): + if value in seen: + yield _broken("ids-are-unique", (section, i, key), + f"{_brief(value)} is already an id in {section}") + seen.add(value) + + +def _edges(group: dict[str, Any]) -> list[tuple[int, Any]]: + edges = group.get("document_edges") + return list(enumerate(edges if isinstance(edges, list) else [])) + + +def _edge_names_a_document(snap: Any) -> Iterator[Violation]: + known = _ids(snap, "documents") + for gi, group in _entries(snap, "clusters"): + for ei, edge in _edges(group): + ref = edge.get("document") if isinstance(edge, dict) else None + if isinstance(ref, str) and ref not in known: + yield _broken("edge-names-a-document", + ("clusters", gi, "document_edges", ei, "document"), + f"no document has the id {_brief(ref)}") + + +def _one_edge_per_document(snap: Any) -> Iterator[Violation]: + for gi, group in _entries(snap, "clusters"): + seen: set[str] = set() + for ei, edge in _edges(group): + ref = edge.get("document") if isinstance(edge, dict) else None + if isinstance(ref, str): + if ref in seen: + yield _broken("one-edge-per-document", + ("clusters", gi, "document_edges", ei, "document"), + f"{_brief(ref)} already has an edge in this group") + seen.add(ref) + + +def _candidate_names_a_group(snap: Any) -> Iterator[Violation]: + known = _ids(snap, "clusters") + for pi, candidate in _entries(snap, "possibles"): + refs = candidate.get("claiming_clusters") + for ri, ref in enumerate(refs if isinstance(refs, list) else []): + if isinstance(ref, str) and ref not in known: + yield _broken("candidate-names-a-group", + ("possibles", pi, "claiming_clusters", ri), + f"no group has the id {_brief(ref)}") + + +def _pick_names_a_selection(snap: Any) -> Iterator[Violation]: + known = _ids(snap, "staged_topics", "staging_id") + for pi, candidate in _entries(snap, "possibles"): + pick = candidate.get("pick") + ref = pick.get("staging_id") if isinstance(pick, dict) else None + if isinstance(ref, str) and ref not in known: + yield _broken("pick-names-a-selection", ("possibles", pi, "pick", "staging_id"), + f"no selection has the staging_id {_brief(ref)}") + + +def _target_names_a_submission(snap: Any) -> Iterator[Violation]: + known = _ids(snap, "changes") + for ti, selection in _entries(snap, "staged_topics"): + ref = selection.get("target_change") + if isinstance(ref, str) and ref not in known: + yield _broken("target-names-a-submission", ("staged_topics", ti, "target_change"), + f"no changes entry has the id {_brief(ref)}") + + +def _keyword_index_matches_topics(snap: Any) -> Iterator[Violation]: + """Present, the index never contradicts the documents: every topic they + carry has one entry, and each entry counts the documents that carry its + keyword. An entry for a keyword no document carries is lawful at 0.""" + index = snap.get("keyword_index") if isinstance(snap, dict) else None + if not isinstance(index, list): + return # absent is lawful; not a list is section-is-a-list's + carried: dict[str, int] = {} + for _i, document in _entries(snap, "documents"): + topics = document.get("topics") + for topic in ({t for t in topics if isinstance(t, str)} + if isinstance(topics, list) else ()): + carried[topic] = carried.get(topic, 0) + 1 + rule = "keyword-index-matches-topics" + listed: set[str] = set() + for ki, entry in enumerate(index): + keyword = entry.get("keyword") if isinstance(entry, dict) else None + if not isinstance(keyword, str): + continue # keyword-entry-keys and topic-is-trimmed-text say why + if keyword in listed: + yield _broken(rule, ("keyword_index", ki, "keyword"), + f"{_brief(keyword)} already has an entry") + continue + listed.add(keyword) + count = entry.get("declared_doc_count") + if _is_type(count, "integer") and count != carried.get(keyword, 0): + yield _broken(rule, ("keyword_index", ki, "declared_doc_count"), + f"{count} documents, but {carried.get(keyword, 0)} carry " + f"{_brief(keyword)}") + unlisted = sorted(set(carried) - listed) + if unlisted: + yield _broken(rule, ("keyword_index",), + f"topics the documents carry and the index does not list: " + f"{_brief(unlisted)}") + + +#: The reference rules this module implements, per packaged copy. Only the +#: neutral snapshot contract declares any. +REFERENCE_RULES: Mapping[str, Mapping[str, Callable[[Any], Iterator[Violation]]]] = { + "opendox-snapshot": { + "ids-are-unique": _ids_are_unique, + "edge-names-a-document": _edge_names_a_document, + "one-edge-per-document": _one_edge_per_document, + "candidate-names-a-group": _candidate_names_a_group, + "pick-names-a-selection": _pick_names_a_selection, + "target-names-a-submission": _target_names_a_submission, + "keyword-index-matches-topics": _keyword_index_matches_topics, + }, +} + + +# --------------------------------------------------------------------------- +# a copy, compiled +# --------------------------------------------------------------------------- + +def _unescape(token: str) -> str: + return token.replace("~1", "/").replace("~0", "~") + + +def _at_pointer(document: Any, pointer: str) -> Any: + """The node `pointer` (a JSON pointer, "" for the root) names in `document`.""" + node = document + if pointer == "": + return node + if not pointer.startswith("/"): + raise KeyError(pointer) + for token in pointer[1:].split("/"): + # RFC 6901 escapes only `~0` and `~1`; any other `~` makes no pointer. + if not _TOKEN.fullmatch(token): + raise KeyError(pointer) + token = _unescape(token) + if isinstance(node, dict): + node = node[token] + elif isinstance(node, list): + # A JSON pointer's array index is a plain decimal: never "-1" and + # never "01", which Python's int() would read as other elements. + if not _INDEX.fullmatch(token): + raise KeyError(pointer) + node = node[int(token)] + else: + raise KeyError(pointer) + return node + + +_INDEX = re.compile(r"0|[1-9][0-9]*") +_TOKEN = re.compile(r"(?:[^~]|~[01])*") + + +# --------------------------------------------------------------------------- +# the shapes the evaluator reads +# --------------------------------------------------------------------------- + +def _is_schema(value: Any) -> bool: + return isinstance(value, (dict, bool)) + + +def _is_count(value: Any) -> bool: + """A non-negative integer, as JSON counts one: `2.0` is 2, `true` is not 1.""" + return _is_type(value, "integer") and value >= 0 + + +def _is_bound(value: Any) -> bool: + """A number that is not infinite or NaN. Every int is finite, and + `math.isfinite()` cannot convert one past a float's range, so it is asked + only about floats.""" + return _is_number(value) and (isinstance(value, int) or math.isfinite(value)) + + +def _are_names(value: Any) -> bool: + """A list of distinct property names.""" + return (isinstance(value, list) and all(isinstance(name, str) for name in value) + and len(set(value)) == len(value)) + + +def _are_schemas(value: Any) -> bool: + return isinstance(value, list) and bool(value) and all(map(_is_schema, value)) + + +def _names_schemas(value: Any) -> bool: + return isinstance(value, dict) and all( + isinstance(name, str) and _is_schema(sub) for name, sub in value.items()) + + +def _names_names(value: Any) -> bool: + return isinstance(value, dict) and all( + isinstance(name, str) and _are_names(needed) for name, needed in value.items()) + + +def _names_types(value: Any) -> bool: + names = value if isinstance(value, list) else [value] + return (bool(names) and all(isinstance(name, str) and name in _TYPES for name in names) + and len(set(names)) == len(names)) + + +#: The shape each evaluated keyword's value must have, as draft 2020-12 gives +#: it, and how a refusal names that shape. The evaluator reads every value as +#: shaped here, so a copy whose keyword holds anything else is refused when its +#: validator is built. It is never evaluated, where it would crash on an +#: instance or judge it wrongly: a `uniqueItems: "yes"` read as true, or a +#: negative `maxLength` that no string meets. `format` and `$ref` are checked +#: beside it, with their own refusals. +_SHAPES: Mapping[str, tuple[Callable[[Any], bool], str]] = { + "$defs": (_names_schemas, "an object of schemas"), + "additionalProperties": (_is_schema, "a schema"), + "allOf": (_are_schemas, "a non-empty list of schemas"), + "anyOf": (_are_schemas, "a non-empty list of schemas"), + "contains": (_is_schema, "a schema"), + "dependentRequired": (_names_names, + "an object of lists of distinct property names"), + "enum": (lambda value: isinstance(value, list), "a list"), + "if": (_is_schema, "a schema"), + "items": (_is_schema, "a schema"), + "maxItems": (_is_count, "a non-negative integer"), + "maxLength": (_is_count, "a non-negative integer"), + "maxProperties": (_is_count, "a non-negative integer"), + "maximum": (_is_bound, "a finite number"), + "minItems": (_is_count, "a non-negative integer"), + "minLength": (_is_count, "a non-negative integer"), + "minProperties": (_is_count, "a non-negative integer"), + "minimum": (_is_bound, "a finite number"), + "not": (_is_schema, "a schema"), + "oneOf": (_are_schemas, "a non-empty list of schemas"), + "pattern": (lambda value: isinstance(value, str), "text"), + "properties": (_names_schemas, "an object of schemas"), + "propertyNames": (_is_schema, "a schema"), + "required": (_are_names, "a list of distinct property names"), + "then": (_is_schema, "a schema"), + "type": (_names_types, + f"one of {sorted(_TYPES)}, or a non-empty list of distinct ones"), + "uniqueItems": (lambda value: isinstance(value, bool), "a boolean"), + "x-rule": (lambda value: isinstance(value, str) and bool(value), + "a rule's identifier"), +} + + +class _ContainsItself(ValueError): + """A subschema is its own ancestor: a YAML alias can build one, and no walk + of it ends.""" + + +class KindValidator: + """The validator of one kind, built over one proved copy. + + `iter_errors(instance)` yields every `Violation`: the schema's, in the + order the schema is walked, and then the reference rules', in the + contract's catalog order. `violations()` lists them and `is_valid()` asks + whether there are none.""" + + def __init__(self, kind: str, copy_id: str, pointer: str, document: Any, + digest: str) -> None: + self.kind = kind + self.copy_id = copy_id + self.pointer = pointer + self.digest = digest + self._document = document + self._patterns: dict[str, re.Pattern[str]] = {} + self._refuse_what_is_not_evaluated(document, pointer) + self._entry = _at_pointer(document, pointer) # resolved, and a schema + self._reference = self._reference_rules(document) + + # -- building ----------------------------------------------------------- + + def _not_evaluable(self, detail: str) -> SchemaNotEvaluable: + return SchemaNotEvaluable( + f"openDox's validator cannot evaluate its packaged copy of " + f"{self.copy_id} for {self.kind}: {detail}") + + def _refuse_what_is_not_evaluated(self, document: Any, pointer: str) -> None: + """Refuse the copy unless the evaluator would read all of it as it + stands: the whole document, the kind's entry, and every reference's + target. A reference can reach a node that no walk of the document's + subschemas passes (one inside an `enum`, say), and the evaluator would + read that node all the same.""" + if not isinstance(document, dict): + raise self._not_evaluable(f"it is a {type(document).__name__}, not a schema") + if document.get("$schema") != DIALECT: + raise self._not_evaluable( + f"its dialect is {document.get('$schema')!r}, not {DIALECT!r}") + try: + entry = _at_pointer(document, pointer) + except (KeyError, IndexError, ValueError) as exc: + raise self._not_evaluable(f"it has no {pointer!r} for {self.kind}") from exc + if not _is_schema(entry): + raise self._not_evaluable(f"its {pointer!r} for {self.kind} is a " + f"{type(entry).__name__}, not a schema") + nodes: dict[int, tuple[str, dict[str, Any]]] = {} + pending: list[tuple[str, Any]] = [("", document), (pointer, entry)] + try: + while pending: + start, subtree = pending.pop() + for at, node in _subschemas(subtree, start): + if id(node) not in nodes: + nodes[id(node)] = (at, node) + pending.extend(self._refuse_node(document, at, node)) + except _ContainsItself as exc: + raise self._not_evaluable( + f"{exc.args[0] or ''} contains itself; this module evaluates a " + "schema that is a tree, and a copy repeats itself only by " + "reference") from None + except RecursionError: + raise self._not_evaluable("it nests deeper than this module walks") from None + cycle = _cycle_in_place(nodes, document) + if cycle: + raise self._not_evaluable( + f"{' -> '.join(cycle)} is a cycle of subschemas that apply one another " + "at one place in the instance, so no evaluation of it ends; a " + "recursive schema moves into the instance (a property, an item) " + "before it recurs") + + def _refuse_node(self, document: dict[str, Any], at: str, + node: dict[str, Any]) -> list[tuple[str, Any]]: + """Refuse `node` unless this module evaluates it as it stands, and + answer its reference's target, for the walk to check in its turn.""" + where = at or "" + unknown = sorted((key for key in node if key not in KEYWORDS), key=_shown) + if unknown: + raise self._not_evaluable(f"{where} uses {unknown}, which " + "this module does not evaluate") + if at and ("$id" in node or "$schema" in node): + raise self._not_evaluable( + f"{where} is an embedded resource (it carries its own " + f"{'$id' if '$id' in node else '$schema'}), which would move where " + "its references resolve; this module resolves every reference " + "against the copy's root") + for keyword in ("const", "enum"): + if keyword in node and _holds_itself(node[keyword]): + raise self._not_evaluable( + f"{where}'s {keyword} holds a value that contains itself, " + "which no JSON value does") + # AND ONLY JSON VALUES (Copilot at openDox-code#58 cb40b977, + # r4139739110). YAML builds sets, bytes and dates, which no JSON + # value is, and a `const: !!set {a: null}` would otherwise build + # and accept an equal set. Checked after the cycle test, so the + # walk ends. + stray = _first_non_json(node[keyword]) if keyword in node else None + if stray is not None: + raise self._not_evaluable( + f"{where}'s {keyword} holds {stray}, which is not a JSON " + "value") + for keyword, value in node.items(): + shape = _SHAPES.get(keyword) + if shape is not None and not shape[0](value): + raise self._not_evaluable( + f"{where}'s {keyword} is {_brief(value)}, and this module " + f"evaluates {keyword} only as {shape[1]}") + if "format" in node and not (isinstance(node["format"], str) + and node["format"] in FORMATS): + raise self._not_evaluable( + f"{where} names the format {_brief(node['format'])}, which this module " + f"does not assert (it asserts {sorted(FORMATS)})") + if "pattern" in node: + try: + self._patterns[node["pattern"]] = re.compile(node["pattern"]) + except (re.error, OverflowError, RecursionError) as exc: + raise self._not_evaluable( + f"{where}'s pattern does not compile ({exc})") from exc + return self._reference_target(document, where, node) if "$ref" in node else [] + + def _reference_target(self, document: dict[str, Any], where: str, + node: dict[str, Any]) -> list[tuple[str, Any]]: + """What `node`'s reference names, as (pointer, target), for the walk. + Refused unless the target is a schema inside the copy itself.""" + ref = node["$ref"] + if not isinstance(ref, str) or not (ref == "#" or ref.startswith("#/")): + raise self._not_evaluable( + f"{where} refers to {_brief(ref)}; only a reference inside the copy " + "is evaluated") + if "%" in ref: + # A reference's fragment is percent-encoded (RFC 3986), and this + # module does not decode it: read literally, `a%20b` would name + # another key than the `a b` jsonschema resolves. + raise self._not_evaluable( + f"{where} refers to {ref!r}, a percent-encoded fragment, which this " + "module does not decode") + try: + target = _at_pointer(document, ref[1:]) + except (KeyError, IndexError, ValueError) as exc: + raise self._not_evaluable(f"{where}'s reference {ref!r} names " + "nothing in the copy") from exc + if not _is_schema(target): + raise self._not_evaluable(f"{where}'s reference {ref!r} names a " + f"{type(target).__name__}, not a schema") + return [(ref[1:], target)] + + def _reference_rules(self, document: dict[str, Any] + ) -> tuple[Callable[[Any], Iterator[Violation]], ...]: + implemented = REFERENCE_RULES.get(self.copy_id, {}) + catalog = document.get("x-rules", []) + if not isinstance(catalog, list) or not all( + isinstance(rule, dict) and isinstance(rule.get("id"), str) + for rule in catalog): + raise self._not_evaluable("its x-rules catalog is not a list of rules " + "that each carry an id") + declared = [rule["id"] for rule in catalog if rule.get("class") == "reference"] + if sorted(declared) != sorted(implemented): + raise self._not_evaluable( + f"its catalog declares the reference rules {sorted(declared)}, and " + f"this module implements {sorted(implemented)}; a reference rule " + "is enforced only when both name it") + return tuple(implemented[rule] for rule in declared) + + # -- evaluating --------------------------------------------------------- + + def iter_errors(self, instance: Any) -> Iterator[Violation]: + try: + yield from self._evaluate(instance, self._entry, ()) + except RecursionError: + # JUDGED, NOT CRASHED ON (Copilot at openDox-code#58 27bcefc0, + # r4136329332). A recursive schema that moves into the instance + # recurs once per level, so an instance nested past what Python's + # recursion limit lets the walk reach raised out of the validator. + # The walk's frames have unwound by the time this is yielded. + # What it found before the limit stands, and one violation names + # the limit, so the instance is never valid. + yield Violation(DEPTH_RULE, (), "depth", + "the instance nests deeper than this validator's " + "evaluation can walk (Python's recursion limit), so " + "it is not judged valid") + for check in self._reference: + yield from check(instance) + + def violations(self, instance: Any) -> list[Violation]: + return list(self.iter_errors(instance)) + + def is_valid(self, instance: Any) -> bool: + return next(self.iter_errors(instance), None) is None + + def _valid(self, value: Any, schema: Any, path: tuple[str | int, ...]) -> bool: + return next(self._evaluate(value, schema, path), None) is None + + def _pattern(self, pattern: str) -> re.Pattern[str]: + """The compiled pattern. The build walks every subschema the evaluator + can reach, references' targets included, and compiles each pattern it + meets, so this reads what the build compiled.""" + return self._patterns[pattern] + + def _evaluate(self, value: Any, schema: Any, + path: tuple[str | int, ...]) -> Iterator[Violation]: + if schema is True: + return + if schema is False: + yield Violation("false", path, "false", "no value is valid here") + return + named = schema.get("x-rule") + + def broken(keyword: str, detail: str) -> Violation: + return Violation(named or keyword, path, keyword, detail) + + if "$ref" in schema: + # Draft 2020-12: a reference applies BESIDE its sibling keywords. + yield from self._evaluate(value, _at_pointer(self._document, schema["$ref"][1:]), + path) + if "type" in schema: + names = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(_is_type(value, name) for name in names): + yield broken("type", f"{_brief(value)} is not of type {' or '.join(names)}") + if "const" in schema and _canon(value) != _canon(schema["const"]): + yield broken("const", f"{_brief(value)} is not {_brief(schema['const'])}") + if "enum" in schema and _canon(value) not in {_canon(v) for v in schema["enum"]}: + yield broken("enum", f"{_brief(value)} is not one of {_brief(schema['enum'])}") + if isinstance(value, str): + yield from self._string(value, schema, broken) + if _is_number(value): + if "minimum" in schema and value < schema["minimum"]: + yield broken("minimum", f"{_brief(value)} is less than {_brief(schema['minimum'])}") + if "maximum" in schema and value > schema["maximum"]: + yield broken("maximum", f"{_brief(value)} is more than {_brief(schema['maximum'])}") + if isinstance(value, list): + yield from self._array(value, schema, path, broken) + if isinstance(value, dict): + yield from self._object(value, schema, path, broken) + for sub in schema.get("allOf", ()): + yield from self._evaluate(value, sub, path) + if "anyOf" in schema: + if not any(self._valid(value, sub, path) for sub in schema["anyOf"]): + yield broken("anyOf", f"{_brief(value)} is valid under none of the " + f"{len(schema['anyOf'])} subschemas") + if "oneOf" in schema: + valid = [i for i, sub in enumerate(schema["oneOf"]) if self._valid(value, sub, path)] + if len(valid) != 1: + yield broken("oneOf", f"{_brief(value)} is valid under " + + ("none" if not valid else f"subschemas {valid}") + + f" of the {len(schema['oneOf'])}, not exactly one") + if "not" in schema and self._valid(value, schema["not"], path): + yield broken("not", f"{_brief(value)} is valid under the subschema `not` refuses") + # `if` is a test, never a failure: it only chooses whether `then` applies. + if "if" in schema and "then" in schema and self._valid(value, schema["if"], path): + yield from self._evaluate(value, schema["then"], path) + + def _string(self, value: str, schema: dict[str, Any], + broken: Callable[[str, str], Violation]) -> Iterator[Violation]: + if len(value) < schema.get("minLength", 0): + yield broken("minLength", f"{_brief(value)} is shorter than {_brief(schema['minLength'])}") + if "maxLength" in schema and len(value) > schema["maxLength"]: + yield broken("maxLength", f"{_brief(value)} is longer than {_brief(schema['maxLength'])}") + if "pattern" in schema and not self._pattern(schema["pattern"]).search(value): + yield broken("pattern", f"{_brief(value)} does not match the rule's pattern") + if "format" in schema and not FORMATS[schema["format"]](value): + yield broken("format", f"{_brief(value)} is not a {schema['format']}") + + def _array(self, value: list[Any], schema: dict[str, Any], + path: tuple[str | int, ...], + broken: Callable[[str, str], Violation]) -> Iterator[Violation]: + if len(value) < schema.get("minItems", 0): + yield broken("minItems", f"{len(value)} items, fewer than {_brief(schema['minItems'])}") + if "maxItems" in schema and len(value) > schema["maxItems"]: + yield broken("maxItems", f"{len(value)} items, more than {_brief(schema['maxItems'])}") + if schema.get("uniqueItems") and len({_canon(v) for v in value}) != len(value): + yield broken("uniqueItems", f"{_brief(value)} repeats an item") + if "items" in schema: + for i, item in enumerate(value): + yield from self._evaluate(item, schema["items"], path + (i,)) + if "contains" in schema and not any( + self._valid(item, schema["contains"], path + (i,)) + for i, item in enumerate(value)): + yield broken("contains", "no item is valid under the subschema `contains` asks for") + + def _object(self, value: dict[str, Any], schema: dict[str, Any], + path: tuple[str | int, ...], + broken: Callable[[str, str], Violation]) -> Iterator[Violation]: + for key in schema.get("required", ()): + if key not in value: + yield broken("required", f"{key!r} is required") + if len(value) < schema.get("minProperties", 0): + yield broken("minProperties", + f"{len(value)} properties, fewer than {_brief(schema['minProperties'])}") + if "maxProperties" in schema and len(value) > schema["maxProperties"]: + yield broken("maxProperties", + f"{len(value)} properties, more than {_brief(schema['maxProperties'])}") + for key, needed in schema.get("dependentRequired", {}).items(): + if key in value: + for other in needed: + if other not in value: + yield broken("dependentRequired", + f"{other!r} is required beside {key!r}") + properties = schema.get("properties", {}) + for key, sub in properties.items(): + if key in value: + yield from self._evaluate(value[key], sub, path + (key,)) + if "additionalProperties" in schema: + extra = [key for key in value if key not in properties] + extra_schema = schema["additionalProperties"] + if extra_schema is False: + if extra: + yield broken("additionalProperties", + f"unexpected properties " + f"{_brief_items(sorted(extra, key=_shown))}") + else: + for key in extra: + yield from self._evaluate(value[key], extra_schema, path + (key,)) + if "propertyNames" in schema: + # jsonschema does not extend the path for a name: the object is where. + for key in value: + for found in self._evaluate(key, schema["propertyNames"], path): + yield Violation(found.rule, path, found.keyword, + f"the property name {_brief(key)}: {found.detail}") + + +def _subschemas(node: Any, at: str = "", _above: frozenset[int] = frozenset() + ) -> Iterator[tuple[str, dict[str, Any]]]: + """Every subschema of `node` that is an object, with its location as a JSON + pointer. A node is yielded before its subschemas are walked, so a caller + that refuses a malformed node stops the walk before it reads what the node + holds. Raises `_ContainsItself` at a subschema that is its own ancestor.""" + if not isinstance(node, dict): + return + if id(node) in _above: + raise _ContainsItself(at) + yield at, node + above = _above | {id(node)} + for key in ("properties", "$defs"): + for name, sub in node.get(key, {}).items() if isinstance(node.get(key), dict) else (): + yield from _subschemas(sub, f"{at}/{key}/{_escape(name)}", above) + for key in ("additionalProperties", "items", "contains", "propertyNames", "not", + "if", "then"): + if key in node: + yield from _subschemas(node[key], f"{at}/{key}", above) + for key in ("allOf", "anyOf", "oneOf"): + for i, sub in enumerate(node.get(key, ())): + yield from _subschemas(sub, f"{at}/{key}/{i}", above) + + +def _escape(token: Any) -> str: + """A JSON pointer's reference token for a key.""" + return str(token).replace("~", "~0").replace("/", "~1") + + +def _in_place(node: dict[str, Any], document: dict[str, Any]) -> Iterator[Any]: + """The subschemas `node` applies at the same place in the instance as + itself: its reference's target, its `allOf`, `anyOf` and `oneOf` branches, + its `not`, and its `if` and `then` when it has both (the evaluator reads + neither alone). Every other applicator moves into the instance: to a + property, an item, or a property's name.""" + if "$ref" in node: + yield _at_pointer(document, node["$ref"][1:]) + for key in ("allOf", "anyOf", "oneOf"): + yield from node.get(key, ()) + if "not" in node: + yield node["not"] + if "if" in node and "then" in node: + yield node["if"] + yield node["then"] + + +def _in_place_ids(node: dict[str, Any], nodes: Mapping[int, Any], + document: dict[str, Any]) -> Iterator[int]: + return (id(sub) for sub in _in_place(node, document) if id(sub) in nodes) + + +def _cycle_in_place(nodes: Mapping[int, tuple[str, dict[str, Any]]], + document: dict[str, Any]) -> list[str]: + """The locations around a cycle of subschemas that apply one another at + one place in the instance, or [] when the copy has none. Evaluating such a + cycle never moves into the instance, so it never ends, whatever the + instance. (jsonschema recurses until Python's limit.)""" + done: set[int] = set() + for start in nodes: + loop = [] if start in done else _cycle_from(start, nodes, document, done) + if loop: + return [nodes[node_id][0] or "" for node_id in loop] + return [] + + +def _cycle_from(start: int, nodes: Mapping[int, tuple[str, dict[str, Any]]], + document: dict[str, Any], done: set[int]) -> list[int]: + """Depth first from `start`, without recursing: the first cycle met, as + the ids around it, or [] once every node reached is marked done.""" + trail = [start] + branches = [_in_place_ids(nodes[start][1], nodes, document)] + while branches: + step = next(branches[-1], None) + if step is None: + done.add(trail.pop()) + branches.pop() + elif step in trail: + return trail[trail.index(step):] + [step] + elif step not in done: + trail.append(step) + branches.append(_in_place_ids(nodes[step][1], nodes, document)) + return [] + + +# --------------------------------------------------------------------------- +# the validators, over copies proved on every call +# --------------------------------------------------------------------------- + +_CACHE: dict[tuple[str, str], KindValidator] = {} +_CACHE_LOCK = threading.Lock() + + +def validator_for(kind: str) -> KindValidator: + """The validator of `kind`, over its packaged copy, proved on this call. + + Raises `UnknownKind` for a kind outside the input set, and + `ValidatorUnavailable` when the copy fails its identity check or uses + something this module does not evaluate.""" + if kind not in KIND_ENTRIES: + raise UnknownKind( + f"{kind!r} is not one of openDox's own kinds; openDox's validator " + f"validates {list(KINDS)} (#1144 7.1)") + copy_id, pointer = KIND_ENTRIES[kind] + try: + data = contracts.verified_bytes(copy_id) + except contracts.CopyRefused as exc: + raise ValidatorUnavailable(str(exc)) from exc + key = (kind, hashlib.sha256(data).hexdigest()) + with _CACHE_LOCK: + cached = _CACHE.get(key) + if cached is not None: + return cached + import yaml + + try: + document = yaml.safe_load(data) + except (yaml.YAMLError, RecursionError, ValueError) as exc: + raise ValidatorUnavailable( + f"the packaged copy of {copy_id} matches its digest but is not YAML " + f"({exc.__class__.__name__})") from exc + built = KindValidator(kind, copy_id, pointer, document, key[1]) + with _CACHE_LOCK: + return _CACHE.setdefault(key, built) + + +def validators() -> dict[str, KindValidator]: + """One validator per kind, every copy proved on this call. A fresh dict, + so a caller changing its copy changes no other caller's.""" + return {kind: validator_for(kind) for kind in KINDS} + + +def validate(instance: Any, *, kind: str | None = None) -> list[Violation]: + """Every rule of `kind`'s contract that `instance` breaks. + + `kind` names the contract the instance must meet, as a generator declares + the contract it writes (`generator_seam.SnapshotGenerator.contract`). Given + one, the instance's own `kind` field is checked by that contract. Without + one, the instance's own `kind` field picks the contract, and a kind that is + not openDox's raises `UnknownKind`.""" + if kind is None: + kind = instance.get("kind") if isinstance(instance, dict) else None + if not isinstance(kind, str) or kind not in KIND_ENTRIES: + raise UnknownKind( + f"the instance's kind is {_brief(kind)}, which is not one of " + f"openDox's own kinds {list(KINDS)} (#1144 7.1)") + return validator_for(kind).violations(instance) diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-keys.negative.yaml new file mode 100644 index 00000000..ca2d3fc3 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-keys.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: candidate-keys +# INVALID: a candidate carries no `title`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [{"id": "loaf-club", "state": "unselected"}], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-names-a-group.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-names-a-group.negative.yaml new file mode 100644 index 00000000..8f8b0543 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-names-a-group.negative.yaml @@ -0,0 +1,57 @@ +# expected_failure: candidate-names-a-group +# INVALID: a candidate is claimed by a group the snapshot does not hold. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [ + { + "id": "loaf-club", + "title": "A weekly loaf club", + "state": "unselected", + "claiming_clusters": ["no-such-group"] + } + ], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-state-is-known.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-state-is-known.negative.yaml new file mode 100644 index 00000000..79ec8eb8 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-candidate-state-is-known.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: candidate-state-is-known +# INVALID: a candidate's state is not one of the four. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [{"id": "loaf-club", "title": "A weekly loaf club", "state": "maybe"}], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-closed-candidate-has-reason.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-closed-candidate-has-reason.negative.yaml new file mode 100644 index 00000000..489e6c63 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-closed-candidate-has-reason.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: closed-candidate-has-reason +# INVALID: a declined candidate carries no `reason`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [{"id": "loaf-club", "title": "A weekly loaf club", "state": "declined"}], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-document-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-document-keys.negative.yaml new file mode 100644 index 00000000..f05eb926 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-document-keys.negative.yaml @@ -0,0 +1,43 @@ +# expected_failure: document-keys +# INVALID: a document carries no `stage`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + {"id": "garden", "path": "garden.md", "title": null, "summary": null, "topics": ["herbs"]} + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-keys.negative.yaml new file mode 100644 index 00000000..c43d8146 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-keys.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: edge-keys +# INVALID: a group edge carries no `matched_topics`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen"}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-names-a-document.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-names-a-document.negative.yaml new file mode 100644 index 00000000..189d97e5 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-edge-names-a-document.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: edge-names-a-document +# INVALID: a group edge names a document the snapshot does not hold. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "no-such-document", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-envelope-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-envelope-keys.negative.yaml new file mode 100644 index 00000000..2261d5f3 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-envelope-keys.negative.yaml @@ -0,0 +1,49 @@ +# expected_failure: envelope-keys +# INVALID: the snapshot has no `changes` section. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-generated-at-is-rfc3339.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-generated-at-is-rfc3339.negative.yaml new file mode 100644 index 00000000..5b94b526 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-generated-at-is-rfc3339.negative.yaml @@ -0,0 +1,53 @@ +# expected_failure: generated-at-is-rfc3339 +# INVALID: `generated_at` names a day that February does not have. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": { + "source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567", + "generated_at": "2026-02-31T12:00:00Z" + }, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-generation-anchored.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-generation-anchored.negative.yaml new file mode 100644 index 00000000..e1e69529 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-generation-anchored.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: generation-anchored +# INVALID: `generation` carries no `source_revision`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"generated_at": "2026-09-27T12:00:00Z"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-group-has-a-topic.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-group-has-a-topic.negative.yaml new file mode 100644 index 00000000..90cbd4c1 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-group-has-a-topic.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: group-has-a-topic +# INVALID: a group names no topic. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": [], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-group-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-group-keys.negative.yaml new file mode 100644 index 00000000..0dd56c3a --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-group-keys.negative.yaml @@ -0,0 +1,49 @@ +# expected_failure: group-keys +# INVALID: a group carries no `name`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-id-is-text.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-id-is-text.negative.yaml new file mode 100644 index 00000000..bf966223 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-id-is-text.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: id-is-text +# INVALID: an entry in `changes` has an empty id. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [{"id": "", "status": "active"}] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-ids-are-unique.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-ids-are-unique.negative.yaml new file mode 100644 index 00000000..a4bdf242 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-ids-are-unique.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: ids-are-unique +# INVALID: two documents share one id. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "kitchen", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-entry-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-entry-keys.negative.yaml new file mode 100644 index 00000000..b616092e --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-entry-keys.negative.yaml @@ -0,0 +1,56 @@ +# expected_failure: keyword-entry-keys +# INVALID: a keyword_index entry's count is text, not a whole number. +# The no-front-matter example with a correct keyword_index added, and +# this one change, so it breaks this shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [], + "keyword_index": [ + {"keyword": "bread", "declared_doc_count": 2}, + {"keyword": "flour", "declared_doc_count": 1}, + {"keyword": "herbs", "declared_doc_count": 1}, + {"keyword": "oven", "declared_doc_count": "one"} + ] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-index-matches-topics.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-index-matches-topics.negative.yaml new file mode 100644 index 00000000..1227fef6 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-keyword-index-matches-topics.negative.yaml @@ -0,0 +1,56 @@ +# expected_failure: keyword-index-matches-topics +# INVALID: keyword_index counts three documents for a topic two carry. +# The no-front-matter example with a correct keyword_index added, and +# this one change, so it breaks this reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [], + "keyword_index": [ + {"keyword": "bread", "declared_doc_count": 3}, + {"keyword": "flour", "declared_doc_count": 1}, + {"keyword": "herbs", "declared_doc_count": 1}, + {"keyword": "oven", "declared_doc_count": 1} + ] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-kind-is-opendox-snapshot.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-kind-is-opendox-snapshot.negative.yaml new file mode 100644 index 00000000..d8bdb8b3 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-kind-is-opendox-snapshot.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: kind-is-opendox-snapshot +# INVALID: `kind` names the governed generator's contract. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "ideation-dashboard-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-one-edge-per-document.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-one-edge-per-document.negative.yaml new file mode 100644 index 00000000..d1cb95fe --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-one-edge-per-document.negative.yaml @@ -0,0 +1,51 @@ +# expected_failure: one-edge-per-document +# INVALID: a group gives one document a second edge. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]}, + {"document": "kitchen", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-path-is-repo-relative.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-path-is-repo-relative.negative.yaml new file mode 100644 index 00000000..339b0c67 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-path-is-repo-relative.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: path-is-repo-relative +# INVALID: a document's path climbs out of the repository. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "../outside/garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-pick-names-a-selection.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-pick-names-a-selection.negative.yaml new file mode 100644 index 00000000..1d5d9514 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-pick-names-a-selection.negative.yaml @@ -0,0 +1,57 @@ +# expected_failure: pick-names-a-selection +# INVALID: a selected candidate's pick names no selection. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [ + { + "id": "loaf-club", + "title": "A weekly loaf club", + "state": "selected", + "pick": {"staging_id": "no-such-selection"} + } + ], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-repository-is-text.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-repository-is-text.negative.yaml new file mode 100644 index 00000000..4ba82e43 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-repository-is-text.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: repository-is-text +# INVALID: `repository` is empty. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-schema-version-is-1.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-schema-version-is-1.negative.yaml new file mode 100644 index 00000000..8c27d842 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-schema-version-is-1.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: schema-version-is-1 +# INVALID: `schema_version` is 2. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 2, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-section-is-a-list.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-section-is-a-list.negative.yaml new file mode 100644 index 00000000..183a28ce --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-section-is-a-list.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: section-is-a-list +# INVALID: `possibles` is an object, not a list. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": {}, + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-selected-candidate-has-pick.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-selected-candidate-has-pick.negative.yaml new file mode 100644 index 00000000..9e80bdde --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-selected-candidate-has-pick.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: selected-candidate-has-pick +# INVALID: a selected candidate carries no `pick`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [{"id": "loaf-club", "title": "A weekly loaf club", "state": "selected"}], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-selection-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-selection-keys.negative.yaml new file mode 100644 index 00000000..d2c54587 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-selection-keys.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: selection-keys +# INVALID: a selection carries no `staging_id`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [{"files": ["garden.md"]}], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-stage-is-a-station-role.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-stage-is-a-station-role.negative.yaml new file mode 100644 index 00000000..51299b5b --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-stage-is-a-station-role.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: stage-is-a-station-role +# INVALID: a document's stage is not one of the six role keys. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "idea", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-keys.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-keys.negative.yaml new file mode 100644 index 00000000..fd28e69d --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-keys.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: submission-keys +# INVALID: an entry in `changes` carries no `status`. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [{"id": "bake-sale"}] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-status-is-known.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-status-is-known.negative.yaml new file mode 100644 index 00000000..8f05c328 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-submission-status-is-known.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: submission-status-is-known +# INVALID: an entry in `changes` has a status neither station reads. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [{"id": "bake-sale", "status": "open"}] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-target-names-a-submission.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-target-names-a-submission.negative.yaml new file mode 100644 index 00000000..b7653024 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-target-names-a-submission.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: target-names-a-submission +# INVALID: a selection's target names no entry in `changes`. +# The no-front-matter example with this one change, so it breaks this +# reference rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [{"staging_id": "loaf-club", "target_change": "no-such-submission"}], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-title-and-summary-are-text.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-title-and-summary-are-text.negative.yaml new file mode 100644 index 00000000..8701fbb4 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-title-and-summary-are-text.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: title-and-summary-are-text +# INVALID: a document's title is empty text. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": "", + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-topic-is-trimmed-text.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-topic-is-trimmed-text.negative.yaml new file mode 100644 index 00000000..c8a7b2a1 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-topic-is-trimmed-text.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: topic-is-trimmed-text +# INVALID: a document's topic carries trailing whitespace. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs "] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/negative/opendox-snapshot-topics-are-unique.negative.yaml b/tests/fixtures/spec-examples/negative/opendox-snapshot-topics-are-unique.negative.yaml new file mode 100644 index 00000000..9880af37 --- /dev/null +++ b/tests/fixtures/spec-examples/negative/opendox-snapshot-topics-are-unique.negative.yaml @@ -0,0 +1,50 @@ +# expected_failure: topics-are-unique +# INVALID: a document names one topic twice. +# The no-front-matter example with this one change, so it breaks this +# shape rule and no other. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs", "herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/opendox-snapshot-no-front-matter.example.yaml b/tests/fixtures/spec-examples/opendox-snapshot-no-front-matter.example.yaml new file mode 100644 index 00000000..296f9648 --- /dev/null +++ b/tests/fixtures/spec-examples/opendox-snapshot-no-front-matter.example.yaml @@ -0,0 +1,54 @@ +# VALID: a plain repository whose documents carry no front matter at all. +# +# Every document is a source, and title and summary are null. The topics are +# the ones a generator's topic rule derived (T054 names that rule; this +# contract does not). One group forms around the topic two documents share, +# which is the grouping tile AT-R1 opens the chat pane from. The other stations +# are empty lists, keyword_index is absent, and so is generated_at: this is the +# smallest shape the contract admits. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "plain-notes", + "generation": {"source_revision": "0a1b2c3d4e5f60718293a4b5c6d7e8f901234567"}, + "documents": [ + { + "id": "kitchen", + "path": "kitchen.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "oven"] + }, + { + "id": "bakery-visit", + "path": "bakery-visit.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["bread", "flour"] + }, + { + "id": "garden", + "path": "garden.md", + "stage": "source", + "title": null, + "summary": null, + "topics": ["herbs"] + } + ], + "clusters": [ + { + "id": "bread", + "name": "bread", + "topics": ["bread"], + "document_edges": [ + {"document": "kitchen", "matched_topics": ["bread"]}, + {"document": "bakery-visit", "matched_topics": ["bread"]} + ] + } + ], + "possibles": [], + "staged_topics": [], + "changes": [] +} diff --git a/tests/fixtures/spec-examples/opendox-snapshot-six-stations.example.yaml b/tests/fixtures/spec-examples/opendox-snapshot-six-stations.example.yaml new file mode 100644 index 00000000..188e669c --- /dev/null +++ b/tests/fixtures/spec-examples/opendox-snapshot-six-stations.example.yaml @@ -0,0 +1,164 @@ +# VALID: a plain repository whose documents sit in all six stations. +# +# Each document declares its station with the neutral `stage:` key (R1Q13 (a)); +# the three that declare `source` could as well have declared nothing. Three +# groups form around shared topics. The candidates show all four states, and +# the selected one's pick names the selection, whose target names the entry in +# the submission station. Each changes entry lists its files, as the selection +# does. keyword_index is present, which it need not be. +{ + "schema_version": 1, + "kind": "opendox-snapshot", + "repository": "community-garden", + "generation": { + "source_revision": "4f2b9c0d1e3a5b7c9d0e2f4a6b8c0d1e3f5a7b9c", + "generated_at": "2026-09-27T12:00:00Z", + "generator_version": "opendox-neutral-1" + }, + "documents": [ + { + "id": "notes/soil-test", + "path": "notes/soil-test.md", + "stage": "source", + "title": "Soil test results", + "summary": "pH and nutrient readings from the three beds.", + "topics": ["compost", "soil"] + }, + { + "id": "notes/compost-bins", + "path": "notes/compost-bins.md", + "stage": "source", + "title": "Compost bins", + "summary": "How the two bins are turned, and when.", + "topics": ["compost", "reuse"] + }, + { + "id": "notes/rain-barrels", + "path": "notes/rain-barrels.md", + "stage": "source", + "title": "Rain barrels", + "summary": null, + "topics": ["reuse", "water"] + }, + { + "id": "notes/watering", + "path": "notes/watering.md", + "stage": "grouping", + "title": "Watering, gathered", + "summary": "Everything on watering, in one place.", + "topics": ["water"] + }, + { + "id": "ideas/tool-library", + "path": "ideas/tool-library.md", + "stage": "candidate", + "title": "A shared tool library", + "summary": "Lend tools between plots instead of buying twice.", + "topics": ["reuse", "tools"] + }, + { + "id": "plans/rain-harvest", + "path": "plans/rain-harvest.md", + "stage": "selection", + "title": "Rain harvest plan", + "summary": "Chosen: gutters to barrels to beds.", + "topics": ["water"] + }, + { + "id": "submissions/rain-harvest-build", + "path": "submissions/rain-harvest-build.md", + "stage": "submission", + "title": "Build the rain harvest", + "summary": null, + "topics": ["water"] + }, + { + "id": "done/compost-rota", + "path": "done/compost-rota.md", + "stage": "completion", + "title": "Compost turning rota", + "summary": "Adopted in the spring.", + "topics": ["compost"] + } + ], + "clusters": [ + { + "id": "compost", + "name": "compost", + "topics": ["compost"], + "document_edges": [ + {"document": "notes/soil-test", "matched_topics": ["compost"]}, + {"document": "notes/compost-bins", "matched_topics": ["compost"]} + ] + }, + { + "id": "reuse", + "name": "reuse", + "topics": ["reuse"], + "document_edges": [ + {"document": "notes/compost-bins", "matched_topics": ["reuse"]}, + {"document": "notes/rain-barrels", "matched_topics": ["reuse"]} + ] + }, + { + "id": "watering", + "name": "Watering, gathered", + "topics": ["water"], + "document_edges": [ + {"document": "notes/rain-barrels", "matched_topics": ["water"]}, + {"document": "notes/watering", "matched_topics": ["water"]} + ] + } + ], + "possibles": [ + { + "id": "tool-library", + "title": "A shared tool library", + "claim": "Lend tools between plots instead of buying twice.", + "state": "unselected", + "claiming_clusters": ["reuse"] + }, + { + "id": "rain-harvest", + "title": "Harvest rain from the shed roof", + "state": "selected", + "claiming_clusters": ["watering"], + "pick": {"staging_id": "rain-harvest"} + }, + { + "id": "paved-paths", + "title": "Pave the paths", + "state": "declined", + "reason": "Paving stops the rain soaking into the beds.", + "claiming_clusters": ["watering"] + }, + { + "id": "bagged-compost", + "title": "Buy bagged compost", + "state": "replaced", + "reason": "The two bins make enough; the turning rota took its place." + } + ], + "staged_topics": [ + { + "staging_id": "rain-harvest", + "files": ["plans/rain-harvest.md"], + "target_change": "rain-harvest-build" + } + ], + "changes": [ + { + "id": "rain-harvest-build", + "status": "active", + "files": ["work/rain-harvest-build/plan.md", "work/rain-harvest-build/steps.md"] + }, + {"id": "compost-rota", "status": "archived", "files": ["work/compost-rota/plan.md"]} + ], + "keyword_index": [ + {"keyword": "compost", "declared_doc_count": 3}, + {"keyword": "reuse", "declared_doc_count": 3}, + {"keyword": "soil", "declared_doc_count": 1}, + {"keyword": "tools", "declared_doc_count": 1}, + {"keyword": "water", "declared_doc_count": 4} + ] +} diff --git a/tests/fixtures/spec-examples/workbench-chat-turn-v2-full-context.example.yaml b/tests/fixtures/spec-examples/workbench-chat-turn-v2-full-context.example.yaml new file mode 100644 index 00000000..7d3d3a3e --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-chat-turn-v2-full-context.example.yaml @@ -0,0 +1,33 @@ +# VALID widened success (contract-v1.40): the ORDINARY posture, STATED. The +# knowledge service answered, the packet was assembled full, and the record says +# so explicitly rather than leaving it to be inferred from a missing key. +# +# THE PAIR TO THIS INSTANCE IS `workbench-chat-turn-v2-success.example.yaml`, +# which carries NO `context_packet` at all and is still valid: that is what makes +# this release additive, and it is why absence must never be read as `full`. A +# record without the key means its producer predates contract-v1.40; a record +# with `posture: full` means the packet was assembled full and someone checked. +# Both are conformant, and a consumer must be able to tell them apart — which it +# can, because they are different bytes. +{ + "schema_version": 1, + "kind": "workbench-chat-turn-v2-success", + "client_turn_id": "turn-v2-0004", + "assistant_turn_id": "srv-v2-0004", + "model_id": "local-authoring-1", + "selected_model": { + "requested_model_id": "local-authoring-1", + "routing_rule": false, + "data_handling": "Local process only; no content leaves this machine." + }, + "context_packet": { + "posture": "full" + }, + "bound_buffer": "ideation/staging/demo-topic/note.md", + "observed_hashes": { + "outline": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "ideation/staging/demo-topic/note.md": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825" + }, + "assistant_prose": "The staged set has two notes that bear on the boundary the outline claims; both are behind this answer.", + "proposals": [] +} diff --git a/tests/fixtures/spec-examples/workbench-chat-turn-v2-loaded-set.example.yaml b/tests/fixtures/spec-examples/workbench-chat-turn-v2-loaded-set.example.yaml new file mode 100644 index 00000000..bace8815 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-chat-turn-v2-loaded-set.example.yaml @@ -0,0 +1,58 @@ +# VALID widened request (contract-v1.34, add-doxbench-editing-phase-b design +# D15): the outline plus TWO document buffers — one loaded by path, one the +# reserved not-yet-created slot — and a DECLARED `bound_buffer` naming the +# loaded one. Binding says what the chat is working ON; grounding is unchanged +# and still carries every loaded buffer, which is why all three ride the request +# while only one is bound. +{ + "schema_version": 1, + "kind": "workbench-chat-turn-v2", + "client_turn_id": "turn-v2-0001", + "scope": { + "repository": "openxFactory", + "ref": "doxbench/session/demo-topic", + "tile_kind": "staged", + "tile_id": "demo-topic" + }, + "bound_buffer": "ideation/staging/demo-topic/note.md", + "working_subject": "Clarify the acceptance boundary", + "message": "Does the note still say what the outline claims?", + "model_id": "local-authoring-1", + "last_assistant_turn_id": null, + "transcript": [], + "buffers": [ + { + "kind": "outline", + "repository": "openxFactory", + "path": "ideation/staging/demo-topic/demo-topic.md", + "base_ref": "doxbench/session/demo-topic", + "base_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "base_hash": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "content_hash": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "content": "# Demo topic\n\nOutline line.\n", + "dirty": false + }, + { + "kind": "document", + "repository": "openxFactory", + "path": "ideation/staging/demo-topic/note.md", + "base_ref": "doxbench/session/demo-topic", + "base_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "base_hash": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825", + "content_hash": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825", + "content": "# Note\n\nBody.\n", + "dirty": false + }, + { + "kind": "document", + "repository": "openxFactory", + "path": null, + "base_ref": "doxbench/session/demo-topic", + "base_revision": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "base_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "content_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "content": "", + "dirty": false + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-chat-turn-v2-provider-retry.example.yaml b/tests/fixtures/spec-examples/workbench-chat-turn-v2-provider-retry.example.yaml new file mode 100644 index 00000000..8c2733a4 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-chat-turn-v2-provider-retry.example.yaml @@ -0,0 +1,49 @@ +# VALID widened success (contract-v1.45): the turn that COST TWO PAID PROVIDER +# CALLS, and says so. +# +# Brett ruled on 2026-08-26 that when a minted token expires part-way through a +# turn the console re-mints and retries ONCE, "with the re-mint and the paid +# retry VISIBLY RECORDED in the turn record" — because a second paid call the +# human cannot see is exactly the decision that ruling was made to avoid. Three +# server-side records of it already existed (the port's content-free mint +# ledger, the console's own stderr notice, and the broker's audit trail +# correlated by `--retry-of`) and the browser could read NONE of them. This key +# is how the fact reaches the person paying for it. +# +# WHAT IT CARRIES AND WHAT IT REFUSES TO: that it happened, that it happened at +# most once (the ruling's bound — a second expiry in one turn refuses instead of +# buying a third call, and that turn produces a FAILURE envelope, never this +# one), and the re-mint's audit reference. Never the token, never a prefix or a +# hash of it, never a provider status and never the provider's words. +# +# THE PAIR TO THIS INSTANCE is every other v2 success example, which carries no +# `provider_retry` at all and stays valid: that is what makes contract-v1.45 +# additive. Absence means "nothing to report, or a producer older than v1.45" — +# never `retried: false`, which this shape cannot even express. +{ + "schema_version": 1, + "kind": "workbench-chat-turn-v2-success", + "client_turn_id": "turn-v2-0009", + "assistant_turn_id": "srv-v2-0009", + "model_id": "authoring-model", + "selected_model": { + "requested_model_id": "authoring-model", + "routing_rule": false, + "data_handling": "leaves this host: a hosted provider reached with a short-lived token the credential broker minted" + }, + "context_packet": { + "posture": "full" + }, + "provider_retry": { + "retried": true, + "at_most_once": true, + "audit_ref": "openprofiler-audit-7b21ee08" + }, + "bound_buffer": "ideation/staging/demo-topic/note.md", + "observed_hashes": { + "outline": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "ideation/staging/demo-topic/note.md": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825" + }, + "assistant_prose": "The boundary the outline claims is narrower than the two staged notes support; both are behind this answer.", + "proposals": [] +} diff --git a/tests/fixtures/spec-examples/workbench-chat-turn-v2-reduced-context.example.yaml b/tests/fixtures/spec-examples/workbench-chat-turn-v2-reduced-context.example.yaml new file mode 100644 index 00000000..6bb621d4 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-chat-turn-v2-reduced-context.example.yaml @@ -0,0 +1,33 @@ +# VALID widened success (contract-v1.40): the DEGRADED POSTURE, on the wire. +# The staged-set knowledge service was unavailable, so the turn ran on the +# declared reduced packet — the selected thread and the loaded buffers, no +# corpus evidence — and it SUCCEEDED, which is the ratified requirement's "MUST +# NOT ... make the editors unusable" half. What contract-v1.40 adds is the other +# half: the posture is STATED where a reader and a human can consult it, and the +# reason says in as many words that nothing unbounded was substituted and no rail +# was bypassed. The reason is carried VERBATIM from the packet, never +# re-derived, and this instance's is the shipped +# `doxbench_packet.REDUCED_NO_KNOWLEDGE_SERVICE` text. +{ + "schema_version": 1, + "kind": "workbench-chat-turn-v2-success", + "client_turn_id": "turn-v2-0003", + "assistant_turn_id": "srv-v2-0003", + "model_id": "local-authoring-1", + "selected_model": { + "requested_model_id": "local-authoring-1", + "routing_rule": false, + "data_handling": "Local process only; no content leaves this machine." + }, + "context_packet": { + "posture": "reduced", + "reduced_reason": "the staged-set knowledge service is unavailable, so this packet carries the selected thread and the loaded buffers only, with NO corpus evidence; no unbounded context was substituted and no rail was bypassed to reach a provider" + }, + "bound_buffer": "ideation/staging/demo-topic/note.md", + "observed_hashes": { + "outline": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "ideation/staging/demo-topic/note.md": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825" + }, + "assistant_prose": "Working from the note and the outline alone this turn: the staged-set index was not reachable, so nothing from the wider corpus is behind this answer.", + "proposals": [] +} diff --git a/tests/fixtures/spec-examples/workbench-chat-turn-v2-success.example.yaml b/tests/fixtures/spec-examples/workbench-chat-turn-v2-success.example.yaml new file mode 100644 index 00000000..b05a432c --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-chat-turn-v2-success.example.yaml @@ -0,0 +1,42 @@ +# VALID widened success (contract-v1.34): the turn's durable RECORD. It names +# the DECLARED bound buffer — carried on the wire, never derived from which +# document happened to be supplied — states every buffer's observed identity BY +# KEY, targets its one proposal at a buffer key, and carries the selected-model +# metadata beside the model that answered. Here the human chose the `auto` +# ROUTING RULE, so `selected_model.requested_model_id` and `model_id` differ and +# `routing_rule` says why. +# +# AND IT CARRIES NO `context_packet`, DELIBERATELY, since contract-v1.40: this +# instance is the pre-release record shape, unchanged byte for byte, and its +# continued validity IS the additive claim. Absence here is NOT a `full` +# posture — it is a producer that predates v1.40 and states no posture at all. +# See `workbench-chat-turn-v2-full-context.example.yaml` for a producer that +# does, and `workbench-chat-turn-v2-reduced-context.example.yaml` for the +# degraded one. +{ + "schema_version": 1, + "kind": "workbench-chat-turn-v2-success", + "client_turn_id": "turn-v2-0001", + "assistant_turn_id": "srv-v2-0001", + "model_id": "local-authoring-1", + "selected_model": { + "requested_model_id": "auto", + "routing_rule": true, + "data_handling": "Local process only; no content leaves this machine." + }, + "bound_buffer": "ideation/staging/demo-topic/note.md", + "observed_hashes": { + "outline": "74d2e440df9974e0c1d82412165e497bb95611142b3ed1235c0bd74b3c2277ba", + "ideation/staging/demo-topic/note.md": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825", + "document": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "assistant_prose": "The note omits the boundary the outline states.", + "proposals": [ + { + "target": "ideation/staging/demo-topic/note.md", + "base_hash": "9859eb7d779f1dee12579980045bb94ecbc2cc487d8160aa239bc1bfe3416825", + "summary": "Names the boundary the outline already claims.", + "content": "# Note\n\nBody.\n\nBoundary: explicit.\n" + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-empty.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-empty.example.yaml new file mode 100644 index 00000000..635ec974 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-empty.example.yaml @@ -0,0 +1,6 @@ +# VALID: the editor-only posture — zero approved models is SUCCESS (FR-025). +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-hosted-zero-retention.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-hosted-zero-retention.example.yaml new file mode 100644 index 00000000..ed8d6325 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-hosted-zero-retention.example.yaml @@ -0,0 +1,16 @@ +# VALID: an explicitly enabled zero-retention hosted model with NARROW limits. +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [ + { + "model_id": "hosted-zr-1", + "label": "Hosted zero-retention model (explicitly enabled)", + "provider_class": "hosted-zero-retention", + "available": true, + "input_limit_bytes": 2048, + "output_limit_bytes": 8192, + "data_handling": "Zero retention; content leaves the tenant boundary for inference only." + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-local.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-local.example.yaml new file mode 100644 index 00000000..b84d093c --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-local.example.yaml @@ -0,0 +1,25 @@ +# VALID: one approved local/on-tenant model, the seven required base fields +# only — no routing declaration, which is the shape every producer that +# predates contract-v1.38 emits and which stays valid unchanged. +# +# It is also the ABSENCE case for contract-v2.2's `modalities`: this entry +# declares no modalities and is valid exactly as it was. Its silence is not a +# claim in either direction — it says the producer predates the field, not that +# the model rejects images — and a reader treats it as text-only FOR ROUTING +# while recording that no declaration was made. The declaring counterpart is +# `workbench-model-catalog-multimodal.example.yaml`. +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [ + { + "model_id": "local-authoring-1", + "label": "Approved authoring model", + "provider_class": "on-tenant", + "available": true, + "input_limit_bytes": 800000, + "output_limit_bytes": 900000, + "data_handling": "Processed in the approved tenant boundary; no retention." + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-multimodal.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-multimodal.example.yaml new file mode 100644 index 00000000..0446bbaf --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-multimodal.example.yaml @@ -0,0 +1,27 @@ +# VALID: one approved model that DECLARES the input modalities it accepts +# (contract-v2.2). The set is non-empty, carries no repeat, and contains +# `text` — a chat turn always carries text, so a model that could not accept +# it would not be routable here at all. +# +# This is the POSITIVE half of the closed vocabulary. Its negatives are +# `negative/workbench-model-catalog-modality-outside-the-vocabulary`, +# `negative/workbench-model-catalog-modality-image-only` and +# `negative/workbench-model-catalog-modality-empty-set`. The ABSENCE case — an +# entry that declares nothing and stays valid — is +# `workbench-model-catalog-local.example.yaml`. +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [ + { + "model_id": "local-vision-1", + "label": "Approved authoring model (accepts images)", + "provider_class": "on-tenant", + "available": true, + "input_limit_bytes": 800000, + "output_limit_bytes": 900000, + "data_handling": "Processed in the approved tenant boundary; no retention.", + "modalities": ["text", "image"] + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule-wider-than-a-non-resolved-member.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule-wider-than-a-non-resolved-member.example.yaml new file mode 100644 index 00000000..2a1fc612 --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule-wider-than-a-non-resolved-member.example.yaml @@ -0,0 +1,52 @@ +# VALID (contract-v1.38, rule 5' — Brett's ruling 2026-08-21): the rule declares +# an input budget of 800000, which EXCEEDS that of `fit-narrow-spare` (2048), a +# model it may route to. That is lawful, and it is the POINT of the ruling: the +# bound is the RESOLVED model's alone. +# +# `fit-wide-resolved` is what answers, and it accepts 800000, so the menu's +# declared limits are honoured by the model that actually serves the turn. The +# narrow member takes no turn while the rule resolves elsewhere, so capping +# against it would constrain a promise nobody can call in — and would bake in +# semantics that contradict the sanctioned per-turn fit-aware router staged as +# `ideation/staging/doxchat-auto-fit-routing/`, under which a rule's ceiling is +# the widest thing it can serve rather than the narrowest. +# +# The first shipped form of this rule (a MINIMUM over `routes_to`) would have +# REFUSED this catalog. Its mirror-image negative is +# `negative/workbench-model-catalog-routing-rule-wider-than-its-resolution`. +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [ + { + "model_id": "auto-fit", + "label": "Automatic (routes by role)", + "provider_class": "routing-rule", + "available": true, + "input_limit_bytes": 800000, + "output_limit_bytes": 900000, + "data_handling": "Routes by role. / Processed in the approved tenant boundary; no retention. / Zero retention; content leaves the tenant boundary for inference only.", + "routing_rule": true, + "routes_to": ["fit-wide-resolved", "fit-narrow-spare"], + "resolved_model_id": "fit-wide-resolved" + }, + { + "model_id": "fit-wide-resolved", + "label": "Approved authoring model (the resolution)", + "provider_class": "on-tenant", + "available": true, + "input_limit_bytes": 800000, + "output_limit_bytes": 900000, + "data_handling": "Processed in the approved tenant boundary; no retention." + }, + { + "model_id": "fit-narrow-spare", + "label": "Hosted zero-retention model (routable, narrower, not resolved)", + "provider_class": "hosted-zero-retention", + "available": true, + "input_limit_bytes": 2048, + "output_limit_bytes": 8192, + "data_handling": "Zero retention; content leaves the tenant boundary for inference only." + } + ] +} diff --git a/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule.example.yaml b/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule.example.yaml new file mode 100644 index 00000000..8b03418e --- /dev/null +++ b/tests/fixtures/spec-examples/workbench-model-catalog-routing-rule.example.yaml @@ -0,0 +1,49 @@ +# VALID (contract-v1.38): an `auto` ROUTING RULE beside the two approved models +# it may route to. Its own badge CARRIES BOTH of theirs as SEGMENTS (the ratified +# "carry the handling badge of every model it may route to"), it resolves to one +# of them, and that one is available. +# +# Its declared limits (2048/8192) are bounded by `routed-on-tenant-1`, THE MODEL +# IT RESOLVES TO (rule 5'), which accepts 800000/900000 — so the rule sits far +# under its bound. They happen to EQUAL the limits of `routed-hosted-zr-1`, the +# member it does NOT resolve to, which is also the narrowest member; so the old +# min-over-`routes_to` form accepts this catalog too. THIS INSTANCE THEREFORE +# DOES NOT DISCRIMINATE rule 5 from rule 5'. The one that does is +# `workbench-model-catalog-routing-rule-wider-than-a-non-resolved-member`, where +# the rule is WIDER than a non-resolved member and lawful only under 5'. +{ + "schema_version": 1, + "kind": "workbench-model-catalog", + "models": [ + { + "model_id": "auto", + "label": "Automatic (routes by role)", + "provider_class": "routing-rule", + "available": true, + "input_limit_bytes": 2048, + "output_limit_bytes": 8192, + "data_handling": "Routes by role. / Processed in the approved tenant boundary; no retention. / Zero retention; content leaves the tenant boundary for inference only.", + "routing_rule": true, + "routes_to": ["routed-on-tenant-1", "routed-hosted-zr-1"], + "resolved_model_id": "routed-on-tenant-1" + }, + { + "model_id": "routed-on-tenant-1", + "label": "Approved authoring model (routable)", + "provider_class": "on-tenant", + "available": true, + "input_limit_bytes": 800000, + "output_limit_bytes": 900000, + "data_handling": "Processed in the approved tenant boundary; no retention." + }, + { + "model_id": "routed-hosted-zr-1", + "label": "Hosted zero-retention model (routable)", + "provider_class": "hosted-zero-retention", + "available": true, + "input_limit_bytes": 2048, + "output_limit_bytes": 8192, + "data_handling": "Zero retention; content leaves the tenant boundary for inference only." + } + ] +} diff --git a/tests/test_validator.py b/tests/test_validator.py new file mode 100644 index 00000000..75bc826f --- /dev/null +++ b/tests/test_validator.py @@ -0,0 +1,953 @@ +"""openDox's own validator, `opendox.validator` (plan 034's T057). + +`tests/test_validator_input_set.py` holds WHAT the validator reads: its four +packaged copies, each proved before it is read, and nothing else. This module +holds HOW it judges an instance against them. + +THE CORPUS. `tests/fixtures/spec-examples/` is openDox-spec's own examples, +copied byte for byte from openDox-spec#16 at `cd49eb25` +(`examples/ideation-dashboard/`, T053). T053 landed as `f7ee3c76`, with the +same tree, so the copies are the landed files. They are the positive examples of the +neutral snapshot, chat-turn and model-catalog kinds, and the neutral snapshot +contract's 32 negatives, one per rule. Each negative's `# expected_failure:` +line names the rule it breaks, so the corpus asks the validator for the rule +itself, not for a wording it guesses at. openDox-spec's two `ideation-workbench` +examples are not carried: this leg's committed-manifest guard +(`workbench.committed_manifests`) refuses a workbench manifest tracked outside +`examples/`, and rightly, so that kind is held over the manifests openDox's +own workbench writes instead. + +WHAT IT HOLDS. + +1. THE NEUTRAL CONTRACT'S RULES, ALL 32. Each negative is refused for exactly + the rule it names, at one place, and its report names that rule as + `[]`. Each positive is refused for nothing. The contract's catalog + and this module's reference rules name the same seven, and every + subschema that can fail names a catalogued shape rule, so no refusal of + the neutral kind is left without an identifier. +2. THE OTHER THREE KINDS, STRUCTURALLY. Every positive example of the + chat-turn and model-catalog kinds validates, each chat-turn wire kind is + judged against its own envelope, and every manifest openDox's own + workbench writes validates as an `ideation-workbench`. +3. THE EVALUATOR'S SEMANTICS, keyword by keyword, over small schemas of its + own: JSON equality, the date-time format, the applicators, and where a + violation is reported. +4. FAIL CLOSED. A copy that uses what this module does not evaluate is refused + when its validator is built, never evaluated with a keyword left out. So + is a keyword holding a value of another shape, wherever the evaluator could + reach it, and a copy's malformation is refused as unavailable, never a crash. +5. jsonschema's SHAPE, FOR THE doxBench SEAM. `serve_workbench`'s two readers + of the seam, run over `validators()`, judge the chat turn as they judge it + over openxFactory's jsonschema validators, so plan 034's T085 can register + them. +6. OVER openDox's OWN PROJECTION. T050's fixture, projected by openDox's own + generator, breaks no rule, and T051's malformed fixture breaks exactly its + `EXPECTED_RULE`. This is F7.2's judgment, in process, and T058 wires it + into the generate verbs. +7. NO REACH. The validator imports, and validates, with every sibling blocked + and no third-party module but PyYAML. + +A CREATED file: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import textwrap +from pathlib import Path +from typing import Any + +import pytest +import yaml + +from opendox import contracts +from opendox import corpus_adapter +from opendox import default_generator +from opendox import domain_profile +from opendox import generator_seam as gs +from opendox import serve_workbench +from opendox import validator as V +from opendox.runtime import local_git_adapter as lga + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +FIXTURES = ROOT / "tests" / "fixtures" +EXAMPLES = FIXTURES / "spec-examples" +POSITIVE = sorted(EXAMPLES.glob("*.example.yaml")) +NEGATIVE = sorted((EXAMPLES / "negative").glob("opendox-snapshot-*.negative.yaml")) +PLAIN = FIXTURES / "plain-documents" # T050, openDox-code#53 +MALFORMED = FIXTURES / "malformed" # T051, openDox-code#56 +SNAPSHOT = gs.NEUTRAL_SNAPSHOT_KIND + +#: The four packages a neutral openDox must import without (#1144's F2.1). +SIBLINGS = ("openxdox", "ideation_dashboard", "doc_health", + "corpus_adapter_openxfactory") + + +def _read(path: Path) -> Any: + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def _expected_failure(path: Path) -> str: + named = re.findall(r"^# expected_failure: (\S+)\s*$", + path.read_text(encoding="utf-8"), re.M) + assert len(named) == 1, f"{path.name} must name exactly one expected_failure: {named}" + return named[0] + + +def _contract() -> dict[str, Any]: + return contracts.load("opendox-snapshot") + + +def _built(schema: dict[str, Any], *, copy_id: str = "a-test-schema", + pointer: str = "") -> V.KindValidator: + """A validator over a schema of the test's own, in the copies' dialect.""" + return V.KindValidator("a-test-kind", copy_id, pointer, + {"$schema": V.DIALECT, **schema}, "0" * 64) + + +def _found(schema: dict[str, Any], instance: Any) -> set[tuple[str, str]]: + return {(v.keyword, v.where) for v in _built(schema).iter_errors(instance)} + + +# --------------------------------------------------------------------------- +# 1. the neutral contract's 32 rules +# --------------------------------------------------------------------------- + +def test_the_corpus_is_one_negative_per_rule_and_the_two_positives() -> None: + catalog = [rule["id"] for rule in _contract()["x-rules"]] + assert len(catalog) == 32 + assert sorted(_expected_failure(path) for path in NEGATIVE) == sorted(catalog) + assert [p.name for p in POSITIVE if p.name.startswith("opendox-snapshot-")] == [ + "opendox-snapshot-no-front-matter.example.yaml", + "opendox-snapshot-six-stations.example.yaml"] + + +@pytest.mark.parametrize("path", NEGATIVE, ids=lambda p: p.name) +def test_a_negative_is_refused_for_its_rule_alone_and_its_report_names_it( + path: Path) -> None: + """Validated against the neutral contract, as openDox's generator declares + it writes, each negative breaks exactly the rule its header names, at one + place, and every line of the report opens with `[]`.""" + expected = _expected_failure(path) + found = V.validate(_read(path), kind=SNAPSHOT) + assert {v.rule for v in found} == {expected}, V.report(found) + assert len({v.where for v in found}) == 1, V.report(found) + assert all(line.startswith(f"[{expected}] ") for line in V.report(found)) + + +@pytest.mark.parametrize("path", [p for p in POSITIVE if p.name.startswith("opendox-")], + ids=lambda p: p.name) +def test_a_positive_breaks_no_rule(path: Path) -> None: + assert V.validate(_read(path)) == [] + assert V.validate(_read(path), kind=SNAPSHOT) == [] + + +def test_the_catalog_and_the_reference_rules_are_one_set() -> None: + """25 shape rules and 7 reference rules. This module implements exactly + the seven the catalog declares, and in the catalog's order.""" + catalog = _contract()["x-rules"] + reference = [rule["id"] for rule in catalog if rule["class"] == "reference"] + shape = [rule["id"] for rule in catalog if rule["class"] == "shape"] + assert (len(shape), len(reference)) == (25, 7) + assert list(V.REFERENCE_RULES["opendox-snapshot"]) == reference + assert set(V.REFERENCE_RULES) == {"opendox-snapshot"} + + +def test_every_subschema_of_the_neutral_contract_that_can_fail_names_its_rule() -> None: + """So every refusal of the neutral kind carries the contract's identifier, + and none falls back to a bare keyword.""" + catalog = {rule["id"]: rule["class"] for rule in _contract()["x-rules"]} + can_fail = V._ASSERTING | {"anyOf", "oneOf", "not", "contains"} + named = set() + for at, node in V._subschemas(_contract()): + if "if" in at.split("/"): + continue # an `if` is a test and never reports + if can_fail & set(node): + assert "x-rule" in node, f"{at or ''} can fail and names no rule" + if "x-rule" in node: + assert catalog.get(node["x-rule"]) == "shape", (at, node["x-rule"]) + named.add(node["x-rule"]) + assert named == {rule for rule, cls in catalog.items() if cls == "shape"} + + +def test_the_governed_kind_is_refused_as_a_rule_or_as_unknown() -> None: + """A snapshot of the consumer's governed kind is not openDox's. Asked for + the neutral contract, the validator refuses it for + `kind-is-opendox-snapshot`; asked to pick a contract by the instance's own + kind, it has none, and says so rather than answering an empty list.""" + governed = _read(EXAMPLES / "negative" / + "opendox-snapshot-kind-is-opendox-snapshot.negative.yaml") + assert governed["kind"] == "ideation-dashboard-snapshot" + assert {v.rule for v in V.validate(governed, kind=SNAPSHOT)} == {"kind-is-opendox-snapshot"} + with pytest.raises(V.UnknownKind): + V.validate(governed) + for not_an_instance in ([], "opendox-snapshot", None, {"kind": 1}): + with pytest.raises(V.UnknownKind): + V.validate(not_an_instance) + with pytest.raises(V.UnknownKind): + V.validate({}, kind="ideation-dashboard-snapshot") + + +def test_a_report_line_names_the_rule_the_place_and_what_was_found() -> None: + snap = _read(EXAMPLES / "opendox-snapshot-no-front-matter.example.yaml") + snap["documents"][0]["title"] = "" + snap["clusters"][0]["document_edges"][0]["document"] = "nowhere.md" + lines = V.report(V.validate(snap, kind=SNAPSHOT)) + assert lines == [ + "[title-and-summary-are-text] /documents/0/title: '' is shorter than 1", + "[edge-names-a-document] /clusters/0/document_edges/0/document: " + "no document has the id 'nowhere.md'", + ], lines + + +def test_a_pointer_escapes_its_keys_and_the_root_is_named() -> None: + violation = V.Violation("r", ("a/b", "c~d", 0), "type", "detail") + assert violation.where == "/a~1b/c~0d/0" + assert V.Violation("envelope-keys", (), "type", "x").line() == "[envelope-keys] : x" + + +# --------------------------------------------------------------------------- +# 2. the other three kinds, structurally +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("path", [p for p in POSITIVE if not p.name.startswith("opendox-")], + ids=lambda p: p.name) +def test_every_positive_example_of_the_other_kinds_validates(path: Path) -> None: + instance = _read(path) + assert instance["kind"] in V.KIND_ENTRIES + assert V.validate(instance) == [], V.report(V.validate(instance)) + + +def test_every_manifest_openDox_own_workbench_writes_validates() -> None: + """The `ideation-workbench` manifests this validator is asked about are the + ones openDox's own `workbench.Workbench` writes (T055 routes + `workbench.validate_manifest` here). One of each seed kind, carrying every + member route, an exclusion, every action and a notebook binding, + validates against the packaged copy. A human override with no recorded + reason, which the writer itself refuses, is refused by the contract too.""" + from opendox import workbench as wb + + now = "2026-09-27T12:00:00Z" + made = [ + wb.Workbench.create("fixture", "an ad-hoc set", now=now), + wb.Workbench.create("fixture", "a cluster set", seed=wb.SEED_CLUSTER, + cluster_id="compost", now=now), + wb.Workbench.create("fixture", "a recipe set", seed=wb.SEED_RECIPE, + recipe={"checked": ["compost", "soil"], "pinned": ["soil"]}, + now=now), + ] + assert {w.data["seed"]["kind"] for w in made} == set(wb.SEED_KINDS) + for w in made: + for via in sorted(wb.VIA_VALUES): + w.add_member(f"notes/{via}.md", via, now=now, + reason="a human chose it" if via == wb.VIA_MANUAL_INCLUDE else None) + w.exclude("notes/left-out.md", "not about the shed", now=now) + for action in sorted(wb.ACTION_VALUES): + w.record_action(action, now=now) + w.bind_notebook(now=now) + manifest = yaml.safe_load(w.render()) + assert V.validate(manifest) == [], V.report(V.validate(manifest)) + silent = yaml.safe_load(made[0].render()) + index = len(silent["members"]) + silent["members"].append({"document": "notes/silent.md", "via": wb.VIA_MANUAL_INCLUDE}) + assert V.report(V.validate(silent)) == [ + f"[required] /members/{index}: 'reason' is required"] + + +def test_each_chat_turn_kind_is_judged_against_its_own_envelope() -> None: + """A success envelope is valid as a success and not as a request: each + wire kind is its own envelope, as openxFactory's `CHAT_TURN_DEFS` names + them, and not the chat-turn file's three-way `oneOf`.""" + success = _read(EXAMPLES / "workbench-chat-turn-v2-success.example.yaml") + assert V.validate(success, kind="workbench-chat-turn-v2-success") == [] + as_request = V.validate(success, kind="workbench-chat-turn-v2") + assert ("const", "/kind") in {(v.keyword, v.where) for v in as_request} + assert "oneOf" not in {v.keyword for v in as_request} + + +# --------------------------------------------------------------------------- +# 3. the evaluator, keyword by keyword +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("types, value, ok", [ + ("integer", 2, True), ("integer", 2.0, True), ("integer", 2.5, False), + ("integer", True, False), ("number", 1.5, True), ("number", False, False), + ("boolean", 0, False), ("null", None, True), ("string", None, False), + (["string", "null"], None, True), ("array", {}, False), ("object", [], False), +], ids=repr) +def test_type_is_json_type(types: Any, value: Any, ok: bool) -> None: + """JSON's types: `true` is not a number, and 2.0 is the integer 2.""" + assert (not _found({"type": types}, value)) is ok + + +@pytest.mark.parametrize("schema, value, ok", [ + ({"const": 1}, 1.0, True), ({"const": 1}, True, False), + ({"const": {"a": 1, "b": [1, 2]}}, {"b": [1.0, 2], "a": 1}, True), + ({"const": [1, 2]}, [2, 1], False), + ({"enum": [False, "x"]}, 0, False), ({"enum": [False, "x"]}, False, True), + ({"uniqueItems": True}, [1, 1.0], False), ({"uniqueItems": True}, [1, True], True), + ({"uniqueItems": True}, [{"a": 1}, {"a": 1}], False), + ({"uniqueItems": True}, [{1: "a"}, {"1": "a"}], True), +], ids=repr) +def test_const_enum_and_uniqueness_are_json_equality(schema: dict, value: Any, + ok: bool) -> None: + assert (not _found(schema, value)) is ok + + +@pytest.mark.parametrize("value, ok", [ + ("2026-09-27T12:00:00Z", True), ("2026-09-27t12:00:00.25+05:30", True), + ("2024-02-29T00:00:00Z", True), ("2000-02-29T23:59:59z", True), + ("0001-01-01T00:00:00Z", True), + ("2026-02-29T00:00:00Z", False), ("1900-02-29T00:00:00Z", False), + ("2026-04-31T00:00:00Z", False), ("2026-13-01T00:00:00Z", False), + ("0000-01-01T00:00:00Z", False), ("2016-12-31T23:59:60Z", False), + ("2026-09-27T24:00:00Z", False), ("2026-09-27T12:00:00", False), + ("2026-09-27", False), ("2026-09-27T12:00:00+0530", False), + ("2026-09-27T12:00:00Z", False), + ("2026-09-27T12:00:00Z\n", False), +], ids=repr) +def test_the_date_time_format_is_asserted(value: str, ok: bool) -> None: + """RFC 3339, as the consumer's validator asserts it: ASCII digits, a year + other than 0000, a day the month has, no leap second. The whole value must + match, so a trailing newline is refused, which is the one place this is + stricter than `rfc3339-validator`. `format` holds only for text.""" + assert (not _found({"format": "date-time"}, value)) is ok + assert not _found({"format": "date-time"}, 12) + + +def test_lengths_count_characters_and_a_pattern_searches() -> None: + assert _found({"minLength": 2, "maxLength": 3}, "é") == {("minLength", "")} + assert not _found({"minLength": 2, "maxLength": 3}, "éé") + assert _found({"maxLength": 3}, "abcd") == {("maxLength", "")} + # A JSON Schema pattern is not anchored: `b` is found inside `abc`. + assert not _found({"pattern": "b"}, "abc") + assert _found({"pattern": "^b"}, "abc") == {("pattern", "")} + # A length or a pattern says nothing about a value that is not text. + assert not _found({"minLength": 5, "pattern": "^x$"}, 7) + + +def test_numbers_have_bounds_and_booleans_are_not_numbers() -> None: + assert _found({"minimum": 0, "maximum": 3}, -1) == {("minimum", "")} + assert _found({"minimum": 0, "maximum": 3}, 4) == {("maximum", "")} + assert not _found({"minimum": 0, "maximum": 3}, 3.0) + assert not _found({"minimum": 1}, False) + + +def test_array_keywords() -> None: + schema = {"minItems": 1, "maxItems": 2, "items": {"type": "string"}, + "contains": {"const": "x"}} + assert _found(schema, []) == {("minItems", ""), ("contains", "")} + assert _found(schema, ["x", "y", "z"]) == {("maxItems", "")} + assert _found(schema, ["x", 1]) == {("type", "/1")} + assert _found(schema, ["y"]) == {("contains", "")} + + +def test_object_keywords() -> None: + schema = {"required": ["a"], "minProperties": 1, "maxProperties": 2, + "dependentRequired": {"b": ["c"]}, + "properties": {"a": {"type": "string"}, "b": {}, "c": {}}, + "additionalProperties": False} + assert _found(schema, {}) == {("required", ""), ("minProperties", "")} + assert _found(schema, {"a": 1}) == {("type", "/a")} + assert _found(schema, {"a": "x", "b": 1}) == {("dependentRequired", "")} + assert _found(schema, {"a": "x", "b": 1, "c": 2}) == {("maxProperties", "")} + assert _found(schema, {"a": "x", "z": 1}) == {("additionalProperties", "")} + # An additionalProperties SCHEMA judges each unnamed property where it is. + assert _found({"properties": {"a": {}}, "additionalProperties": {"type": "integer"}}, + {"a": "x", "z": "y"}) == {("type", "/z")} + + +def test_property_names_are_judged_at_the_object() -> None: + """jsonschema reports a bad property NAME at the object, with the keyword + that refused the name, and so does this module.""" + found = _built({"properties": {"o": {"propertyNames": {"pattern": "^[a-z]+$"}, + "maxProperties": 5}}}).violations( + {"o": {"ok": 1, "Not OK": 2}}) + assert [(v.keyword, v.where) for v in found] == [("pattern", "/o")] + assert "Not OK" in found[0].detail + + +def test_applicators() -> None: + one_of = {"oneOf": [{"type": "integer"}, {"minimum": 0}]} + assert _found(one_of, -1) == set() # integer only + assert _found(one_of, 1) == {("oneOf", "")} # both + assert _found(one_of, "x") == set() # `minimum` holds for text + assert _found({"oneOf": [{"type": "integer"}, {"type": "null"}]}, "x") == {("oneOf", "")} + any_of = {"anyOf": [{"type": "integer"}, {"type": "null"}]} + assert _found(any_of, "x") == {("anyOf", "")} and not _found(any_of, None) + assert _found({"not": {"required": ["a"]}}, {"a": 1}) == {("not", "")} + assert _found({"allOf": [{"minimum": 1}, {"maximum": 0}]}, 0.5) == { + ("minimum", ""), ("maximum", "")} + # `if` never reports; it only chooses whether `then` applies. + conditional = {"if": {"properties": {"k": {"const": "a"}}, "required": ["k"]}, + "then": {"required": ["a"]}} + assert _found(conditional, {"k": "a"}) == {("required", "")} + assert not _found(conditional, {"k": "b"}) + assert not _found(conditional, {}) + + +def test_a_reference_applies_beside_its_siblings_and_names_its_own_rule() -> None: + """Draft 2020-12: `$ref` applies BESIDE the keywords next to it, and a + violation found through the reference carries the referenced subschema's + rule.""" + built = _built({"properties": {"n": {"$ref": "#/$defs/n", "x-rule": "outer", + "maximum": 5}}, + "$defs": {"n": {"x-rule": "inner", "type": "integer"}}}) + assert {(v.rule, v.keyword) for v in built.violations({"n": 6.5})} == { + ("inner", "type"), ("outer", "maximum")} + + +def test_an_instance_whose_keys_are_not_text_is_judged_never_crashed_on() -> None: + """YAML can give an instance a key that is not text: a number or null. It + is judged like any other key, and reporting it never orders unlike keys + against each other. An instance fuzz found that ordering raising TypeError + under `additionalProperties: false`.""" + odd = {1: "a number", None: "null", 2.5: "a float", "b": 2} + assert _found({"additionalProperties": False}, odd) == {("additionalProperties", "")} + judged = _built({"properties": {"b": {"type": "integer"}}, "additionalProperties": False, + "propertyNames": {"type": "string"}}).violations(odd) + assert {(v.keyword, v.where) for v in judged} == {("additionalProperties", ""), ("type", "")} + assert "unexpected properties [1, 2.5, None]" in V.report(judged)[0] + + +def _deep(levels: int) -> list[Any]: + """A list nested `levels` deep, past Python's recursion limit at 5000.""" + value: list[Any] = [] + for _ in range(levels): + value = [value] + return value + + +def test_an_instance_key_of_any_size_is_named_not_crashed_on() -> None: + """A mapping key that is an int past 4300 digits has no decimal text, so + naming it in a violation's pointer, or ordering it in the report of + unexpected properties, raised ValueError (Copilot at openDox-code#58 + 2b8ad245, r4139823704 and r4139823779). Both now show its size. An + ordinary key reads exactly as before.""" + huge = 10 ** 5000 + shown = f"" + below = _built({"additionalProperties": {"type": "string"}}).violations({huge: 1}) + assert [(v.keyword, v.where) for v in below] == [("type", f"/{shown}")] + extra = _built({"additionalProperties": False}).violations({huge: 1, "b": 2}) + assert [v.keyword for v in extra] == ["additionalProperties"] + assert shown in V.report(extra)[0] and "'b'" in V.report(extra)[0] + assert _found({"additionalProperties": {"type": "string"}}, {7: 1, "a/b": 2}) == { + ("type", "/7"), ("type", "/a~1b")} + + +def test_a_non_text_key_that_is_not_a_scalar_is_judged_as_itself() -> None: + """A tuple key is canonicalized as an opaque value, never equal to a JSON + one, so `const`, `enum` and `uniqueItems` judge it without crashing + (Copilot at openDox-code#58 2b8ad245, r4139823750, which does not + reproduce: this is the guard that it stays so).""" + odd = {("non-text",): 1} + assert _found({"enum": [{"a": 1}]}, odd) == {("enum", "")} + assert _found({"const": {"non-text": 1}}, odd) == {("const", "")} + assert _found({"uniqueItems": True}, [odd, {("non-text",): 1}]) == {("uniqueItems", "")} + + +def test_an_integer_bound_of_any_size_is_evaluated() -> None: + """YAML gives an integer of up to 4300 digits, and a JSON number has no + bound. `math.isfinite()` could not convert one past a float's range, so + such a bound crashed the build with OverflowError.""" + huge = int("9" * 400) + assert _found({"minimum": huge}, 5) == {("minimum", "")} + assert _found({"maximum": -huge}, 5) == {("maximum", "")} + assert _found({"maxLength": huge, "const": huge}, huge) == set() + + +def test_a_const_or_enum_that_contains_itself_is_refused() -> None: + """A YAML alias can build a list that contains itself. No JSON value does, + so a copy whose `const` or `enum` holds one is refused when built.""" + looped = yaml.safe_load("&c [*c]") + for schema in ({"const": looped}, {"enum": [1, looped]}): + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"properties": {"a": schema}}) + assert "contains itself, which no JSON value does" in str(refused.value) + + +@pytest.mark.parametrize("text,what", [ + ("!!set {a: null}", "a set"), + ("!!binary aGk=", "a bytes"), + ("2026-09-30", "a date"), + ("[1, {2: x}]", "a mapping key that is not text"), + ("{a: [.inf]}", "the non-finite number inf"), + ("[.nan]", "the non-finite number nan"), +], ids=["set", "binary", "date", "int-key", "inf", "nan"]) +def test_a_const_or_enum_that_holds_a_value_json_has_not_is_refused(text, what) -> None: + """YAML builds values JSON has not: sets, bytes, dates, non-text keys and + non-finite numbers. A `const` or `enum` holding one is refused when built, + anywhere inside the value (Copilot at openDox-code#58 cb40b977, + r4139739110). Before, `const: !!set {a: null}` built and accepted an + equal set. A JSON value, nested, still builds.""" + value = yaml.safe_load(text) + for schema in ({"const": value}, {"enum": ["ok", value]}): + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"properties": {"a": schema}}) + assert what in str(refused.value) and "is not a JSON value" in str(refused.value) + nested = yaml.safe_load("{a: [1, 2.5, true, null, {b: text}]}") + assert _found({"const": nested}, nested) == set() + + +def test_values_of_any_depth_are_compared_without_recursing() -> None: + """JSON equality is judged on a flat canon, built without recursing. So + neither a deep schema value nor a deep instance exhausts Python's stack + (nested tuples compare recursively, and did), and a violation's detail + stays brief.""" + assert _found({"const": _deep(5000)}, _deep(5000)) == set() + assert _found({"const": [1]}, _deep(5000)) == {("const", "")} + assert _found({"enum": [_deep(5000)]}, _deep(4999)) == {("enum", "")} + assert _found({"uniqueItems": True}, [_deep(5000), _deep(5000)]) == {("uniqueItems", "")} + assert _found({"const": [[1]]}, yaml.safe_load("&c [*c]")) == {("const", "")} + assert len(_built({"const": [1]}).violations(_deep(5000))[0].detail) < 200 + + +def test_a_violations_detail_always_shows_the_value() -> None: + """repr() fails for a value nested deep enough (20000 levels here), and + for an int past 4300 digits, which Python can hand in though YAML and JSON + cannot. Either way the detail shows a stand-in, and the violation is + reported.""" + [deep] = _built({"type": "string"}).violations(_deep(20000)) + assert deep.detail == "[[[[...]]]] is not of type string" + [huge] = _built({"type": "string"}).violations(10 ** 5000) + assert huge.detail == f" is not of type string" + [odd] = _built({"const": 1}).violations({10 ** 5000}) + assert odd.detail == " is not 1" + + +@pytest.mark.parametrize("schema, instance", [ + ({"minimum": 10 ** 5000}, 0), ({"maximum": -(10 ** 5000)}, 0), + ({"minLength": 10 ** 5000}, "a"), ({"minItems": 10 ** 5000}, []), + ({"minProperties": 10 ** 5000}, {})], + ids=["minimum", "maximum", "minLength", "minItems", "minProperties"]) +def test_a_violations_detail_shows_a_bound_of_any_size(schema, instance) -> None: + """Copilot at bf51a30a, a finding its review lists as previously missed. + A bound builds at any size, but the detail interpolated it directly, so a + bound past 4300 digits raised `ValueError` while its violation was being + written. The bound is now shown the way the value is.""" + [found] = _built(schema).violations(instance) + assert found.keyword == next(iter(schema)) + assert f"" in found.detail + # An ordinary bound reads exactly as it did. + [plain] = _built({"minimum": 5}).violations(1) + assert plain.detail == "1 is less than 5" + + +def test_the_canon_keeps_json_equality() -> None: + """`true` is not `1`, `1` is `1.0` and `-0.0` is `0`, key order is noise, + and a text key is not the number it spells.""" + canon = V._canon + assert canon(True) != canon(1) and canon(False) != canon(0) and canon(None) != canon(0) + assert canon(1) == canon(1.0) and canon(-0.0) == canon(0) and canon(0.5) != canon(1) + assert canon({"a": 1, "b": [2, 3]}) == canon({"b": [2.0, 3], "a": 1.0}) + assert canon({"1": "x"}) != canon({1: "x"}) and canon([1, 2]) != canon([2, 1]) + assert canon("a") != canon(["a"]) and canon([]) != canon({}) and canon("") != canon(None) + # An int past 4300 digits has no decimal text, and still has a canon. + assert canon(10 ** 5000) == canon(10 ** 5000) != canon(10 ** 5000 + 1) + + +def test_a_boolean_schema() -> None: + assert not _found({"properties": {"a": True}}, {"a": object()}) + assert _found({"properties": {"a": False}}, {"a": 1}) == {("false", "/a")} + + +# --------------------------------------------------------------------------- +# 4. fail closed +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("schema, says", [ + ({"patternProperties": {"^x": {}}}, "does not evaluate"), + ({"properties": {"a": {"else": {}}}}, "does not evaluate"), + ({"format": "uri"}, "does not assert"), + ({"$ref": "other.schema.yaml#/$defs/x"}, "only a reference inside the copy"), + ({"$ref": "#/$defs/missing"}, "names nothing in the copy"), + ({"$ref": "#/title", "title": "t"}, "not a schema"), + ({"pattern": "("}, "does not compile"), + ({"type": "text"}, "evaluates type only as one of"), + ({"x-rules": [{"id": "r", "class": "reference", "says": "?"}]}, + "implements []"), + ({"x-rules": ["not a rule"]}, "each carry an id"), + # Each of these once crashed the build itself instead of refusing it. + ({"format": {}}, "does not assert"), + ({"pattern": "a{4294967296}"}, "does not compile"), + ({1: "a number as a key", "zz": "text"}, "does not evaluate"), + # An embedded resource would move where its references resolve. + ({"properties": {"a": {"$id": "https://example.test/a"}}}, "embedded resource"), + ({"properties": {"a": {"$schema": V.DIALECT}}}, "embedded resource"), + # A JSON pointer's index is a plain decimal, never Python's reading of it. + ({"$ref": "#/allOf/-1", "allOf": [{}]}, "names nothing in the copy"), + ({"$ref": "#/allOf/01", "allOf": [{}, {}]}, "names nothing in the copy"), + # RFC 6901 escapes only ~0 and ~1, and a fragment's %-encoding is not decoded. + ({"$ref": "#/$defs/~2", "$defs": {"~2": {}}}, "names nothing in the copy"), + ({"$ref": "#/$defs/a%20b", "$defs": {"a b": {}, "a%20b": {}}}, "percent-encoded"), +], ids=["patternProperties", "else", "a format", "a remote reference", + "a dangling reference", "a reference to text", "a bad pattern", + "an unknown type", "an unimplemented reference rule", "a malformed catalog", + "a format of another shape", "an unbounded repetition", "mixed keys", + "an embedded $id", "an embedded $schema", "a negative index", + "a zero-padded index", "an invalid escape", "a percent-encoded fragment"]) +def test_what_is_not_evaluated_is_refused_when_the_validator_is_built( + schema: dict, says: str) -> None: + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built(schema) + assert says in str(refused.value) + assert isinstance(refused.value, V.ValidatorUnavailable) + + +@pytest.mark.parametrize("schema, keyword", [ + ({"type": {}}, "type"), + ({"type": [["string"]]}, "type"), + ({"type": ["string", "string"]}, "type"), + ({"type": []}, "type"), + ({"enum": 5}, "enum"), + ({"required": 5}, "required"), + ({"required": [1]}, "required"), + ({"required": ["a", "a"]}, "required"), + ({"dependentRequired": []}, "dependentRequired"), + ({"dependentRequired": {"a": 5}}, "dependentRequired"), + ({"dependentRequired": {1: ["a"]}}, "dependentRequired"), + ({"minLength": "3"}, "minLength"), + ({"maxLength": -1}, "maxLength"), + ({"minItems": True}, "minItems"), + ({"maxItems": 1.5}, "maxItems"), + ({"minProperties": None}, "minProperties"), + ({"maxProperties": "2"}, "maxProperties"), + ({"minimum": "5"}, "minimum"), + ({"maximum": float("nan")}, "maximum"), + ({"uniqueItems": "yes"}, "uniqueItems"), + ({"pattern": b"^a"}, "pattern"), + ({"properties": []}, "properties"), + ({"properties": {"a": 5}}, "properties"), + ({"properties": {1: {}}}, "properties"), + ({"$defs": []}, "$defs"), + ({"items": [{}]}, "items"), + ({"additionalProperties": "no"}, "additionalProperties"), + ({"contains": []}, "contains"), + ({"propertyNames": 5}, "propertyNames"), + ({"not": []}, "not"), + ({"if": 5, "then": {}}, "if"), + ({"if": {}, "then": 5}, "then"), + ({"allOf": 5}, "allOf"), + ({"allOf": []}, "allOf"), + ({"anyOf": "ab"}, "anyOf"), + ({"oneOf": [5]}, "oneOf"), + ({"x-rule": {}}, "x-rule"), + ({"x-rule": ""}, "x-rule"), +], ids=lambda case: repr(case) if isinstance(case, dict) else case) +def test_a_keyword_holding_a_value_of_another_shape_is_refused_when_built( + schema: dict, keyword: str) -> None: + """The evaluator reads each keyword's value as draft 2020-12 shapes it. + Before `_SHAPES`, `type: {}` and `allOf: 5` crashed the build with a + TypeError. Every other case here built, and then crashed on an instance + or judged it wrongly: `uniqueItems: "yes"` read as true, and a negative + `maxLength` refused every string. Each is now refused as unavailable, at + the place it is written.""" + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"properties": {"a": schema}}) + assert f"/properties/a's {keyword} is " in str(refused.value) + assert isinstance(refused.value, V.ValidatorUnavailable) + + +def test_every_keyword_this_module_evaluates_has_a_shape_or_its_own_check() -> None: + """A keyword added to `KEYWORDS` without a shape would be read unchecked. + `$ref` and `format` have their own refusals, `const` holds any value, and + the rest are annotations the evaluator never reads (`x-rules` is checked + as the snapshot contract's catalog).""" + own_check = {"$ref", "format", "const"} + annotations = {"$id", "$schema", "title", "description", "contract_schema_version", + "x-rules"} + assert set(V._SHAPES) == V.KEYWORDS - own_check - annotations + assert annotations | {"$defs", "x-rule"} == set(V._ANNOTATING) + + +def test_a_references_target_is_checked_where_no_walk_of_the_subschemas_reaches() -> None: + """A reference can name a node inside an `enum`, which no walk of the + subschemas passes, and the evaluator reads that node all the same. So + the build walks every reference's target. A malformed target is refused, + and a target's pattern is compiled when the validator is built.""" + malformed = {"$defs": {"x": {"enum": [{"uniqueItems": "yes"}]}}, + "properties": {"a": {"$ref": "#/$defs/x/enum/0"}}} + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built(malformed) + assert "/$defs/x/enum/0's uniqueItems is 'yes'" in str(refused.value) + patterned = {"$defs": {"x": {"enum": [{"pattern": "^a"}]}}, + "properties": {"a": {"$ref": "#/$defs/x/enum/0"}}} + assert _found(patterned, {"a": "abc"}) == set() + assert _found(patterned, {"a": "b"}) == {("pattern", "/a")} + + +def test_a_references_escapes_are_read_as_rfc_6901_reads_them() -> None: + """`~1` is `/` and `~0` is `~`, so each reference names the key it spells.""" + schema = {"properties": {"a": {"$ref": "#/$defs/x~1y"}, "b": {"$ref": "#/$defs/x~0y"}}, + "$defs": {"x/y": {"type": "string"}, "x~y": {"type": "integer"}}} + assert _found(schema, {"a": 1, "b": "t"}) == {("type", "/a"), ("type", "/b")} + assert _found(schema, {"a": "t", "b": 1}) == set() + + +def test_the_kinds_entry_is_a_schema_and_is_checked_wherever_it_is() -> None: + """The kind's entry is where evaluation starts, so it is checked like + every subschema, even where no walk of the document reaches.""" + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"$defs": {"x": "text"}}, pointer="/$defs/x") + assert "'/$defs/x' for a-test-kind is a str, not a schema" in str(refused.value) + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({}, pointer="/$defs/missing") + assert "it has no '/$defs/missing'" in str(refused.value) + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"$defs": {"x": {"enum": [{"uniqueItems": "yes"}]}}}, + pointer="/$defs/x/enum/0") + assert "/$defs/x/enum/0's uniqueItems is 'yes'" in str(refused.value) + + +def test_a_subschema_that_contains_itself_is_refused() -> None: + """A YAML alias can build a mapping that holds itself, and no walk of it + ends. It is refused where it loops, and never recursed into.""" + looped = yaml.safe_load("&s {properties: {a: *s}}") + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built({"properties": {"b": looped}}) + assert "/properties/b/properties/a contains itself" in str(refused.value) + + +@pytest.mark.parametrize("schema, cycle", [ + ({"$ref": "#"}, " -> "), + ({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}, + "$ref": "#/$defs/a"}, "/$defs/a -> /$defs/b -> /$defs/a"), + ({"allOf": [{"$ref": "#"}]}, " -> /allOf/0 -> "), + ({"anyOf": [{"type": "string"}, {"$ref": "#"}]}, " -> /anyOf/1 -> "), + ({"oneOf": [{"$ref": "#"}]}, " -> /oneOf/0 -> "), + ({"not": {"$ref": "#"}}, " -> /not -> "), + ({"if": {"$ref": "#"}, "then": {}}, " -> /if -> "), +], ids=["itself", "two $defs", "allOf", "anyOf", "oneOf", "not", "if"]) +def test_a_reference_cycle_that_never_moves_into_the_instance_is_refused( + schema: dict, cycle: str) -> None: + """Each of these is a valid draft 2020-12 schema that no evaluation ends: + jsonschema recurses on it until Python's limit, whatever the instance. + Here each is refused when its validator is built, naming the cycle.""" + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built(schema) + assert f"{cycle} is a cycle of subschemas" in str(refused.value) + + +def test_a_recursive_schema_that_moves_into_the_instance_is_evaluated() -> None: + """A tree whose children are items of the node recurs through `items`, so + each step moves into the instance and every evaluation ends. And an `if` + without a `then` is never read, so a reference back from it is no cycle.""" + tree = {"$defs": {"node": {"type": "object", "required": ["name"], "properties": { + "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/node"}}}}}, + "$ref": "#/$defs/node"} + deep = {"name": "a", "children": [{"name": "b", "children": [{"name": "c"}]}]} + assert _found(tree, deep) == set() + deep["children"][0]["children"][0] = {} + assert _found(tree, deep) == {("required", "/children/0/children/0")} + assert _found({"if": {"$ref": "#"}, "type": "string"}, 5) == {("type", "")} + + +def test_an_instance_deeper_than_the_walk_is_judged_not_crashed_on() -> None: + """Copilot at 27bcefc0 (r4136329332). The tree above recurs once per level + of the instance, so a deep enough tree raised `RecursionError` out of the + validator. A 200-level tree already did, at the default limit of 1000. It + is now judged: one `DEPTH_RULE` violation at the root, so it is never + valid. What the walk found before the limit stands, and a tree the walk + does reach is judged as before.""" + tree = {"$defs": {"node": {"type": "object", "required": ["name"], "properties": { + "name": {"type": "string"}, + "children": {"type": "array", "items": {"$ref": "#/$defs/node"}}}}}, + "$ref": "#/$defs/node"} + + def tree_of(levels: int) -> dict[str, Any]: + node: dict[str, Any] = {"name": "leaf"} + for _ in range(levels): + node = {"name": "n", "children": [node]} + return node + + built = _built(tree) + assert built.violations(tree_of(50)) == [] + deep = tree_of(5000) + [found] = built.violations(deep) + assert (found.rule, found.where, found.keyword) == (V.DEPTH_RULE, "", "depth") + assert found.line().startswith("[evaluation-depth] : ") + assert not built.is_valid(deep) + # A broken node near the top is still named, beside the limit. + del deep["children"][0]["name"] + assert {(v.rule, v.where) for v in built.violations(deep)} == { + ("required", "/children/0"), (V.DEPTH_RULE, "")} + + +def test_a_copy_nested_deeper_than_the_walk_is_refused() -> None: + """Python's recursion limit bounds the walk. A copy nested past it is + refused as unavailable, never a RecursionError out of the build.""" + deep: dict[str, Any] = {} + for _ in range(5000): + deep = {"not": deep} + with pytest.raises(V.SchemaNotEvaluable) as refused: + _built(deep) + assert "nests deeper than this module walks" in str(refused.value) + + +def test_another_dialect_is_refused() -> None: + with pytest.raises(V.SchemaNotEvaluable) as refused: + V.KindValidator("k", "c", "", {"$schema": "http://json-schema.org/draft-07/schema#"}, + "0" * 64) + assert "dialect" in str(refused.value) + + +def test_a_reference_rule_the_catalog_does_not_declare_is_not_enforced() -> None: + """The neutral contract's catalog, less one reference rule, is refused: + this module implements it and the contract would not declare it.""" + contract = _contract() + contract["x-rules"] = [r for r in contract["x-rules"] if r["id"] != "ids-are-unique"] + with pytest.raises(V.SchemaNotEvaluable) as refused: + V.KindValidator(SNAPSHOT, "opendox-snapshot", "", contract, "0" * 64) + assert "ids-are-unique" in str(refused.value) + + +def test_every_copy_builds_and_is_cached_by_its_proved_digest() -> None: + first = V.validators() + assert sorted(first) == list(V.KINDS) + again = V.validators() + assert first is not again and all(first[k] is again[k] for k in V.KINDS) + for kind, built in first.items(): + copy_id, pointer = V.KIND_ENTRIES[kind] + assert (built.kind, built.copy_id, built.pointer) == (kind, copy_id, pointer) + assert built.digest == contracts.record().copy(copy_id).sha256 + + +# --------------------------------------------------------------------------- +# 5. the doxBench seam's two readers, over these validators +# --------------------------------------------------------------------------- + +def test_the_doxbench_seams_readers_judge_the_chat_turn_through_these_validators() -> None: + """`serve_workbench` reads a validator's `iter_errors()` and each error's + `validator` and `absolute_path`, as jsonschema spells them. Over + `validators()`, a valid request conforms; a request carrying only the + outline breaks the buffers' `minItems` floor and nothing else; and one + that breaks something besides is told apart from it.""" + seam = serve_workbench.WorkbenchRoutes + validators = V.validators() + request = _read(EXAMPLES / "workbench-chat-turn-v2-loaded-set.example.yaml") + kind = request["kind"] + assert seam._doxbench_wire_conforms(validators, kind, request) + assert not seam._doxbench_violation_beside_the_buffers_floor(validators, kind, request) + + outline_only = {**request, "buffers": [b for b in request["buffers"] + if b.get("kind") == "outline"]} + assert len(outline_only["buffers"]) == 1 + errors = list(validators[kind].iter_errors(outline_only)) + assert [(e.validator, list(e.absolute_path)) for e in errors] == [("minItems", ["buffers"])] + assert not seam._doxbench_wire_conforms(validators, kind, outline_only) + assert not seam._doxbench_violation_beside_the_buffers_floor(validators, kind, + outline_only) + + and_more = {**outline_only, "message": ""} + assert seam._doxbench_violation_beside_the_buffers_floor(validators, kind, and_more) + # A kind with no validator is no verdict, and no verdict is not consent. + assert not seam._doxbench_wire_conforms(validators, "workbench-chat-turn", request) + + +# --------------------------------------------------------------------------- +# 6. over openDox's own projection (F7.2's judgment, in process) +# --------------------------------------------------------------------------- + +ANCHOR_DATE = "2026-09-27T12:00:00+00:00" + + +def _git(root: Path, *args: str) -> None: + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env.update({ + "GIT_AUTHOR_NAME": "fixture", "GIT_AUTHOR_EMAIL": "fixture@example.invalid", + "GIT_COMMITTER_NAME": "fixture", "GIT_COMMITTER_EMAIL": "fixture@example.invalid", + "GIT_AUTHOR_DATE": ANCHOR_DATE, "GIT_COMMITTER_DATE": ANCHOR_DATE, + "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull, + }) + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True, env=env) + + +def _committed_copy(tmp_path: Path, fixture: Path) -> Path: + root = tmp_path / fixture.name + shutil.copytree(fixture, root) + _git(root, "-c", "init.defaultBranch=main", "init", "-q") + _git(root, "add", "-A") + _git(root, "commit", "-qm", "fixture") + return root + + +@pytest.fixture +def _default_home(): + """openDox's default home corpus registered, and the registry put back.""" + saved = corpus_adapter._home_factory + corpus_adapter.register_home(lambda root: ( + lga.WorkingTreeCorpus(), corpus_adapter.CorpusRef(name="home", location=str(root)))) + yield + corpus_adapter._home_factory = saved + + +def test_openDox_projection_of_the_plain_fixture_breaks_no_rule( + tmp_path: Path, _default_home) -> None: + snapshot = default_generator.generate(_committed_copy(tmp_path, PLAIN), "fixture") + assert snapshot["kind"] == SNAPSHOT + assert V.validate(snapshot, kind=default_generator.GENERATOR.contract) == [] + + +def test_openDox_projection_of_the_malformed_fixture_breaks_its_expected_rule( + tmp_path: Path, _default_home) -> None: + """T051's fixture breaks exactly one rule, and the report names the rule + `EXPECTED_RULE` holds, which is what F7.2 greps the verbs' output for.""" + rule = (MALFORMED / "EXPECTED_RULE").read_text(encoding="utf-8").strip() + snapshot = default_generator.generate(_committed_copy(tmp_path, MALFORMED), "fixture") + found = V.validate(snapshot, kind=SNAPSHOT) + assert [(v.rule, v.where) for v in found] == [(rule, "/documents/1/title")], V.report(found) + assert any(rule in line for line in V.report(found)) + + +# --------------------------------------------------------------------------- +# 7. no reach +# --------------------------------------------------------------------------- + +_BLOCKED = """ +import json, sys +for name in SIBLINGS: + sys.modules[name] = None +before = set(sys.modules) +import yaml # what PyYAML brings with it (its C binding's runtime) is PyYAML's +pyyaml = sorted({m.split(".")[0] for m in set(sys.modules) - before}) +from opendox import validator +built = validator.validators() +snap = {"schema_version": 1, "kind": "opendox-snapshot", "repository": "r", + "generation": {"source_revision": "HEAD"}, "documents": [], "clusters": [], + "possibles": [], "staged_topics": [], "changes": []} +print(json.dumps({"kinds": sorted(built), "violations": validator.report(validator.validate(snap)), + "pyyaml": pyyaml, + "siblings": [n for n in SIBLINGS if sys.modules.get(n) is not None], + "new": sorted({m.split(".")[0] for m in set(sys.modules) - before})})) +""" + + +def test_the_validator_imports_and_validates_with_every_sibling_blocked() -> None: + program = (f"import sys; sys.path.insert(0, {str(SRC)!r})\n" + f"SIBLINGS = {SIBLINGS!r}\n" + textwrap.dedent(_BLOCKED)) + done = subprocess.run([sys.executable, "-c", program], capture_output=True, text=True, + cwd=str(ROOT), timeout=120) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout.strip().splitlines()[-1]) + assert out["kinds"] == list(V.KINDS) + assert out["violations"] == [] + assert "yaml" in out["pyyaml"] + third_party = sorted(set(out["new"]) - set(sys.stdlib_module_names) + - {"opendox"} - set(out["pyyaml"]) - set(SIBLINGS)) + assert third_party == [], third_party + assert out["siblings"] == [], "a sibling was imported past its block" + + +def test_the_validator_leaves_the_registries_as_it_found_them() -> None: + """Validating registers nothing: no profile, no generator, no home.""" + before = (domain_profile._registered, gs._registered, corpus_adapter._home_factory) + V.validate(_read(EXAMPLES / "opendox-snapshot-six-stations.example.yaml")) + assert (domain_profile._registered, gs._registered, + corpus_adapter._home_factory) == before diff --git a/tests/test_validator_input_set.py b/tests/test_validator_input_set.py new file mode 100644 index 00000000..763bae43 --- /dev/null +++ b/tests/test_validator_input_set.py @@ -0,0 +1,432 @@ +"""openDox's validator's input set, held to plan 034's T057. + +T057 realizes #1144's 7.1, 7.1a, 7.1b and 7.2, with 7.1 as T007's batch G +amends it (R1Q11 (a) and R1Q12 (a), `openxFactory#656` comment `5850003126`): + + Narrow it to openDox's own kinds: its spec leg's four, which are 7.1's + three and T053's neutral snapshot schema ... Ship the four as package + data. A test checks each copy's digest against the spec-leg commit the + openDox root pins (R1Q12 (a)) ... A test asserts that `gate-intent` and + `ideation-possibles-register` are NOT in the set (7.1b). + +Its falsifier is *"the packaged-copy digest test and the 7.1b test"*. They are +the first two cases below: + +* `test_each_packaged_copy_is_the_spec_legs_file_at_the_pinned_commit` is the + digest test; +* `test_gate_intent_and_the_possibles_register_are_not_in_the_set` is 7.1b's. + +WHAT ELSE IT HOLDS. + +1. THE SET IS EXACTLY FOUR: the record, the validator's kinds and the files on + disk all name the same four copies, and none of the six schemas outside + the set is carried. +2. PRESENCE IS NOT IDENTITY: a copy that differs from its digest, is absent, + or has no digest recorded is refused before a byte of it is parsed, and a + validator is never built over it. A record that cannot hold every copy to a + digest is refused as a whole. +3. THE KINDS ARE THE COPIES': each kind the validator maps is the `kind` const + its entry declares, and `generator_seam.NEUTRAL_SNAPSHOT_KIND`, the + contract openDox's own generator declares (T052), is the packaged neutral + contract's `kind` (the holder's note to T057). +4. AN INSTALL CARRIES THEM: `pyproject.toml`'s package-data table ships, + under `opendox.contracts`, exactly the record and every copy the record + pins, and the bundle's own line beside it is unchanged. + +A CREATED file: no carve-manifest row (RULED OQ-C). +""" + +from __future__ import annotations + +import hashlib +import os +import shutil +import types +from pathlib import Path + +import pytest +import yaml + +from opendox import contracts +from opendox import default_generator +from opendox import generator_seam +from opendox import validator + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = ROOT / "src" / "opendox" / "contracts" + +#: openDox's own spec leg's four schemas (7.1, as batch G amends it). +THE_FOUR = ("ideation-workbench", "opendox-snapshot", "xfactory-workbench-chat-turn", + "xfactory-workbench-model-catalog") + +#: The spec-leg commit the openDox root pins, which the copies are held to: T053 +#: as landed, openDox-spec#16's squash (opensoft/openDox#14 moved the root's spec +#: pin to it). It is the one commit that carries all four. It moved from #16's +#: head, cd49eb25, with the record, in lockstep. The two commits have one tree. +SPEC_COMMIT = "f7ee3c763b3af4581daf1cd54406e5111e9358e6" + +#: WHAT THE OPENDOX ROOT PINS, stated here apart from the record, so that a copy +#: and its recorded digest cannot move together unseen. All four are the +#: digests the root's `contracts/manifest.yaml` records for them at its spec +#: pin, `SPEC_COMMIT` (the root's `main` at 52005213, opensoft/openDox#14). The +#: first three are unchanged from the root's previous spec pin, 8fe8c4c7. The +#: fourth is T053's new `opendox-snapshot` entry. +PINNED_BY_THE_ROOT = { + "ideation-workbench": + "d30438491119c20928fbe4e85088fc33682829eeb6558d87dafce651000faafc", + "opendox-snapshot": + "f9e3e111af1d4bd4c377c933027d81b582ae2b0a395b66f4e4621992454a584a", + "xfactory-workbench-chat-turn": + "350bfedc02696e7281a42c0bdc9a25059bf7af14d16d89d9f07018d3e691dc1d", + "xfactory-workbench-model-catalog": + "e563cc9fc6ede03dfd62537935d0ae0842617d7de46702aee6ad9026aa021635", +} + +#: The ten schemas of the family the consumer's script names +#: (`SCHEMA_FILENAMES`), by owner, as #1144's 7.1 tables them. +OPENXDOX_SPECS = ("ideation-dashboard-snapshot", "ideation-dashboard-snapshot-index", + "gate-action-record") +OPENXFACTORYS = ("ideation-possibles-register", "project-register", + "demotion-execution-receipt", "gate-intent") + + +# --------------------------------------------------------------------------- +# the falsifier +# --------------------------------------------------------------------------- + +def test_each_packaged_copy_is_the_spec_legs_file_at_the_pinned_commit() -> None: + """THE DIGEST TEST. Each copy on disk has the sha256 the record pins, the + record pins each copy at the spec leg's own path and at one spec-leg + commit, and each digest is the one the openDox root pins for that file.""" + record = contracts.record() + assert record.spec_leg == "opensoft/openDox-spec" + assert record.commit == SPEC_COMMIT, ( + f"the copies are recorded at {record.commit}, and this test holds them at " + f"{SPEC_COMMIT}. They move together, in one commit: copy the spec leg's " + "files at the commit the openDox root pins, and move both") + assert record.ids == THE_FOUR + for copy in record.copies: + on_disk = PACKAGE / "schemas" / f"{copy.id}.schema.yaml" + digest = hashlib.sha256(on_disk.read_bytes()).hexdigest() + assert copy.path == f"contracts/schemas/{copy.id}.schema.yaml" + assert copy.resource == f"schemas/{copy.id}.schema.yaml" + assert digest == copy.sha256, ( + f"{on_disk.relative_to(ROOT)} is {digest}, and the record pins " + f"{copy.sha256}. A copy is never edited in place") + assert copy.sha256 == PINNED_BY_THE_ROOT[copy.id], ( + f"the record pins {copy.id} at {copy.sha256}, and the openDox root " + f"pins {PINNED_BY_THE_ROOT[copy.id]}") + # the package reads the same bytes, through its own identity check + assert contracts.verified_bytes(copy.id) == on_disk.read_bytes() + + +def test_gate_intent_and_the_possibles_register_are_not_in_the_set() -> None: + """7.1b. `gate-intent` is an intent-plane schema that requirement 1 keeps + with openxFactory, and `ideation-possibles-register` is openxFactory's own + candidate register. Neither is a kind openDox validates, neither is a + packaged copy, neither is on disk, and no validator can be asked for + either.""" + for name in ("gate-intent", "ideation-possibles-register"): + assert name not in validator.KIND_ENTRIES, f"{name} is a kind openDox validates" + assert name not in {copy for copy, _pointer in validator.KIND_ENTRIES.values()}, ( + f"a kind is validated against a copy of {name}") + assert name not in contracts.record().ids, f"the record pins a copy of {name}" + assert name not in contracts.COPY_IDS, f"a record may name a copy of {name}" + assert not (PACKAGE / "schemas" / f"{name}.schema.yaml").exists(), ( + f"a copy of {name} is carried under src/opendox/contracts/schemas/, " + "which 7.1b refuses: requirement 1 keeps it with openxFactory") + with pytest.raises(validator.UnknownKind): + validator.validator_for(name) + with pytest.raises(contracts.CopyRefused): + contracts.verified_bytes(name) + + +# --------------------------------------------------------------------------- +# the set is exactly four +# --------------------------------------------------------------------------- + +def test_the_set_is_the_spec_legs_four_and_nothing_else() -> None: + """7.1: openDox validates its own spec leg's kinds. The record, the kinds' + entries and the files on disk name the same four copies. None of the + consumer's three and none of openxFactory's four is carried.""" + entries = {copy for copy, _pointer in validator.KIND_ENTRIES.values()} + on_disk = {path.name.removesuffix(".schema.yaml") + for path in (PACKAGE / "schemas").iterdir()} + assert set(THE_FOUR) == entries == on_disk == set(contracts.record().ids) + assert contracts.COPY_IDS == set(THE_FOUR) + assert not (set(OPENXDOX_SPECS) | set(OPENXFACTORYS)) & on_disk + assert sorted(p.name for p in PACKAGE.iterdir() if p.name != "__pycache__") == [ + "__init__.py", "copies.yaml", "schemas"] + + +def test_each_kind_is_the_const_its_entry_declares() -> None: + """The validator's map of kinds is the copies' own: each kind's entry + declares that kind as its `kind` const. So a kind the map names and no + copy declares, or a copy's kind the map leaves out, fails here.""" + derived = {} + for copy_id in THE_FOUR: + document = contracts.load(copy_id) + envelopes = [document] if "oneOf" not in document else [ + validator._at_pointer(document, branch["$ref"][1:]) + for branch in document["oneOf"]] + for envelope in envelopes: + derived[envelope["properties"]["kind"]["const"]] = copy_id + assert {kind: copy for kind, (copy, _pointer) in validator.KIND_ENTRIES.items()} == derived + assert validator.KINDS == tuple(sorted(derived)) + for kind, (copy_id, pointer) in validator.KIND_ENTRIES.items(): + entry = validator._at_pointer(contracts.load(copy_id), pointer) + assert entry["properties"]["kind"]["const"] == kind + + +def test_the_neutral_snapshot_kind_is_the_packaged_contracts_kind() -> None: + """The contract openDox's own generator declares (T052's + `NEUTRAL_SNAPSHOT_KIND`) is the packaged neutral contract's `kind` const, + so what the generator writes is what this validator validates it + against.""" + snapshot_contract = contracts.load("opendox-snapshot") + kind = snapshot_contract["properties"]["kind"]["const"] + assert generator_seam.NEUTRAL_SNAPSHOT_KIND == kind == "opendox-snapshot" + assert default_generator.GENERATOR.contract == kind + assert validator.KIND_ENTRIES[kind] == ("opendox-snapshot", "") + assert validator.validator_for(kind).copy_id == "opendox-snapshot" + + +# --------------------------------------------------------------------------- +# presence is not identity +# --------------------------------------------------------------------------- + +def _serve(monkeypatch: pytest.MonkeyPatch, replaced: dict[str, bytes | None]) -> None: + """Answer the package's reads from `replaced` where it names the file + (None is an absent file), and from the package itself otherwise.""" + real = contracts._read_package_file + + def read(name: str) -> bytes: + if name in replaced: + if replaced[name] is None: + raise contracts.CopyRefused(f"opendox.contracts has no {name}") + return replaced[name] + return real(name) + + monkeypatch.setattr(contracts, "_read_package_file", read) + + +@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, + reason="root reads a file at mode 000") +@pytest.mark.parametrize("name", ["copies.yaml", "schemas/opendox-snapshot.schema.yaml"]) +def test_a_present_file_that_cannot_be_read_is_refused_not_raised( + name: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A packaged record or copy that is present but unreadable is refused as + `CopyRefused`, so the validator reports itself unavailable, as for a + missing one. It raised `PermissionError` out of `validator_for()`, and + `generate --strict` ended in a traceback (the T058 writer's measurement, + from Copilot at openDox-code#68, r4139734412).""" + package = tmp_path / "contracts" + shutil.copytree(PACKAGE, package) + (package / name).chmod(0) + monkeypatch.setattr(contracts, "resources", + types.SimpleNamespace(files=lambda _name: package)) + try: + with pytest.raises(contracts.CopyRefused) as refused: + contracts.load("opendox-snapshot") + assert "cannot be read (PermissionError" in str(refused.value) + with pytest.raises(validator.ValidatorUnavailable): + validator.validator_for("opendox-snapshot") + finally: + (package / name).chmod(0o644) + + +def _record_with(**changes) -> bytes: + data = yaml.safe_load((PACKAGE / "copies.yaml").read_text(encoding="utf-8")) + data.update(changes) + return yaml.safe_dump(data, sort_keys=False).encode() + + +def test_a_changed_copy_is_refused_before_a_byte_of_it_is_parsed( + monkeypatch: pytest.MonkeyPatch) -> None: + """One byte added to the neutral contract's copy is refused by the + identity check, naming both digests, before YAML is asked to read it; and + the validator reports itself unavailable rather than validating.""" + changed = (PACKAGE / "schemas" / "opendox-snapshot.schema.yaml").read_bytes() + b"\n" + _serve(monkeypatch, {"schemas/opendox-snapshot.schema.yaml": changed}) + parsed: list = [] + real_load = yaml.safe_load + + def spy(stream, *args, **kwargs): + parsed.append(stream) + return real_load(stream, *args, **kwargs) + + monkeypatch.setattr(yaml, "safe_load", spy) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.load("opendox-snapshot") + assert PINNED_BY_THE_ROOT["opendox-snapshot"] in str(refused.value) + assert hashlib.sha256(changed).hexdigest() in str(refused.value) + assert parsed, "the record, which is read first, was not read through YAML" + assert changed not in parsed, ( + "the changed copy was parsed before its identity was proved") + + +def test_a_changed_copy_leaves_the_validator_unavailable( + monkeypatch: pytest.MonkeyPatch) -> None: + """The validator proves the copy on every call, so a copy that changes + after a validator was built and cached is refused on the next call, not + answered from the cache.""" + assert validator.validator_for("opendox-snapshot").is_valid( + yaml.safe_load((ROOT / "tests" / "fixtures" / "spec-examples" / + "opendox-snapshot-no-front-matter.example.yaml").read_text())) + real = (PACKAGE / "schemas" / "opendox-snapshot.schema.yaml").read_bytes() + _serve(monkeypatch, {"schemas/opendox-snapshot.schema.yaml": real.replace( + b'"minLength": 1', b'"minLength": 0', 1)}) + with pytest.raises(validator.ValidatorUnavailable) as unavailable: + validator.validator_for("opendox-snapshot") + assert "is not the file the record pins" in str(unavailable.value) + with pytest.raises(validator.ValidatorUnavailable): + validator.validators() + + +def test_an_absent_copy_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + _serve(monkeypatch, {"schemas/ideation-workbench.schema.yaml": None}) + with pytest.raises(contracts.CopyRefused): + contracts.verified_bytes("ideation-workbench") + with pytest.raises(validator.ValidatorUnavailable): + validator.validator_for("ideation-workbench") + + +#: The shipped record's own entries, for the cases that add one or drop one. +_SHIPPED = yaml.safe_load((PACKAGE / "copies.yaml").read_text(encoding="utf-8"))["copies"] + + +@pytest.mark.parametrize("changes, says", [ + ({"copies": [{"id": "opendox-snapshot", + "path": "contracts/schemas/opendox-snapshot.schema.yaml", + "sha256": ""}]}, "not a 64-hex sha256"), + ({"copies": [{"id": "opendox-snapshot", + "path": "contracts/schemas/opendox-snapshot.schema.yaml"}]}, + "exactly id, path and sha256"), + ({"copies": [{"id": "opendox-snapshot", "path": "schemas/elsewhere.yaml", + "sha256": "0" * 64}]}, "not 'contracts/schemas/opendox-snapshot"), + ({"copies": [{"id": "opendox-snapshot", + "path": "contracts/schemas/opendox-snapshot.schema.yaml", + "sha256": "f" * 64}] * 2}, "recorded twice"), + ({"copies": []}, "not a non-empty list"), + ({"commit": "cd49eb25"}, "not a full 40-hex commit id"), + ({"spec_leg": "opensoft/openXdox-spec"}, "spec_leg is"), + ({"kind": "pinned_contract_manifest"}, "kind is"), + ({"schema_version": True}, "schema_version is"), + ({"unread": 1}, "its keys are"), + # 7.1b at run time: an edited record cannot let a fifth schema in, even a + # well-formed entry whose file sits beside the four, nor leave one out. + ({"copies": _SHIPPED + [{"id": "gate-intent", + "path": "contracts/schemas/gate-intent.schema.yaml", + "sha256": "0" * 64}]}, "not openDox's four"), + ({"copies": _SHIPPED[:3]}, "not openDox's four"), +], ids=["empty digest", "no digest", "wrong path", "repeated id", "no copies", + "short commit", "another leg", "another kind", "boolean version", "unknown key", + "a fifth copy", "three copies"]) +def test_a_record_that_cannot_hold_every_copy_is_refused( + monkeypatch: pytest.MonkeyPatch, changes: dict, says: str) -> None: + """An empty or absent digest is drift and never a pass, and so is a record + whose shape leaves any copy unpinned.""" + _serve(monkeypatch, {contracts.RECORD_NAME: _record_with(**changes)}) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.record() + assert says in str(refused.value) + with pytest.raises(validator.ValidatorUnavailable): + validator.validator_for("opendox-snapshot") + + +def test_a_record_whose_keys_are_not_all_text_is_refused( + monkeypatch: pytest.MonkeyPatch) -> None: + """A YAML key need not be text. The refusal names such a key rather than + failing to order it against the others.""" + data = yaml.safe_load((PACKAGE / "copies.yaml").read_text(encoding="utf-8")) + data[1] = "a number as a key" + _serve(monkeypatch, {contracts.RECORD_NAME: yaml.safe_dump(data, sort_keys=False).encode()}) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.record() + assert "its keys are ['commit', 'copies', 'kind', 'schema_version', 'spec_leg', 1]" in ( + str(refused.value)) + with pytest.raises(validator.ValidatorUnavailable): + validator.validator_for("opendox-snapshot") + + +#: YAML nested past Python's recursion limit, which no read of it ends. +_TOO_DEEP = b"[" * 5000 + b"]" * 5000 + + +def test_a_record_nested_past_the_limit_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + _serve(monkeypatch, {contracts.RECORD_NAME: _TOO_DEEP}) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.record() + assert "not YAML this module can read (RecursionError)" in str(refused.value) + + +@pytest.mark.parametrize("copy, raised", [ + (_TOO_DEEP, "RecursionError"), + (b"kind: 2026-02-30\n", "ValueError"), +], ids=["nested past the limit", "an impossible date"]) +def test_an_unreadable_copy_is_refused_under_its_own_digest( + monkeypatch: pytest.MonkeyPatch, copy: bytes, raised: str) -> None: + """Recorded under its own digest, so it passes the identity check, the copy + is still refused as unreadable, never a RecursionError or a ValueError to + the caller.""" + entries = [dict(entry) for entry in _SHIPPED] + for entry in entries: + if entry["id"] == "opendox-snapshot": + entry["sha256"] = hashlib.sha256(copy).hexdigest() + _serve(monkeypatch, {contracts.RECORD_NAME: _record_with(copies=entries), + "schemas/opendox-snapshot.schema.yaml": copy}) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.load("opendox-snapshot") + assert f"({raised})" in str(refused.value) + with pytest.raises(validator.ValidatorUnavailable) as unavailable: + validator.validator_for("opendox-snapshot") + assert f"({raised})" in str(unavailable.value) + + +@pytest.mark.parametrize("record", [ + b"schema_version: " + b"9" * 5000 + b"\n", + b"schema_version: 1\nkind: 2026-02-30\n", +], ids=["an integer past 4300 digits", "an impossible date"]) +def test_a_record_pyyaml_cannot_construct_is_refused( + monkeypatch: pytest.MonkeyPatch, record: bytes) -> None: + """PyYAML raises ValueError, not a YAMLError, for a literal it cannot + construct. The record is refused, never a ValueError to the caller.""" + _serve(monkeypatch, {contracts.RECORD_NAME: record}) + with pytest.raises(contracts.CopyRefused) as refused: + contracts.record() + assert "not YAML this module can read (ValueError)" in str(refused.value) + with pytest.raises(validator.ValidatorUnavailable): + validator.validator_for("opendox-snapshot") + + +def test_the_record_as_shipped_is_accepted() -> None: + """The negative cases above change one field each of the shipped record, + so this is their control.""" + record = contracts.record() + assert (record.commit, record.ids) == (SPEC_COMMIT, THE_FOUR) + + +# --------------------------------------------------------------------------- +# package data (7.1): an install carries the copies +# --------------------------------------------------------------------------- + +def test_the_package_data_ships_the_record_and_every_copy() -> None: + """7.1 settles that the copies travel as PACKAGE DATA, so an install has + them beside the validator. The package-data table names, under + `opendox.contracts`, exactly the record and every schema copy the record + pins, and the bundle's own line is unchanged. (A wheel built without it + carries `opendox/contracts/__init__.py` alone, and its validator refuses, + naming the absent record.)""" + import fnmatch + import tomllib + + table = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8")) + data = table["tool"]["setuptools"]["package-data"] + assert data["opendox"] == ["web/**"] + patterns = data["opendox.contracts"] + shipped = sorted( + relative for relative in (p.relative_to(PACKAGE).as_posix() + for p in PACKAGE.rglob("*") if p.is_file()) + if any(fnmatch.fnmatchcase(relative, pattern) for pattern in patterns)) + assert shipped == sorted([contracts.RECORD_NAME] + + [copy.resource for copy in contracts.record().copies])