Skip to content

feat(manifest): dispatch repo-manifest-validate on schema_version, and lock the vendored schemas - #145

Open
Jess Sullivan (Jesssullivan) wants to merge 2 commits into
mainfrom
feat/repo-manifest-v2-dispatch-20260826
Open

Jess Sullivan (Jesssullivan) wants to merge 2 commits into
mainfrom
feat/repo-manifest-v2-dispatch-20260826

Conversation

@Jesssullivan

Copy link
Copy Markdown
Contributor

repo-manifest-validate hardcoded the v1 schema, and that was worse than having no gate: it looked like coverage. A v2 manifest could not pass it, so v2 adopters forked around the action with hand-rolled checks and ended up with less validation than before.

What changed

  1. The action reads the manifest's own schema_version and selects v1 or v2. Unknown versions fail closed — a manifest declaring 3 must not fall through to v2's rules. The error names the remedy (vendor the schema, extend the dispatch) rather than inviting someone to widen an existing one.
  2. v2 is vendored from tinyland-inc/site.scaffold, whose $id both schemas already name as the authority.
  3. schemas/VENDORED.json records the source revision and per-file digests, and a new vendored-schema-provenance gate asserts the copies still match.

Before this, ci-templates carried a v1 copy with no lock and no gate, and it had silently diverged from its source in both directions — ci-templates gained authorities.artifact_registry, site.scaffold gained a gitops_receiver prohibition. Nothing compared them.

The provenance gate is deliberately hermetic: it compares recorded digests and does not reach site.scaffold. A network call would make every consumer's CI depend on another repository being reachable. It catches what a lock can catch offline — a hand-edited copy. Mutation-proven.

v1 is recorded as drifted, not reconciled. Adopting upstream's v1 bytes would change what every existing consumer is validated against — a separate decision with its own review. This records the divergence so it is reported instead of unnoticed.

Blast radius: zero, verified rather than assumed

repo invokes the action? schema_version
account-controller yes 1
ci-templates yes 1
ci-templates-tin-2783-immutable-release yes 1
MassageIthaca no — only a comment saying its hand-rolled gate "mirrors" this action 2
bulkload no 2
medical-massage-specialists-infra no 2

Every actual caller is v1 and unaffected.

A finding this surfaced, bigger than the hardcoding

All three v2 adopters fail the v2 schema too, for three different reasons:

  • MassageIthaca$schema is the raw.githubusercontent URL, but v2 permits only relative paths.
  • bulkload — declares v2 while missing v2's required $schema, runtime_network, and authorities.
  • medical-massage-specialists-infra — hits const values encoding a spoke shape (tofu_module_authority must be site.scaffold; boundary flags must be false) while it is an owner overlay. site.scaffold ships a separate application-owner-overlay.schema.json, which suggests dispatch may eventually need primary_role, not just schema_version.

That is a real reconciliation with three owners — not something to resolve by widening v2 until everything passes. This PR does not attempt it. Landing the dispatch is still correct and strictly additive: it takes the gate from "cannot admit v2 at all" to "admits v2 when v2 is valid".

Does not self-merge

Per the 2026-08-26 ratification, a change touching the shared schema surface ships with its consumer census attached and waits for review. Auto-merge is deliberately not armed.

Related to TIN-4094

…d lock the vendored schemas

