# v1.9.26 schema-contract polish

Date: 2026-05-29

This memo records the schema-contract polish that closes item 1 of the
"Outstanding before v2.0" list in `docs/roadmap.md`. The work is
documentation plus a schema-additive set of `$defs` and a small
pure-Python rule set. No runtime output shape changed.

## Why this shipped now

The pre-lock schema audit surfaced three gaps between
`docs/recon-schema.json` and the way real output relates to it:

1. The schema description read "contract for `recon <domain> --json`
   output" but said nothing about the batch and NDJSON modes, which
   emit additional record shapes (including a `{domain, error}` error
   record that does not match the single-domain contract).
2. The `domain` and `error` fields of the batch error record were
   declared nowhere in the schema.
3. `explanation_dag` and `unclassified_cname_chains` are emitted only
   behind flags, but the schema prose did not say so plainly, so a
   consumer could read them as always present.

A fourth gap surfaced while grounding the work in the emitter: the
`BatchResult` `$def` claimed the batch JSON always wraps the per-domain
results under `domains`. The default `recon batch --json` actually emits
a bare array (`recon_tool/cli.py` batch path, the
`typer.echo(json_mod.dumps(json_results, indent=2))` branch); only
`--include-ecosystem` wraps, and even that falls back to the bare array
when no domain resolved. The prose was corrected to match the emitter.

## What changed

- **`docs/recon-schema.json`**
  - Top-level `description` rewritten to scope the root object to
    single-domain success output and to point at the per-mode `$defs`.
  - New `$defs/BatchErrorRecord`: `{domain, error}`, both required,
    `additionalProperties: false`.
  - New `$defs/BatchArray` (default `batch --json`) and
    `$defs/BatchNdjsonRecord` (`batch --ndjson`): each element / line is
    `oneOf {root, BatchErrorRecord}`.
  - `$defs/BatchResult` description corrected (wrapper is the
    `--include-ecosystem` shape, with the bare-array fallback noted);
    `domains` items now `oneOf {root, BatchErrorRecord}`;
    `ecosystem_hyperedges` declared required in the wrapper.
  - `evidence`, `explanation_dag`, `unclassified_cname_chains`
    descriptions now state the conditional emission explicitly.
  - The batch-only cross-domain fields (`shared_verification_tokens`,
    `shared_tenant`, `shared_display_name`) are now declared as optional
    properties with batch-only descriptions.

- **`recon_tool/schema_contract.py`**
  - `BATCH_ERROR_RECORD_KEYS` and `classify_batch_record()` added: the
    single deterministic rule set a consumer applies to any batch /
    NDJSON record. Returns `success` (key set is a superset of the
    required single-domain fields), `error` (key set is exactly
    `{domain, error}`), or `unknown`.

- **`docs/schema.md`**
  - New "Output modes" section with a per-mode shape table, the
    `BatchErrorRecord` shape, and the deterministic classification rule.
  - New "Batch-only cross-domain fields" subsection.
  - Verbose-modes section now names the conditional fields and why they
    are absent from `required`.

- **Tests**
  - `tests/test_batch_ndjson_schema.py` (new): validates a synthetic
    batch NDJSON sample with the rule set, and asserts the schema file's
    batch `$defs` agree with the classifier.
  - `tests/test_json_schema_file.py` (extended): asserts the four batch
    `$defs` are present.

## Acceptance check

The roadmap acceptance for item 1:

> a sample batch NDJSON over the private corpus validates against the
> schema using a single deterministic rule set (success records pass
> the full shape; error records are handled by an explicit allowance),
> and a re-read of the schema prose answers "what shape can I expect
> from each output mode" without ambiguity.

- **Single deterministic rule set.** `classify_batch_record` is that
  rule set. `test_every_ndjson_record_classifies_as_success_or_error`
  confirms every record in the sample classifies as `success` or
  `error`, never `unknown`; success records pass the full shape
  (`test_success_records_carry_the_full_single_domain_shape`); error
  records are handled by the explicit `{domain, error}` allowance
  (`test_error_records_match_the_explicit_allowance`).
- **No real data.** The committed sample uses Microsoft fictional
  brands (Contoso, Northwind) generated from the existing
  `fully_populated_tenant_info` fixture, per the project's no-real-data
  discipline. The same rule set runs over a gitignored private-corpus
  NDJSON locally:

  ```bash
  recon batch validation/corpus-private/consolidated.txt --ndjson > /tmp/corpus.ndjson
  python - <<'PY'
  import json
  from recon_tool.schema_contract import classify_batch_record
  bad = []
  with open("/tmp/corpus.ndjson", encoding="utf-8") as fh:
      for n, line in enumerate(fh, 1):
          line = line.strip()
          if not line:
              continue
          if classify_batch_record(json.loads(line)) == "unknown":
              bad.append(n)
  print("unknown records:", bad or "none")
  PY
  ```

  An empty `bad` list is the pass condition: every line is either a
  success object or an error record.
- **Prose answers the per-mode question.** The "Output modes" table in
  `docs/schema.md` and the rewritten root `description` in the schema
  file both enumerate single-domain `--json`, `batch --json`,
  `batch --json --include-ecosystem`, `batch --ndjson`, and `delta`,
  each with its top-level and per-record shape.

## Scope note

This patch chose option (a) from the roadmap (document the error-record
shape and provide an explicit allowance) over option (b) (emit the full
single-domain skeleton on input-validation failures). Option (a) is
schema-additive and changes no runtime output, so no existing consumer
breaks; option (b) would change the bytes every batch consumer reads on
a failed domain. The deterministic rule set gives option-(b) consumers
the same branch-free check they wanted (key set alone) without the
behavior change.
