Repository navigation
chore(isolation): formalize JSON Schema for acq-provider-facts/v1 and acq-neutral-model-catalog/v1 #435
Description
Summary
Fast-follow from #424's review (nexus-agents 3-role consensus vote, unanimous fast-follow). Two new JSON shapes were introduced without formal JSON Schema files:
acq-provider-facts/v1— the provider self-registration facts file (/var/lib/acq/models/providers/<providerId>.json).acq-neutral-model-catalog/v1— the neutral model-catalog shape (usai-provider'sscripts/normalize-catalog.mjsoutput and its vendoredmodel-catalog-snapshot.json).
Both currently exist only as inline JSON examples in ADR 0004 (integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md) and as this implementation's literal output.
Why deferred, not skipped
Per the consensus vote: writing additionalProperties: false-strict schemas now, while ADR 0004 is still unmerged and before a second real producer/consumer exists (#425, the models orchestrator kit), risks two sources of truth diverging before either is settled. The natural forcing moment is when #425 actually reads these shapes — that's when required/additionalProperties decisions can be made against real usage instead of guessed, following the existing precedent of integrations/providers/usai/catalog.schema.json (JSON Schema draft 2020-12, validated in CI).
Scope
- Write
acq-provider-facts.schema.jsonandacq-neutral-model-catalog.schema.jsonunderintegrations/isolation/schemas/(or an equivalent location — follow this repo's existing schema-placement convention), matchingcatalog.schema.json's style ($schema,$id,additionalProperties: false,required, per-fielddescription). - Wire CI validation for both (the existing schema-validation job that already validates
catalog.schema.jsonshould generalize cleanly). - Validate the existing
usai-providerkit's facts file, normalizer output, and vendored snapshot against the new schemas — retrofit, not a design change.
Security condition carried forward from the review (binding for whoever reads these shapes, not just whoever writes the schema)
The Security Engineer vote on #424 was explicit: deferring the schema file is only acceptable because it is not the actual security control — the control is defensive parsing at every READ site. Whoever builds #425 (the first real consumer) MUST NOT trust either JSON blob unsafely: no interpolating field values into a shell command or filesystem path without allow-list validation, and a hard cap on document size/array length to avoid resource exhaustion from a malformed or oversized file. This issue's schema files should encode those constraints (e.g. pattern on providerId/keyEnv, maxItems on models[]) so the schema itself becomes part of that defense, not just a shape check.
References
- ADR 0004: docs(isolation): add ADR 0004 for neutral model-provider discovery #423
- Introduced by: feat(usai-provider): self-register provider facts + own normalizer + vendored fallback (ADR 0004 Layer 1) #424
- First real consumer (natural trigger to do this work): feat(isolation): new models orchestrator kit — discover, cache/TTL, aggregate neutral catalog (ADR 0004 Layer 2) #425
Activity
wz-gsa commentedon Sep 24, 2026
Picked this up and stopped before writing any schema, because the investigation turned up a blocker in the issue's own scope plus two findings worth acting on separately. AI-assisted (OpenCode), advisory. I wrote this issue, so this is me correcting it rather than reviewing someone else.
Blocker: step 3 can't be done on
mainThe issue says:
None of those three artifacts are on
main. In a fresh clone ata717b12:CHANGES_REQUESTED, and the facts-file mechanism was rejected there (guest startup write → host-materialized:ro), so the facts instance it currently ships is not the shape that will land.A PR on
maintoday would add two schemas with zero instances to validate. That is the thing this issue was written to prevent, so I'd rather fix the issue than satisfy it literally.That exact failure mode is already in this repo
integrations/providers/usai/catalog.schema.jsonis validated by a hand-rolled JS validator inbuild-catalog.mjs:604-684, whose own comment explains why: "ajv is NOT a repo dependency ... so we hand-roll a check." But:validateCatalogis exported at:845and imported nowhere. No test calls it.check-jsonin pre-commit is syntax-only. So that schema is enforced only when a human manually runs the script. It also re-hardcodes its constraints in JS (:616,:641,:666) and reads onlyschema.propertieskeys andschema.requiredfrom the file — so the schema and the validator can drift silently, and the schema isn't the source of truth.Whatever #435 adds has to be wired into something CI actually runs, or this repo gets a second unenforced schema.
The wiring that works, from the observed pattern
Every schema that is enforced lives in
schemas/and is validated by Pythonjsonschema(already pinned,pyproject.toml:9):skill.schema.jsonvalidate_frontmatter.py:284validate_repo.py:34→make validate→ci.yml:171network-tier-baseline-v1validate_network_tiers.py:33validate_repo.py:37→ samekit-hybrid-v1validate-kits.py:132,317ci.yml:180step + pre-commitusai/catalogThree for three: in
schemas/, validated by Python, enforced. The one that isn't inschemas/is the one that isn't enforced. So: put both new files inschemas/, add one tuple tovalidate_repo.py's validator list, and there's noci.ymlchange at all.validate-kits.py:317is the repo's onlyDraft202012Validator.check_schemacall — worth copying, it catches a typo'd schema at the gate.Two things found on the way, both separable from this issue
1. A degraded-path defect in
validate_network_tiers.py:49-62.jsonschemais imported inside atry, and onImportErrorthe fallback checks only the host regex:…then
:87prints the same✓ network-tier baseline valideither way. A missing dependency silently downgrades full schema validation to one regex with no "I could not check" state — andjsonschema==4.26.0is a hard pinned dep, so the fallback isn't even reachable in a correct install. Related:validate-kits.py:320-322returns0on "No kits found", which is the no-evidence-is-a-pass shape. The new validator should do neither. Happy to file this separately if you'd rather keep #435 clean.2. The cost unit is documented both ways in
catalog.schema.json. Five sites, four wrong:The data settles it:
catalog.json'sclaude_4_5_sonnethascost.input: 3, i.e. $3 per million (per-token would be $3/token). Provenance agrees —build-catalog.mjs:253-279copies models.dev values with a pure key rename and no arithmetic, and models.dev publishes per-million.It's documentation-only today:
grepfor* 1000000,/ 1e6,* 1e6,1_000_000across every.mjs/.pyreturns zero hits; every consumer passes the number through verbatim (prime-agent.mjs:43-51,opencode.mjs:50-57,sync-usai-models.mjs:515-520). But the schema is the only place the unit is written down and it says two different things, so the first consumer that actually computes a cost gets a coin flip. This is also why I'd put the unit in the field name for the new catalog schema (costPerMillionTokens) rather than repeat a barecost.Proposed rescope
Replace step 3, and sequence the rest:
main: write both schema files inschemas/, add the validator + a tuple invalidate_repo.py, and addscripts/tests/test_validate_model_provider_schemas.pyvalidating in-memory instances (positive + negative, matching thetest_validate_kits_*pattern). Also mirrortest_test_cases_ci_wired.pywith a 3-line assertion thatvalidate_repo.pylists the new validator — that's what stops this becoming the second unenforced schema.integrations/isolation/docs/decisions/0004-neutral-model-provider-discovery.md(per the Durable-References rule — not#435).One open question that blocks step 1's field set, and it's the reason I'm not just writing the files: the shape in ADR 0004 is
{schemaVersion, providerId, host, baseUrl, modelsUrl, keyEnv}, and I raised evidence on #436 that several of those diverge from universal practice —envis an array in all 223 live models.dev providers (14 need more than one variable, so scalarkeyEnvcan't express Azure/Bedrock/Vertex at all), everyone usesidnotproviderId, and nothing has ahostfield. WritingadditionalProperties: falsearound the current shape would freeze that in, which is precisely the "two sources of truth diverging" risk this issue was deferred to avoid.So: should the schema encode ADR 0004's shape as-is, or should the shape revision land first? I don't think I should decide that unilaterally —
ADR 0004:332says the field set is a cross-repo contract surface. Say which and I'll build it.Meanwhile the security constraints from the original issue are unaffected and I'd encode them either way:
patternon the id and env-var names,maxItemsonmodels[],format: uriplus an^https://pattern on URLs, andadditionalProperties: falsefor authoring — with the consuming side documented as ignore-unknown-fields, per the survey.docs(adr): define normalizer containment, providerId path invariant, …
docs(adr): record host-side-fetch-by-acq as a distinct, unevaluated a…
fix(usai-provider): drop guest-written provider facts; keep the neutr…
wz-gsa commentedon Oct 1, 2026
I went to write these two schemas and stopped, because the work surfaced a conflict I created and a false premise in this issue's own text. Four questions below, each a decision I do not think is mine to make.
1. This issue's stated precedent is not enforced anywhere
The scope says to follow
integrations/providers/usai/catalog.schema.json, described here as "JSON Schema draft 2020-12, validated in CI." Measured:So the repo has a schema file that is documentation, and enforcement that is a parallel hand-rolled implementation in
build-catalog.mjs:623, written that way because "ajv is NOT a repo dependency".To be fair to it: I checked for drift and there is none today. The validator derives top-level allowed keys from
schema.propertiesand required fromschema.required, so the top level cannot drift; only the nestedgateway/vendors/modelskey sets are hardcoded, and all three match the schema exactly.catalog.jsonpasses both the hand-rolled validator (0 errors) and realjsonschema(0 errors). The defect is the absent gate, not a current mismatch — I had initially mis-grepped this as duplicated key lists and want to correct that.And the hand-roll's stated justification no longer holds:
ajvis genuinely absent (so the Node-side hand-roll was reasonable), but a real schema gate needs no new dependency — it can run python-side like every other validator in this repo. I verifiedjsonschema.Draft202012Validatoragainstcatalog.json: 0 errors.2. The precedent schema does not meet the bar this issue sets
This issue's security condition asks the new schemas to encode "
patternonproviderId/keyEnv,maxItemsonmodels[]". The schema it points to as the model has:So "follow the existing precedent" and "encode these constraints" are in tension. Should
usai-model-catalog/v1be retrofitted in the same pass, or left alone?3. ADR-0004 as written would invalidate #436's shipped artifacts — and that is my fault
This is the blocking one. ADR-0004 (#423) says:
Measured against what #436 actually ships:
A schema honouring the ADR literally would fail both files #436 ships. One
schemaVersionis being asked to cover two documents with different required fields — a per-provider normalizer output, and a multi-provider aggregate. I introduced that ine322cb0while addressing your item 3, and it needs a decision before either schema can be written:acq-neutral-model-catalog/v1for a per-provider document,acq-neutral-model-aggregate/v1for the orchestrator's output, each with its own required set; or4. Half of this issue's scope is now orphaned
acq-provider-facts/v1has zero producers — #436 removed the only one, andgrepacrossintegrations/finds no remaining reference. That half should probably move to #456 (host-materialized provider facts), leaving this issue scoped to the catalog shape only. #424's scope item 1 also still specifies the removed heredoc verbatim.Prior art relevant to question 3
Docker's Sandbox Kit Spec v3 (
docker/sandbox-kit-spec) hit the same problem and solved it with a lever we could borrow: capability types carry their own version, independent of the document'sschemaVersion.network-policy@1andnetwork-policy@2coexist as published, addressable config schemas; the descriptor-wide version bump is explicitly "the last resort for what that lever cannot express." That is a cleaner answer to "two documents, different required fields" than overloading one version string.They also close precisely the gate we lack:
spec/schema_test.gopins the JSON Schema's constants to the Go validator so the two cannot drift silently, with the Go validator named as the validator of record. That is the shape of the fix for item 1 here — one implementation is authoritative, and a test asserts the other cannot diverge.(Separately, and not an argument for adopting anything: their
credential@1keeps the real secret out of the guest entirely, which bears on #456 — I have left the detail there rather than here.)What I would do, pending answers
Nothing yet. Writing either schema before questions 3 and 4 are settled means writing it twice, and picking (a) or (b) myself is the kind of call ADR-0004's own "What an agent must NOT decide unilaterally" section covers.
Once decided, the mechanical work is small and I will take it: schema files, a python-side
jsonschemagate wired into the existingmake validatepath, and a drift test pinning the hand-rolled validator's hardcoded key sets to the schema so item 1 cannot recur.AI-assisted (OpenCode), on operator instruction. Advisory; human review required.
docs(adr): split the neutral catalog into per-provider and aggregate …
wz-gsa commentedon Oct 2, 2026
Decisions on the four questions above, from my operator. Recording them here so whoever implements has the rationale, not just the outcome.
1. Two versions, not one — and ADR-0004 is amended
Split.
acq-neutral-model-catalog/v1for a per-provider document (a normalizer's output or a vendored snapshot); newacq-neutral-model-aggregate/v1for the orchestrator's multi-provider aggregate.The deciding argument was one I had not surfaced when I asked: a normalizer cannot know its own provenance. It is a pure file-to-file transform over bytes the bounded-fetch helper already retrieved, so
source: live|snapshotis knowable only to its caller — a field the producer must be told and cannot verify. "Provenance required everywhere" would also have invalidated #436's shipped normalizer output and snapshot, reopening a green PR.ADR-0004 is amended accordingly (patterns
ea10fbf, CI 7/7), with the mistake written in rather than quietly corrected.2. Provenance: aggregate-level, per-provider outcome
The aggregate records the set of providers discovered and each one's outcome —
live/snapshot/rejected. A provider rejected by host-side validation must be visible as rejected, not merely absent. Entries name their originproviderId; staleness is answered once per provider rather than repeated on every row.The per-provider document requires no provenance field.
3. Enforcement: python
jsonschema, already in the repojsonschema==4.26.0is already pinned inpyproject.tomland already used byvalidate_repo.py. Verified:Draft202012Validatorpassescatalog.jsonwith 0 errors.We considered adding
ajvand retiring the hand-roll, and measured the cost first:Both candidate homes have zero dependencies and no lockfile, so ajv would mean creating the repo's second npm dependency surface from scratch — new lockfile, new dependabot ecosystem, new
npm auditgate — plus 4 transitive deps. There is also a correctness footgun: these schemas declare$schema: .../draft/2020-12/schema, and ajv 8 defaults to draft-07 (2020-12 needs the separateAjv2020export). The python path has neither problem.The Node hand-roll stays for build-time feedback, with a drift test pinning its hardcoded nested key sets (
gwAllowed,vendorAllowed,modelAllowed) to the schema — so item 1 of my earlier comment cannot recur.4. Retrofit
usai-model-catalog/v1in the same PRVerified safe before proposing a pattern, against the real 21 ids:
So the retrofit lands with an underscore-permitting pattern plus
maxItemsonmodels[]. Had I guessed the stricter form it would have broken the shipped catalog — worth stating because "add a pattern" sounds safer than it is.5. This issue: close and reopen scoped — but not yet
Agreed that the right end state is scoped issues, since much of the text above is now stale (the false "validated in CI" premise, the orphaned
acq-provider-facts/v1half, the #424 reference to a removed heredoc).Deliberately sequenced last. Closing an issue a maintainer is engaged with, then reopening replacements, while the ADR the work depends on is still mid-review, is how a thread gets lost at the worst moment. Order:
ea10fbf).make validatepath, the retrofit, and the drift test.Writing the schemas before step 1 means writing them against a version boundary that can still move.
Still owed to a maintainer, not decided by us
The
acq-provider-facts/v1half of this issue's scope belongs with #456 now that #436 removed its only producer. I have not moved it unilaterally — that is a scope call, and it is bundled into the close/reopen in step 3 rather than done piecemeal.AI-assisted (OpenCode), on operator instruction. Advisory; human review required before any of this lands.
fix(usai-provider): drop guest-written provider facts; keep the neutr…
docs(adr): resolve the 3 open review threads on ADR 0004