{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://skill-map.ai/spec/v1/conformance-case.schema.json",
  "title": "ConformanceCase",
  "description": "Shape of a single declarative test case under `spec/conformance/cases/<id>.json`. Consumed by language-neutral conformance runners. See `spec/conformance/README.md` for the runner contract.",
  "type": "object",
  "required": ["id", "description", "invoke", "assertions"],
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string",
      "format": "uri",
      "description": "Optional IDE hint. Implementations MUST ignore this field."
    },
    "id": {
      "type": "string",
      "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$",
      "description": "Kebab-case unique identifier. MUST equal the filename stem (`cases/<id>.json`)."
    },
    "description": {
      "type": "string",
      "minLength": 1,
      "description": "One-to-three-sentence human-readable explanation of what the case verifies."
    },
    "fixture": {
      "type": "string",
      "description": "Folder name under `conformance/fixtures/` used as the scope root. Omit for cases that do not need a corpus (e.g. `kernel-empty-boot`)."
    },
    "setup": {
      "type": "object",
      "description": "Pre-invocation toggles and ordered staging steps. All toggles default to `false`. `priorScans`, when present, run BEFORE the main `invoke` so a case can establish a prior snapshot the heuristic-driven verbs (e.g. `sm scan` rename detection) can react to. `priorInvokes`, when present, run AFTER the top-level `fixture` copy and BEFORE the main `invoke` for state a scan alone cannot establish (e.g. `sm jobs submit` before `sm jobs preview --last`).",
      "additionalProperties": false,
      "properties": {
        "disableAllProviders": {
          "type": "boolean",
          "description": "When true, the runner injects `SKILL_MAP_DISABLE_ALL_PROVIDERS=1` into the child process environment, dropping every Provider extension (built-in and user-plugin) before scan composition."
        },
        "disableAllExtractors": {
          "type": "boolean",
          "description": "When true, the runner injects `SKILL_MAP_DISABLE_ALL_EXTRACTORS=1` into the child process environment, dropping every Extractor extension before scan composition."
        },
        "disableAllAnalyzers": {
          "type": "boolean",
          "description": "When true, the runner injects `SKILL_MAP_DISABLE_ALL_ANALYZERS=1` into the child process environment, dropping every Analyzer extension before scan composition."
        },
        "serve": {
          "type": "boolean",
          "description": "When true, the runner starts the implementation's server (the `sm serve` equivalent) on an EPHEMERAL port inside the scope, after the top-level fixture copy and plugin trust and BEFORE `setup.priorInvokes`. Readiness is observed via `.skill-map/serve.json` (the discovery file the serve contract already mandates), and the server stays up through the main `invoke` AND assertion evaluation, then is torn down. Keeping it up through assertions is the point: it lets `file-matches-schema` observe files that exist only while the server runs, and it is what the `http-matches-schema` assertion depends on."
        },
        "staticServe": {
          "type": "object",
          "required": ["fixture"],
          "additionalProperties": false,
          "description": "Recorded-fixture network transport. The runner serves the named fixture directory READ-ONLY over loopback HTTP on an EPHEMERAL port for the case's lifetime (up before `setup.priorInvokes`, torn down only after assertion evaluation), and binds the base URL (e.g. `http://127.0.0.1:49152`) as the substitution variable `{{staticServeUrl}}`, available everywhere `capture` variables are. Exists for extensions whose contract involves a network fetch: the fixture holds RECORDED responses as plain files whose relative paths mirror the request paths, so the case exercises the REAL fetch path against deterministic bytes while the scope stays offline (no real network is ever reached). Path resolution is containment-guarded like every other fixture reference: a fixture reference or request path escaping its anchor is refused, directory listings are not served, and unknown paths return 404.",
          "properties": {
            "fixture": {
              "type": "string",
              "description": "Folder under `conformance/fixtures/` whose files are served verbatim: a request for `/a/b` answers with the bytes of `<fixture>/a/b` (200) or 404 when no such file exists. MUST stay inside the fixtures root."
            }
          }
        },
        "priorScans": {
          "type": "array",
          "description": "Ordered staging scans, each running before the next. For step N, the runner first replaces the scope's Provider content with `priorScans[N].fixture`, then invokes `sm scan` with the supplied flags. After the last step, the runner copies the top-level `fixture` (overwriting again) and runs the main `invoke`. The DB persists across all steps because `.skill-map/` is preserved between fixture swaps.",
          "items": {
            "type": "object",
            "required": ["fixture"],
            "additionalProperties": false,
            "properties": {
              "fixture": {
                "type": "string",
                "description": "Folder under `conformance/fixtures/` to copy into the scope, replacing every non-`.skill-map/` directory."
              },
              "flags": {
                "type": "array",
                "description": "Flags passed to `sm scan`. Default: empty (full scan).",
                "items": { "type": "string" }
              }
            }
          }
        },
        "priorInvokes": {
          "type": "array",
          "description": "Ordered staging invocations, each run against the fully-provisioned scope (after the top-level `fixture` copy) and before the main `invoke`. A step MUST exit 0 unless it declares `expectExit`; an unexpected exit fails the case without evaluating its assertions. Use when the main `invoke` needs prior state that `priorScans` cannot establish (e.g. a submitted job).",
          "items": { "$ref": "#/$defs/StagedInvocation" }
        }
      }
    },
    "invoke": {
      "allOf": [{ "$ref": "#/$defs/Invocation" }],
      "unevaluatedProperties": false,
      "properties": {
        "parallel": {
          "type": "integer",
          "minimum": 2,
          "maximum": 8,
          "description": "The runner spawns this many IDENTICAL invocations CONCURRENTLY (all started before any is awaited), which is the only way a case can express a race; capture substitution applies to each copy identically. When `parallel` is set, the per-result assertion types (`exit-code`, `json-path`, the schema and verbatim families, `stderr-matches`) are AUTHORING ERRORS, because \"the\" result is ambiguous across N; only the `parallel-*` set assertions may be used, and the runner MUST fail loudly on a violation rather than picking a result silently."
        }
      }
    },
    "assertions": {
      "type": "array",
      "description": "Ordered list of assertions. A case passes iff every assertion passes.",
      "minItems": 1,
      "items": { "$ref": "#/$defs/Assertion" }
    }
  },
  "$defs": {
    "Invocation": {
      "type": "object",
      "required": ["verb"],
      "description": "A single CLI invocation. Deliberately does NOT close itself with `additionalProperties` / `unevaluatedProperties`: it is composed via `allOf` at both use sites (`invoke` and `StagedInvocation`), and a closure declared HERE would see only its own annotations, rejecting the extra properties its composer legitimately adds.",
      "properties": {
        "verb": {
          "type": "string",
          "description": "First-level CLI verb (`scan`, `list`, `show`, `check`, `findings`, `graph`, `export`, `job`, `record`, …)."
        },
        "sub": {
          "type": "string",
          "description": "Subcommand for verbs that have them (e.g. `submit` for `job submit`)."
        },
        "args": {
          "type": "array",
          "description": "Positional arguments, in order.",
          "items": { "type": "string" }
        },
        "flags": {
          "type": "array",
          "description": "Flags. Implementations MUST accept them in any order; ordering here is cosmetic.",
          "items": { "type": "string" }
        }
      }
    },
    "StagedInvocation": {
      "type": "object",
      "description": "A `setup.priorInvokes` step: an `Invocation` plus the two staging-only controls. Composed via `allOf` so the invocation shape stays defined once.",
      "allOf": [{ "$ref": "#/$defs/Invocation" }],
      "unevaluatedProperties": false,
      "properties": {
        "expectExit": {
          "type": "integer",
          "minimum": 0,
          "maximum": 255,
          "description": "Exit code this staging step MUST return. Defaults to 0. Declare it when the state under test is a REFUSAL rather than a success (e.g. staging a duplicate `sm jobs submit` that must exit 3 before asserting the `--force` bypass): without it, every staged step is required to succeed, so a case can establish the happy path but never the rejection that precedes the behaviour it verifies."
        },
        "capture": {
          "type": "object",
          "description": "Values extracted from THIS step's stdout and bound as substitution variables for every later step and the main `invoke`, where `{{name}}` is replaced by the captured value. Exists because some credentials are minted at runtime and cannot be written into a static case: `sm jobs claim --json` issues the nonce `sm record` then requires. Keys are variable names; values are JSONPath expressions (same subset the `json-path` assertion accepts) evaluated against the step's parsed stdout. Stdout MUST parse as JSON and every expression MUST match, otherwise the case fails at staging: an unmatched capture would silently pass `{{nonce}}` through verbatim and surface as a puzzling credential rejection instead of a missing capture. Substitution applies to `args` and `flags` only, never to `verb` or `sub`, so a captured value can never redirect which command runs.",
          "propertyNames": { "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" },
          "additionalProperties": {
            "type": "string",
            "description": "JSONPath expression (RFC 9535 subset) evaluated against the step's parsed stdout."
          },
          "minProperties": 1
        },
        "sleepAfterMs": {
          "type": "integer",
          "minimum": 0,
          "maximum": 30000,
          "description": "The runner sleeps this long AFTER the step completes and before the next one. Exists solely to let TTL-expiry contracts become observable (`sm jobs submit --ttl 1`, then a sleep, then the reap); a case SHOULD arm a sleep of at least 3x the TTL it staged so wall-clock noise cannot flip the outcome. It is NOT a general pacing tool."
        }
      }
    },
    "Assertion": {
      "description": "Discriminated union. The `type` field selects the assertion shape.",
      "oneOf": [
        { "$ref": "#/$defs/ExitCode" },
        { "$ref": "#/$defs/JsonPath" },
        { "$ref": "#/$defs/FileExists" },
        { "$ref": "#/$defs/FileContainsVerbatim" },
        { "$ref": "#/$defs/StdoutContainsVerbatim" },
        { "$ref": "#/$defs/FileMatchesSchema" },
        { "$ref": "#/$defs/StdoutMatchesSchema" },
        { "$ref": "#/$defs/HttpMatchesSchema" },
        { "$ref": "#/$defs/NdjsonLineMatches" },
        { "$ref": "#/$defs/StderrMatches" },
        { "$ref": "#/$defs/ParallelExitCodes" },
        { "$ref": "#/$defs/ParallelJsonPathCount" }
      ]
    },
    "ExitCode": {
      "type": "object",
      "required": ["type", "value"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "exit-code" },
        "value": {
          "type": "integer",
          "minimum": 0,
          "maximum": 255,
          "description": "Exit code the invocation MUST return."
        }
      }
    },
    "JsonPath": {
      "type": "object",
      "required": ["type", "path"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "json-path" },
        "path": {
          "type": "string",
          "description": "JSONPath expression (RFC 9535 subset) evaluated against parsed stdout."
        },
        "equals": {
          "description": "Any JSON value. The extracted value MUST deep-equal this."
        },
        "greaterThan": {
          "type": "number"
        },
        "lessThan": {
          "type": "number"
        },
        "matches": {
          "type": "string",
          "description": "ECMAScript regex. The extracted value (coerced to string) MUST match."
        }
      },
      "anyOf": [
        { "required": ["equals"] },
        { "required": ["greaterThan"] },
        { "required": ["lessThan"] },
        { "required": ["matches"] }
      ]
    },
    "FileExists": {
      "type": "object",
      "required": ["type", "path"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "file-exists" },
        "path": {
          "type": "string",
          "description": "Relative to the scope root, and MUST stay inside it: a path escaping the scope is an assertion failure, never a read. Exactly one file; glob syntax is not interpreted."
        }
      }
    },
    "FileContainsVerbatim": {
      "type": "object",
      "required": ["type", "path", "fixture"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "file-contains-verbatim" },
        "path": {
          "type": "string",
          "description": "Relative to the scope root, and MUST stay inside it: a path escaping the scope is an assertion failure, never a read. Exactly one file; glob syntax is not interpreted."
        },
        "fixture": {
          "type": "string",
          "description": "Path under `conformance/fixtures/` whose bytes MUST appear verbatim in the target file."
        }
      }
    },
    "StdoutContainsVerbatim": {
      "type": "object",
      "required": ["type", "fixture"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "stdout-contains-verbatim" },
        "fixture": {
          "type": "string",
          "description": "Path under `conformance/fixtures/` whose bytes MUST appear verbatim in the invocation's stdout. Used for preamble bitwise checks."
        }
      }
    },
    "FileMatchesSchema": {
      "type": "object",
      "required": ["type", "path", "schema"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "file-matches-schema" },
        "path": {
          "type": "string",
          "description": "Relative to the scope root, and MUST stay inside it: a path escaping the scope is an assertion failure, never a read. Exactly one file; glob syntax is not interpreted. The file MUST parse as JSON; an unparseable file fails the assertion rather than being skipped."
        },
        "schema": {
          "type": "string",
          "description": "Path under `schemas/` that the file MUST validate against, with or without the `schemas/` prefix. MUST stay inside the spec root."
        },
        "schemaPointer": { "$ref": "#/$defs/SchemaPointer" },
        "each": { "$ref": "#/$defs/EachElement" }
      }
    },
    "StdoutMatchesSchema": {
      "type": "object",
      "required": ["type", "schema"],
      "additionalProperties": false,
      "description": "The stdout twin of `file-matches-schema`, and the form most schema-coverage rows need: a CLI's machine-readable surface is `--json` on stdout, not a file it happens to leave behind. Without it, a row claiming a schema is covered could only ever be backed by field-by-field `json-path` probes, which assert the fields someone remembered rather than the contract.",
      "properties": {
        "type": { "const": "stdout-matches-schema" },
        "schema": {
          "type": "string",
          "description": "Path under `schemas/` that the invocation's stdout MUST validate against, with or without the `schemas/` prefix. MUST stay inside the spec root. Stdout MUST parse as JSON; a non-JSON stdout fails the assertion rather than being skipped."
        },
        "schemaPointer": { "$ref": "#/$defs/SchemaPointer" },
        "each": { "$ref": "#/$defs/EachElement" }
      }
    },
    "HttpMatchesSchema": {
      "type": "object",
      "required": ["type", "request", "schema"],
      "additionalProperties": false,
      "description": "The HTTP twin of `stdout-matches-schema`: a request issued against the server `setup.serve` started, on loopback, with the port resolved from the scope's `.skill-map/serve.json`. Declaring it in a case without `setup.serve: true` is a case authoring error the runner MUST fail loudly (a failed assertion naming the error), never skip. The response body MUST parse as JSON and validate against the named schema. Exists because REST envelope contracts are HTTP response shapes: no CLI invocation ever emits them, so no stdout or file assertion can observe them.",
      "properties": {
        "type": { "const": "http-matches-schema" },
        "request": {
          "type": "object",
          "required": ["path"],
          "additionalProperties": false,
          "properties": {
            "path": {
              "type": "string",
              "pattern": "^/",
              "description": "Absolute URL path (leading slash) requested on the served port, e.g. `/api/nodes`. Query strings are carried verbatim."
            },
            "method": {
              "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"],
              "default": "GET",
              "description": "HTTP method. Defaults to `GET`."
            }
          }
        },
        "status": {
          "type": "integer",
          "minimum": 100,
          "maximum": 599,
          "default": 200,
          "description": "Status code the response MUST return. Defaults to `200`. A mismatch fails the assertion naming both codes, before the body is validated."
        },
        "schema": {
          "type": "string",
          "description": "Path under `schemas/` that the response body MUST validate against, with or without the `schemas/` prefix. MUST stay inside the spec root. The body MUST parse as JSON; a non-JSON body fails the assertion rather than being skipped."
        },
        "schemaPointer": { "$ref": "#/$defs/SchemaPointer" },
        "each": { "$ref": "#/$defs/EachElement" }
      }
    },
    "NdjsonLineMatches": {
      "type": "object",
      "required": ["type", "match"],
      "additionalProperties": false,
      "description": "Line-oriented stdout assertion for event-stream surfaces (`sm record --json` emits one envelope per line), which a whole-document assertion cannot parse at all. Stdout is parsed as NDJSON and EVERY non-empty line MUST parse as JSON (a non-JSON line fails the assertion, since surfaces contracted as ndjson emit nothing else on stdout). The assertion selects the FIRST line whose document deep-equals every key/value in `match` (top-level keys only); no matching line fails. When `path` plus a comparator are given they are evaluated against the matched line's document.",
      "properties": {
        "type": { "const": "ndjson-line" },
        "match": {
          "type": "object",
          "minProperties": 1,
          "description": "Top-level key/value selector. Each value is a JSON literal the matched line's document MUST deep-equal at that key. Keys are author-chosen, so this object is deliberately not closed."
        },
        "path": {
          "type": "string",
          "description": "Optional JSONPath expression (RFC 9535 subset) evaluated against the MATCHED line's document, paired with one of the comparators below."
        },
        "equals": {
          "description": "Any JSON value. The extracted value MUST deep-equal this."
        },
        "greaterThan": {
          "type": "number"
        },
        "lessThan": {
          "type": "number"
        },
        "matches": {
          "type": "string",
          "description": "ECMAScript regex. The extracted value (coerced to string) MUST match."
        }
      }
    },
    "SchemaPointer": {
      "type": "string",
      "pattern": "^/",
      "description": "Optional JSON Pointer (RFC 6901, leading slash) resolved INSIDE the referenced schema document, selecting the subschema the payload MUST validate against (e.g. `/$defs/PluginManifest`). Exists for schemas whose ROOT models an aggregate no implementation writes while a `$def` inside it describes the real artifact. The pointer navigates within the named schema only; it cannot reach another file, and a pointer that resolves to nothing fails the assertion."
    },
    "EachElement": {
      "type": "boolean",
      "description": "When true, the payload MUST be a NON-EMPTY array and every element MUST validate against the (pointer-resolved) schema; the assertion reports the first offending index. An empty array fails: validating zero elements proves nothing, and list surfaces are exactly where a vacuous pass hides. Default false: the whole document validates as one instance. Exists because list-shaped CLI surfaces (`sm history --json`, `sm jobs list --json`) emit arrays of per-item documents, and a whole-document assertion cannot name their element contract."
    },
    "StderrMatches": {
      "type": "object",
      "required": ["type", "pattern"],
      "additionalProperties": false,
      "properties": {
        "type": { "const": "stderr-matches" },
        "pattern": {
          "type": "string",
          "description": "ECMAScript regex. The full stderr output MUST match at least once."
        }
      }
    },
    "ParallelExitCodes": {
      "type": "object",
      "required": ["type", "sorted"],
      "additionalProperties": false,
      "description": "Set assertion over the N results of a `parallel` invoke: the multiset of the N invocations' exit codes, sorted ascending, MUST deep-equal `sorted`. The canonical use: with one queued job and two concurrent claims, `[0, 1]` IS the atomicity proof, since both succeeding would mean a double handout and both failing would mean a lost job. Declaring it without `invoke.parallel` is an authoring error the runner MUST fail loudly, never skip.",
      "properties": {
        "type": { "const": "parallel-exit-codes" },
        "sorted": {
          "type": "array",
          "minItems": 2,
          "maxItems": 8,
          "items": { "type": "integer", "minimum": 0, "maximum": 255 },
          "description": "Expected exit-code multiset, sorted ascending. Length MUST equal `invoke.parallel`."
        }
      }
    },
    "ParallelJsonPathCount": {
      "type": "object",
      "required": ["type", "path", "count"],
      "additionalProperties": false,
      "description": "Set assertion over the N results of a `parallel` invoke: of the N results, the number whose stdout parses as JSON AND satisfies the comparator at `path` MUST equal `count`. A result whose stdout is not JSON simply does not count (the losing claim prints nothing, and that is the point). Declaring it without `invoke.parallel` is an authoring error the runner MUST fail loudly, never skip.",
      "properties": {
        "type": { "const": "parallel-json-path-count" },
        "path": {
          "type": "string",
          "description": "JSONPath expression (RFC 9535 subset) evaluated against each result's parsed stdout."
        },
        "equals": {
          "description": "Any JSON value. The extracted value MUST deep-equal this for the result to count."
        },
        "greaterThan": {
          "type": "number"
        },
        "lessThan": {
          "type": "number"
        },
        "matches": {
          "type": "string",
          "description": "ECMAScript regex. The extracted value (coerced to string) MUST match for the result to count."
        },
        "count": {
          "type": "integer",
          "minimum": 0,
          "description": "Exact number of results that MUST satisfy the comparator."
        }
      },
      "anyOf": [
        { "required": ["equals"] },
        { "required": ["greaterThan"] },
        { "required": ["lessThan"] },
        { "required": ["matches"] }
      ]
    }
  }
}