`repo-manifest-validate` hardcoded the v1 schema, and that was worse than
having no gate: it LOOKED like coverage. A v2 manifest could not pass it --
MassageIthaca's real one fails with `additional property 'runtime_network' is
not allowed` and `/schema_version: must equal const 1` -- so v2 adopters
forked around the action with hand-rolled checks and ended up with LESS
validation than before.

WHAT CHANGED

1. The action now reads the manifest's OWN `schema_version` and selects v1 or
   v2. Unknown versions FAIL CLOSED: a manifest declaring 3 must not fall
   through to v2's rules. The error names the remedy (vendor the schema and
   extend the dispatch) rather than inviting someone to widen an existing one.

2. v2 is vendored from tinyland-inc/site.scaffold, whose `$id` both schemas
   already name as the authority.

3. `schemas/VENDORED.json` records the source revision and per-file digests,
   and a new `vendored-schema-provenance` gate asserts the copies still match.
   Before this, ci-templates carried a v1 copy with NO lock and NO gate, and it
   had silently diverged from its source in BOTH directions (ci-templates
   gained `authorities.artifact_registry`; site.scaffold gained a
   `gitops_receiver` prohibition). Nothing compared them.

The provenance gate is deliberately HERMETIC -- it compares recorded digests
and does not reach site.scaffold. A network call would make every consumer's
CI depend on another repository being reachable. It catches what a lock can
catch offline: a hand-edited copy. Mutation-proven.

v1 IS RECORDED AS `drifted`, NOT RECONCILED. Adopting upstream's v1 bytes
would change what every existing consumer is validated against, which is a
separate decision with its own review. This records the divergence so it is
reported instead of unnoticed.

BLAST RADIUS: ZERO, verified rather than assumed. Every repo that actually
invokes this action is on v1 (account-controller, ci-templates,
ci-templates-tin-2783-immutable-release) and is unaffected. None of the three
v2 adopters call it: bulkload and medical-massage-specialists-infra do not
reference it at all, and MassageIthaca's only mention is a COMMENT saying its
hand-rolled gate "mirrors" this action.

A FINDING THIS SURFACED, AND IT IS BIGGER THAN THE HARDCODING. All three v2
adopters fail the v2 schema too, for three DIFFERENT reasons:
  - MassageIthaca: `$schema` is the raw.githubusercontent URL, but v2 permits
    only relative paths.
  - bulkload: declares v2 while missing v2's required `$schema`,
    `runtime_network`, and `authorities`.
  - medical-massage-specialists-infra: hits `const` values that encode a spoke
    shape (`tofu_module_authority` must be site.scaffold, boundary flags must
    be false) while it is an owner overlay -- site.scaffold ships a separate
    `application-owner-overlay.schema.json`, which suggests dispatch may
    eventually need `primary_role` and not just `schema_version`.

That is a real reconciliation with three owners, not something to resolve by
widening v2 until everything passes. This PR does not attempt it. Landing the
dispatch is still correct and strictly additive: it takes the gate from
"cannot admit v2 at all" to "admits v2 when v2 is valid".

DOES NOT SELF-MERGE. Per the 2026-08-26 ratification, a change touching the
shared schema surface ships with its consumer census attached and waits for
review.

Related to TIN-4094
@linear-code

linear-code Bot commented Aug 27, 2026

Copy link
Copy Markdown

TIN-4094

@Jesssullivan

Copy link
Copy Markdown
Contributor Author

Correcting this PR's own claims — an adversarial Wave B review found three things that narrow it

Posting against my own PR because the body overclaims, and "remove false artifacts as you go" applies to the author first.

1. The shipping validator does not implement not — PROVEN by execution

scripts/manifest-schema-validate.py implements $ref, const, enum, type, minLength, pattern, minItems, uniqueItems, items, required, additionalProperties, allOf, if/then, and its own docstring says unknown keywords are ignored, "annotation-safe".

manifest-schema-validate.py <site.scaffold v1> darkmap/tinyland.repo.json  -> rc=0
manifest-schema-validate.py <ci-templates  v1> darkmap/tinyland.repo.json  -> rc=0

darkmap declares authorities.gitops_receiver while being a static-spoke. The authority v1 forbids exactly that via allOf[0].then.authorities.not.required. It passes anyway, against both forks.

This is the deepest instance of the pattern this program exists to fix: a gate that reads as coverage at the validator level, one layer below the schema fork this PR addresses.

2. Which means my "v1 is drifted, not reconciled" deferral was right — but for a better reason than I gave

I deferred de-forking v1 because adopting upstream's bytes changes what consumers are validated against. True, but weak. The stronger reason:

The sole material divergence between the two v1 forks IS that not — site.scaffold adds the gitops_receiver prohibition; ci-templates adds authorities.artifact_registry, which has zero users estate-wide.

So de-forking v1 today would adopt a rule the live validator ignores, and flipping VENDORED.json's "state": "drifted" to identical would advertise convergence on a no-op. That would be a gate-that-looks-like-coverage manufactured by the repair itself. The drifted row should stay until the validator implements not.

3. This PR's practical reach is one repo, not the estate

Enrollment audit: the action reaches 7 of 28 repos, and every consumer pins @v2.x — which resolves to the version that hardcodes v1. This PR ships as v3.2.x.

enrollment repos pin
direct account-controller @v2.12.2
via spoke-ci.yml site.scaffold @v3.1.0
gftb-site, greatfallstoolbus.org, software.tinyland.dev @v2.10.0@v2.12.2
via js-bazel-package.yml acuity-middleware, scheduling-kit @v2.5.1
no gate at all 21 repos

On merge the dispatch reaches only site.scaffold, which is schema_version: 1 anyway. A pin-advance wave is a prerequisite for this PR to do anything observable, not a follow-on.

4. And v2 is currently unsatisfiable for overlays

My body said this takes the gate from "cannot admit v2 at all" to "admits v2 when v2 is valid." The second half needs qualifying: under the shipping validator, no repo can be an application-owner-overlay or organization-execution-overlay.

allOf[5] (if primary_role not in [...overlays...]owns_gitops_apply: false) fires vacuously alongside allOf[11] (const application-owner-overlayowns_gitops_apply: true) — because the if guard uses not, which the validator ignores. MMSI emits 12 boundaries/*: must equal const False errors plus the tofu const for exactly this reason.

So my three-adopters-fail-v2 finding was right, but I attributed MMSI's failure to schema over-reach. It is really validator incompleteness making two branches fire at once.

What I am NOT changing here

The dispatch itself is still correct and still strictly additive, and the hermetic VENDORED.json drift gate stands. This PR does not become wrong — it becomes narrower than advertised, and the gap it exposes (the validator's missing not) is a bigger finding than the fork it was written to fix.

Recommend this stays unarmed pending review, and that the validator gap gets its own issue before anyone counts on the dispatch.

…k (TIN-4132)

The stdlib fallback implemented a subset of JSON Schema and ignored the rest
as "annotation-safe". That made it a gate-that-looks-like-coverage one layer
below the schema fork this branch already repairs:

- `not` never evaluated, so every prohibition passed unconditionally. Three
  static spokes carrying the evicted `authorities.gitops_receiver` validated
  at rc=0.
- v2 expresses role discipline negatively (`not` x17, `contains` x13,
  `anyOf` x4). Under the fallback, allOf[5]'s `if primary_role not in
  [...overlays...]` guard fired VACUOUSLY alongside allOf[11], making both
  overlay roles literally unsatisfiable -- misread as schema over-reach for
  weeks (medical-massage-specialists-infra's 12 boundary const errors were
  this, not the schema).

ITS REASON FOR EXISTING IS DEAD. The TIN-2109 cold-`nix develop` store-lock
failure belonged to the shared-store host-runner generation; current ARC pods
mount per-pod ephemeral PVC nix stores, so cross-job store-lock contention is
structurally gone (GF substrate confirmation, 2026-08-27, peer review
requested by the operator before this change). GF's words: "delete the
fallback with confidence; it guards against a dead generation while silently
passing real violations."

WHAT REPLACES IT: nothing weaker. The script imports jsonschema or exits 2
naming the dependency. The composite action, when host python lacks it, runs
the validator inside the pinned devshell (`nix develop --command`), which
substitutes cache-hot from the in-cluster Attic -- machinery this substrate
already operates; no pip surface added (GF's recommendation over a pinned pip
step, and the estate's Bazel/nix-ingestion doctrine). Only a host with
neither jsonschema nor nix refuses, visibly.

ACCEPTANCE, run against real estate bytes:
- darkmap.tinyland.dev vs the AUTHORITY v1 (site.scaffold): FAILS with
  "should not be valid under {'required': ['gitops_receiver']}" -- the exact
  violation the fallback passed at rc=0.
- darkmap vs the VENDORED v1 still passes rc=0 -- because the vendored copy
  lacks the `not` (0 occurrences vs the authority's 1). That is the recorded
  drift itself made visible, exactly what VENDORED.json's "drifted" row says;
  the de-fork remains a separate decision and is now WORTH something, which
  it was not under the old engine.
- medical-massage-specialists-infra vs v2: real errors only (the
  tofu_module_authority const ruling question), not 12 vacuous boundary
  cascades. The overlay branches are satisfiable again.
- A valid v1 manifest still passes; a jsonschema-less python exits 2 with the
  reason.

The vendored-schema-provenance gate is untouched and still green.

Related to TIN-4132
Jess Sullivan (Jesssullivan) added a commit that referenced this pull request Aug 29, 2026
…e vendored schemas (TIN-4192)

Re-scopes ci-templates #145 onto main, per TIN-4192. #146 already landed the
`schema_version` dispatch, so what #145 still uniquely carried is here and
nothing else: the engine reduction, the vendoring lock, and the same
hardcoded-v1 defect one directory over.

WHY NOT STACK ON #145

#145 branches from 4a31f34, which predates #146. Merging it would delete
`scripts/manifest-schema-validate-selftest.sh` and
`tests/fixtures/repo-manifest-v2.json` (both added by #146) and replace #146's
single-source routing with a second copy of the version -> schema mapping
written in shell inside the action -- the exact drift #146's commit message
names as the reason to keep the mapping in `SCHEMA_BY_VERSION` only.

#145's action also fails this repo's own gate. It calls
`nix develop --command python3`, and `validate-ci-templates.py
cache-backed-optin-contract` rejects that string outright (TIN-2109: the action
must not depend on `nix develop`). Measured: swapping #145's action.yml into
this tree makes that check exit 1 naming the rule. So the engine reduction is
carried across by hand, and this action does NOT shell out to nix -- providing
the interpreter is the calling workflow's job, via the `nix-setup` composite.

WHAT CHANGED

1. The stdlib fallback validator is deleted. It implemented a subset of JSON
   Schema and, for months, never evaluated `not` -- so every prohibition passed
   unconditionally, and static spokes carrying the evicted
   `authorities.gitops_receiver` validated at exit 0 against the authority v1
   schema. v2 states its role discipline negatively (`not` x17, `contains` x13,
   `anyOf` x4), so under the subset the overlay branches were unsatisfiable,
   which was misread as schema over-reach for weeks. Widening it and adding
   `assert_fallback_covers()` made it honest about its gaps; it was still a
   second engine kept in step by a hand-written harness.

   Its reason for existing is gone too: the TIN-2109 cold-`nix develop`
   store-lock failure belonged to the shared-store host-runner generation, and
   current ARC pods mount per-pod ephemeral nix stores (GF substrate
   confirmation, 2026-08-27).

2. `schemas/VENDORED.json` + `just vendored-schema-provenance-check`. Both
   vendored manifest schemas name site.scaffold as their authority via `$id`,
   and until now nothing compared the copies -- which is how v1 drifted in both
   directions unnoticed. The gate is hermetic (recorded digests, no network) and
   also fails on a vendored schema no entry records and on an emptied record.
   v1 is recorded as `drifted` and REPORTED, not failed: adopting upstream's v1
   bytes changes what every consumer is validated against and is its own change.

3. `validate-ci-templates.py manifest` stops hardcoding v1. It now imports
   `resolve_schema_name` from the validator so the mapping has exactly one copy.

ACCEPTANCE, against real estate bytes on their default branches

- darkmap.phasi.space vs the AUTHORITY v1: exit 1, "should not be valid under
  {'required': ['gitops_receiver']}" -- the violation the fallback passed at 0.
- darkmap vs the VENDORED v1: still exit 0. That is the recorded drift made
  visible, exactly what VENDORED.json's `drifted` row says.
- software.tinyland.dev: same pair of verdicts.
- MassageIthaca (schema_version 2) routes to the v2 schema and fails on one
  real error -- its `$schema` names the schema's own `$id`, which v2 does not
  admit. The gate can now admit v2; that manifest is a separate repair.
- A python without `jsonschema` exits 2 naming the dependency, on every entry
  point, for valid and invalid manifests alike.

MUTATION PROOFS

- provenance gate: one flipped byte in the v2 copy -> 1; an unrecorded
  `tinyland-repo-manifest.*.json` on disk -> 1; `files: []` -> 1; restored -> 0.
- refusal contract: reinstating a fallback that answers "valid" on ImportError
  fails 5 of 20 selftest cases and nothing else.
- C7: with `schema_version` set to 2, the previous `validate-ci-templates.py
  manifest` answers `/schema_version: 1 was expected`; this one routes to v2 and
  reports the real errors. Positive control: both answer 0 on the real v1
  manifest.

`just check` passes end to end.

Related to TIN-4192, TIN-4132
@Jesssullivan

Copy link
Copy Markdown
Contributor Author

Superseded in content by #152 (draft), which is the TIN-4192 re-scope cut onto main. Not closing this — that is the operator's call — but recording why #152 is not stacked here, since both reasons are measured rather than stylistic.

1. This branch predates #146. It is based on 4a31f34; #146 merged as d321dbd on 2026-08-27 and already landed the schema_version dispatch and the vendored v2. git diff --name-status origin/main <this branch head> reports:

D  scripts/manifest-schema-validate-selftest.sh
D  tests/fixtures/repo-manifest-v2.json

Both were added by #146. Merging as-cut would also replace #146's single-source routing with a second copy of the version→schema mapping written in shell inside the action — which #146's own commit message names as the thing to avoid: "Keeping the mapping in ONE place is the point: a second copy here in shell is how it would drift."

2. This branch's action fails this repo's own gate. action.yml here calls nix develop --command python3, and scripts/validate-ci-templates.py rejects that literal on TIN-2109 grounds. Measured by swapping this branch's action.yml into a clean main tree:

$ python3 scripts/validate-ci-templates.py cache-backed-optin-contract
.github/actions/repo-manifest-validate/action.yml: repo-manifest-validate must not depend on
  `nix develop` (fails on nix-store lock on cluster runners)
rc=1

So just check would not have passed on this branch. (Worth knowing: this repo runs no CI on its own PRs — every workflow is workflow_call-only, and gh pr checks reports "no checks reported" here and on #146 alike. That is why nothing surfaced it.)

#152 carries what this branch still uniquely had — the engine reduction, schemas/VENDORED.json + the vendored-schema-provenance gate, and the same hardcoded-v1 defect in validate-ci-templates.py manifest — on top of #146's routing, and does not shell out to nix. The commentary in 6e833c7's message is preserved almost verbatim in the new code and CHANGELOG; the reasoning was right, only its base was stale.

That nix develop guard's stated rationale ("fails on nix-store lock on cluster runners") is the same dead-generation reasoning TIN-4132 retired, so it is arguably vestigial. #152 obeys it rather than deciding — flagged for the operator on TIN-4192.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant