Commit Graph
16 Commits
Author SHA1 Message Date
timothy dafa56452c test(807): make "threaded through every branch" a checked property
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 11s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 40s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 45s
PR Gates / decisions lifecycle (pull_request) Successful in 23s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 6m0s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m49s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Review verdict / Set review-verdict status (pull_request_target) Successful in 9s
review-verdict/h10 Review-verdict: MERGEABLE @ dafa564 (base: main)
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m45s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 12s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 8s
`_resolve` returns three sets and the third has to reach every composition site.
That was a claim in a docstring; each site is a separate opportunity to forget,
and forgetting is silent — no error, just a smaller population.

Ten sites enumerated and asserted: top level, `allOf` arm, `if`, `then`, `else`,
`dependentSchemas`, `oneOf` arm, `anyOf` arm, `items`, `prefixItems`. All ten
currently thread it. Witnessed load-bearing by dropping the threading at three of
them — `allOf` reddens 1 case, `oneOf`/`anyOf` 2, `items` 2.

The value is not today's ten passes; it is that a NEW composition keyword has one
visible place to be added to, instead of the omission being spread across the
resolver where nothing would report it.

956 passed 2 skipped, ruff check 0, ruff format 0.
2026-08-23 01:36:32 +02:00
timothy 424ffb573b fix(807): remove the collision class instead of asserting it away
Round 7 reviewed the assertion added two commits ago and found it covered 1 of
the 5 nodes where the collision fires — the four it missed being the ones that
matter, since they are where the walk actually qualifies a name. I had already
widened it to walk the whole tree, but the reviewer's recommendation is better
than either version and is taken here: restructure so there is nothing to assert.

A nested inline object lives in its OWN namespace. The defect was qualifying its
members as `parent.child` BEFORE subtracting `required`, which merges the two
namespaces and lets a literal `a.b` member mask a nested `a` -> `b` one. Now
`_optional_of` subtracts inside each namespace and qualifies only the survivors,
so the sets are never mixed. No separator to defend, no assumption to assert, and
`NESTED_SEPARATOR` and its whole-tree checker are gone.

Verified at all five sites — top level of a named schema, one level down, inside
an `allOf` arm, under `items`, and inside an inline request body — every one now
surfaces (`a.b`, `outer.a.b`, `a.b`, `a.b`, `a.b`); four were masked before.

The reviewer flagged a trap in this fix and it is real: keeping a 2-tuple
compatibility shim (`return properties | nested, required`) re-creates the mask
at the merge boundary while the suite stays green. So `_resolve` returns three
sets and every caller changed. There is now a case that reddens exactly that
shim.

Residual, stated rather than hidden: a literal `a.b` and a nested `a` -> `b` that
are BOTH optional conflate into one reported string. That is a diagnostic
ambiguity, not a miss — the schema still enters the population and still forces a
disposition.

Also from round 7: `prefixItems` was the one keyword whose `required` handling no
case pinned (its only case carried none), so discarding it stayed green; it has a
case now. And the assumptions docstring is back to describing two assertions
rather than three, which the separator removal resolved on its own.

REBASED onto ad31a0285 (#825). That change edits the same guard-inventory summary
line this branch does, and the merged truth is neither side's number: 38 guards /
20 proof files / 16 with a mutation proof. Resolved by reading it off
`test_the_summary_counts_match_the_table` rather than by picking a side.

One local trap worth recording: a stale `scripts/**/__pycache__` made pytest
report this commit's new case failing while the source could not produce that
result — `_optional_of` returned the right value when called directly. Clearing
the cache resolved it. I nearly "fixed" correct code on the strength of a red.

946 passed 2 skipped on the new base, ruff check 0, ruff format 0, typecheck 0,
eslint 0, 1200/1200 web, build 0, catalog clean, doc-narrative 0 new.
2026-08-23 01:36:32 +02:00
timothy 161c788a70 fix(807): the collision check did not walk the tree it was checking
Third round of the same shape in three consecutive commits, and the cleanest
statement of it yet.

The assertion added one commit ago — no property name may contain the separator —
iterated only the top-level `properties` of each named component schema. The
resolver it protects walks the WHOLE tree. So the two disagreed exactly where it
matters: a dotted name on a nested inline object, or inside an inline request
body, was undetected, and those are precisely the places the resolver qualifies a
name and can therefore collide. A guard for a hole, with the same hole.

`_property_names_containing_the_separator` now recurses over the parsed document
the way `_schema_refs` does. Witnessed across five shapes — top-level on a named
schema, a nested inline object, an `allOf` arm, under `items`, and inside an
inline request body — all detected; the middle three were not, before.

The transferable rule, which this issue has now paid for three times in a row: a
check must walk the SAME tree as the thing it checks. Not a similar tree, not the
obvious subset — the same one. Each of these three defects was introduced by the
commit fixing the previous one, so the pattern is not carelessness about the
subject, it is that a fix written to close one shape gets tested against that
shape only.

908 passed 2 skipped, ruff check 0, ruff format 0.
2026-08-23 01:36:32 +02:00
timothy 021f3a4bb9 fix(807): the dotted path I just added could MASK a nested member
Self-attack on the previous commit's new code, in the function every round has
found defective — including, twice now, in the block that fixed the last defect.

Qualifying a nested inline object's members as `parent.child` assumes no property
name contains a dot. JSON permits one. Measured: a schema with a literal `a.b`
member alongside a nested `a` -> `b` collides, and when the literal is `required`
the nested optional member is MASKED — it vanishes from the population entirely.
Silent-miss direction again, introduced by the fix for a silent miss.

Zero dotted property names in the document today, which is exactly the reasoning
that has been wrong five times on this issue, so the assumption is asserted
rather than relied on. The separator is a named constant, the assumptions test
now carries a third clause, and the failure message says what to change instead
of only reporting that a fact moved. Witnessed: planting a schema with an `a.b`
member fires it.

908 passed 2 skipped, ruff format 0.
2026-08-23 01:36:32 +02:00
timothy 4d7fd26a3e fix(807): then/else are ALTERNATIVES — stop unioning their required
Round 6 returned BLOCKED on a polarity error I committed in the block that fixed
the previous polarity error.

`then` and `else` were in the conjunctive list with `allOf`, so their `required`
unioned. They are MUTUALLY EXCLUSIVE: a member required only under `then` is
omittable whenever `if` does not match. The guard reported it as required — the
silent-miss direction, which is the one this file exists to catch. Twelve lines
below the defect the same function warns, about `oneOf`, that getting exactly
this backwards "is not a detail".

The comment made it worse, not better: it asserted the semantics had been
considered, which is what stops the next reader checking. Its reasoning was right
for properties (union them — a client may send them) and silently inverted for
`required`.

`if`, `then`, `else` and `dependentSchemas` now share one loop with one rule:
collect properties, discard `required`. They discard for three different reasons
— a branch selector, a branch that may not be taken, a trigger key that may be
absent — but the rule is identical, so one loop beats three arguments.

Why fix a keyword the emitter has never produced: this head added `prefixItems`
and `dependentSchemas` handling on the stated grounds that "it does not appear in
today's document" has been wrong repeatedly here. That standard cannot apply to
three keywords and not the fourth — and a wrongly-handled keyword is worse than
an unhandled one, because it ships a comment claiming it was thought about.

The real lesson is narrower than "handle more keywords": for EVERY keyword whose
`required` is unioned or discarded, ship a case that puts a `required` under it.
None of the 27 cases did, which is why both this and the unpinned `if` discard
were invisible. Three such cases added.

Also closes the last blind spot with a mechanism behind it: an inline object
under `properties`. The generator recurses into it and emits `"b"?:`, so it is
genuinely droppable; the resolver returned nothing. Now reported with a DOTTED
path, so `logo.contentType` cannot be mistaken for a top-level `contentType` in a
MISSING diagnostic. `patternProperties` is correctly unhandled (no fixed members,
like `additionalProperties`) and now has a case saying so rather than being
silently absent.

The docstring now names the resolver's exact reach instead of gesturing at it,
and the `$ref` skip in the inline walk is marked as belt-and-braces rather than
reading as protective — deleting it changes no result.

Population unchanged at 9, set equality holds. 33 resolver cases, 908 passed 2
skipped, ruff 0/0, typecheck 0, eslint 0, 1200/1200 web, build 0, catalog clean.
2026-08-23 01:36:32 +02:00
timothy 4ce590662a docs(807): record what happens on a cyclic schema, measured both ways
The resolver has no cycle guard and that is deliberate, but the reasoning was
nowhere, so the next reader would either add a redundant guard or wonder.

Measured 2026-08-23:

- `_properties_and_required` raises `RecursionError` on a dict that contains
  itself by object identity. That input cannot come from `json.load` — JSON has
  no back-references — and the failure is a loud red rather than a silent wrong
  answer, so it needs no defence.
- The cycle a real document CAN express is a mutual `$ref`. `_request_reachable`
  handles it: A -> B -> A resolves to {A, B} and terminates, because of its
  `seen` set. This resolver never follows `$ref` at all, which is what makes the
  first case the only one available.

Stated where the recursion is, since "why is there no cycle guard here" is
answerable only by knowing both halves.
2026-08-23 01:36:32 +02:00
timothy aa24e57343 fix(807): the last unannotated item builder, and three more resolver blind spots
Self-attack ahead of round 6, on the two things every round has found defective:
the newest hand-written code, and the completeness of an enumeration.

CONSTRUCTION SITES, enumerated rather than sampled. 15 wrappers now take
`Complete<>`; walking each one's nested item type to its builder leaves exactly
one unannotated — `DecosScreen.breakToRequest` / `toReplaceRequest`, feeding the
`Complete<>`-annotated `replaceDeco`. Round 5 called this non-blocking because a
declared return type is already a contextual position, so the phantom direction
fires there. True, but the MISSING direction does not: a new optional member on
`DecoBreakContentRequest` would have been droppable. Both annotated.

RESOLVER, probed with constructed schemas rather than read. Three more shapes it
could not see, all silent misses:

  items in the 3.0 TUPLE form (a list)   -> nothing
  prefixItems (the 2020-12 spelling)     -> nothing
  dependentSchemas                       -> nothing

`isinstance(items, dict)` skipped the list form silently. All three are handled
now; `dependentSchemas` gets the `if` treatment — properties collected because a
client may send them, conditional `required` discarded because it binds only when
the trigger key is present.

Two shapes probed and confirmed already CORRECT, pinned so they stay that way:
`additionalProperties` contributes no members (it describes values under
arbitrary keys, not named members), and `not` cannot make a member required.
`else` was already handled; it now has a case of its own rather than riding on
`then`'s.

Seven new parametrised cases, 27 in the file. Population unchanged at 9 with set
equality — every one of these is a latent hole, not a live miss, which is the
point: "it does not appear in today's document" is the reasoning that has been
wrong five times on this issue.

902 passed 2 skipped (up from 895), ruff format 0, typecheck 0, eslint 0,
1200/1200 web, build 0.
2026-08-23 01:36:32 +02:00
timothy d194091536 fix(807): close the phantom gap at two sites, pin every resolver branch, stop
claiming coverage in prose

Round 5 returned BLOCKED. Its blocker was a durable coverage claim the tree
contradicted, and its other findings were the same class one layer down.

F1, the blocker. The record said `Complete<T>` "is applied … at each construction
site, which is what keeps the phantom direction alive." This PR annotated
`replacePathReplacements` and `replaceRemoteLibraryPreferences` at the wrapper
but left their construction sites as bare generic `.map` callbacks — the exact
shape spa-conventions §4b rule 2 says is unchecked. Injecting `phantomFieldZZZ`
into both literals typechecked CLEAN. Both sites are annotated now and both
reject the phantom with TS2353.

F2. Five mutations of the composition resolver left the whole file GREEN:
dropping `anyOf` entirely, making the alternatives loop non-recursive, taking
only the first arm's properties, ignoring top-level `required` when alternatives
are present, and dropping `items` descent. Three of those are silent-miss
direction — a droppable member the guard reports nothing about. The six existing
cases pinned the `allOf` path and the `oneOf` intersection and nothing else, so
the previous commit's "witnessed" claim covered one branch of five. All five now
redden; fourteen parametrised cases instead of six.

F3. "A new optional member anywhere in the request graph fails this test" was
false in three constructed shapes. Two are now handled — an inline body that is
an ARRAY of objects, and properties under `if`/`then` — and the third was a real
collision: `_inline_body_members` keyed on verb+path with no media type, so a
second `[Consumes]` on one action made the later body silently overwrite the
earlier. Keyed by media type now. The universal is replaced in both copies by the
actual boundary.

`if` is treated as a condition, not a constraint: its properties are collected (a
client may send them) and its `required` is discarded, because that `required`
selects a branch rather than obliging anyone to send anything.

F4/F5. The record characterised the annotated wrappers as "the ones reachable
from a droppable schema, plus the six builders" — five fall outside both. And
`oneOf` and `anyOf` were concatenated into one alternatives list, which
intersects `required` ACROSS the two keywords; they are conjunctive with each
other, so each intersects within itself and the results union. Safe-direction but
wrong, and untested.

The prose change is the one that matters. Five rounds, five blockers, every one a
hand-written claim about a population. So the record no longer asserts present
coverage at all: it states the RULE, says plainly that nothing enforces the
second half (#820), and tells the reader to read the code rather than a summary.
A set phrased in words rots exactly like a count — deleting the counts in an
earlier commit while leaving the worded set was treating the symptom.

895 passed 2 skipped (up from 887), ruff format 0, ruff check 0, typecheck 0,
eslint 0, 1200/1200 web, build 0, catalog clean.
2026-08-23 01:36:31 +02:00
timothy 3758cb9112 test(807): pin that the component walk and the inline walk COMPOSE
`_properties_and_required` does not resolve `$ref`, so an `allOf` arm that is a
`$ref` contributes nothing to the inline key. I flagged that as a suspected hole
and it is not one: the referenced schema is a named component, so
`_request_reachable` seeds on it (`_schema_refs` finds a `$ref` anywhere, arms
included) and `_optional_members` walks it, and its optional members surface
under their own key. Verified by construction — a probe schema behind a `$ref`
arm reports `['probeOptional']` under `RefArmProbeSchema` while the sibling
inline arm reports `['probeInline']`.

Pinned as a test rather than left as a comment for two reasons. Resolving refs in
this resolver as well is the obvious "fix" and would report the same member under
two keys, which is worse than either. And a reader checking only one of the two
walks would reasonably conclude the case is uncovered and go build that fix.

887 passed, 2 skipped. ruff format 0.
2026-08-23 01:36:31 +02:00
timothy 9445330cb6 fix(807): resolve schema composition recursively, and by its actual semantics
Self-attack on the inline-body walk added one commit ago, before round 5
reported. Probing it with constructed schemas rather than reading it found two
defects, and the second is the more serious:

  nested allOf inside allOf   -> reported NO properties (one-level handling)
  oneOf, x required in ONE arm -> reported x as REQUIRED

The second is backwards, not merely incomplete. `allOf` arms all apply, so their
`required` sets union. `oneOf`/`anyOf` arms are ALTERNATIVES, so a member is
genuinely required only when EVERY arm requires it — the sets intersect. Unioning
them marks a member required because one arm requires it, which hides the arm
that lets a client omit it. That is exactly the drop this guard exists to catch,
so the resolver was capable of concealing its own subject matter.

`_properties_and_required` now recurses and distinguishes conjunction from
disjunction, and `_optional_members` uses it too, so named component schemas and
inline bodies resolve composition identically instead of the named path silently
keeping the old flat behaviour.

Effect on today's document: none. Population stays 9, set equality holds. The
document exercises exactly one composition shape (a two-arm `allOf` in the
artwork upload body), which is precisely why every other branch was unexercised
prose — a resolver whose correctness rested on a case the corpus does not
contain.

So the six probe cases are now parametrised tests, pinned against constructed
schemas rather than against today's API. Witnessed: reverting the allOf recursion
reddens the nested case.

886 passed, 2 skipped (up from 880), ruff format 0, ruff check 0, catalog clean.
2026-08-23 01:36:31 +02:00
timothy 5371b664e0 fix(807): cover inline request bodies; drop three unverifiable counts
Round 4 returned BLOCKED. Its blocker is R2's failure mode repeated: a boundary
drawn by hand, stated as a universal, missing a case already present in the
document.

The guard's docstring claimed "a new optional member ANYWHERE in the request
graph" fails the test. It did not. `_optional_members` iterates
`components.schemas` and `_request_reachable` seeds from `$ref`s, so a request
body declared INLINE is invisible in both directions — and
`POST /api/v1/artwork/uploads` declares one, whose `file` and `target` sit
outside any `required` array and had no disposition. Planting a third optional
member into that body left the guard GREEN.

Rather than narrow the claim, `_inline_body_members` now walks inline bodies and
keys them `"<VERB> <path> (inline body)"` — there is no schema name to use, which
is precisely why the component walk could not see them. Composition arms are
unioned, because this body splits its properties across `allOf` and would
otherwise report none. The population goes 8 -> 9 and the uploads endpoint
acquires a TRIGGER disposition. The planted-member proof now plants into the
inline body too: deleting the inline branch reddens two tests, where before the
branch could have been deleted wholesale with everything green — the same gap
`oneOf` was in one round ago.

Live impact of the hole was nil (`artwork.ts` builds that body as hand-rolled
FormData, no generated type, create-not-replace), which is why it is worth fixing
rather than shrugging at: it was invisible, not harmless.

`createFFmpegProfile` was dispositioned COVERED while its wrapper was
unannotated, contradicting the vocabulary's own words and spa-conventions §4b
rule 1. Annotated; coverage no longer rests on the single `Draft` alias.

Three hand-written counts removed rather than corrected. "The eleven schemas the
six named builders target" is unverifiable — no natural cut reproduces eleven.
"The ten construction sites" has no derived definition of a construction site and
was already wrong once on this branch. And the record offered
`rg 'Complete<' web/src/api` as a reproduction for "most wrappers do not carry
it" — a command that counts the helper and its own test file and cannot produce
the claim. A command that does not reproduce is worse than none: it reads as
checked.

NITs: inventory row moved into alphabetical position; the manifest `why` now says
the rename fires BOTH directions, since `expect` names only the MISSING half; two
over-120-char comment lines wrapped.

Filed #822 for the `test_hook_fire_log` race surfaced while running the suite —
its oracle is the shared production hook-fire directory, so any concurrent
session reddens it with a message that blames the suite.

Verification: ruff format 0, ruff check 0, 880 passed 2 skipped, typecheck 0,
eslint 0, 1200/1200 web tests, build 0, catalog clean, doc-narrative 0 new.

refs #822

Decisions-Edit: yes
2026-08-23 01:36:31 +02:00
timothy c31c3cc556 fix(807): ruff format, and re-pin case 4 to a member that discriminates
Round 3 returned BLOCKED on a CI-red I would have shipped.

BLOCKER — `ruff format --check` reds on the new guard file. Two implicitly
concatenated strings fit in 120 chars when joined. `ruff check` was green, which
is why this survived; the format step is a separate gate and the merge gate reads
COMBINED status, so an advisory-looking red still blocks. The real defect is that
ruff was absent from my local gate for six commits while I was reporting
"verification: ..." lines that read as complete. Both ruff steps are now run, in
the CI's own `git ls-files` form under `bash -c` (zsh has no `mapfile`).

FALSE NEGATIVE, third of its kind on this branch — the guard's own comment said
"No real request type here can discriminate today, because none has an optional
member". That was false when written, and this PR's own derived guard lists eight
schemas that contradict it. It also cost the proof its most valuable case.

Case 4 is re-pinned from `ReplaceDecoTemplateRequest.name` (already REQUIRED, so
it errored with or without `Complete<T>`) to `MultiCollectionItemRequest.weight`,
which is genuinely optional and is the exact field whose loss was live before
#807. Measured: case 4 now reddens under ALL THREE weakenings including the
realistic `{ [K in keyof T]: T[K] }`, where before it reddened only under
`Partial<T>`. The table is updated to what was measured, and the file now says
that the set of schemas with optional members is DERIVED and must not be restated
in prose — three hand-written versions of that list have now been wrong.

STALE AFTER THE VERB WIDENING — the MISSING diagnostic still told the reader
"reachable from a POST/PUT/PATCH body" one commit after the walk started seeding
from every verb, so it could name a DELETE-reached schema while misdescribing
where it came from; `expect` matched the tail of the message, so no test caught
it. Same sentence fixed in the decision record.

`oneOf` COVERAGE WAS CLAIMED, NOT HELD — `_schema_refs` has a dict branch and a
list branch, and only the dict one was exercised: deleting list descent (making
all 23 `oneOf` refs invisible) left the file GREEN. The planted fixture now goes
in through a `oneOf` list, and deleting that branch reddens it.

Also: deterministic host selection in the mutation test (set iteration over
strings varies per process, so the proof's depth varied run to run); the
`#/components/` bucket regex no longer assumes alphabetic bucket names; the
hand-maintained "thirteen of thirty-three wrappers" count is replaced by its
reproduction command; the record now reconciles its own pin-vs-floor argument
against this guard's floors; 141 -> 142; generator path corrected to
`web/scripts/`.

Verification: ruff format 0, ruff check 0, 880 passed 2 skipped, typecheck 0,
eslint 0, 1200/1200 web tests, build 0, catalog clean, doc-narrative 0 new (9
pre-existing warnings, none in files this branch touches).

Decisions-Edit: yes
2026-08-23 01:36:31 +02:00
timothy f878831e8f test(807): assert the two document properties the walk silently depends on
`testing.guard-derives-population-from-source`: when a guard's scope mirrors an
authoritative source, the mirror needs its own check or it goes stale silently.
`_request_reachable` mirrors two properties of the OpenAPI document, both true
today and guaranteed by nothing:

1. Request bodies are declared inline on the operation. If ASP.NET emits a
   `components.requestBodies` bucket and operations `$ref` into it, the seed walk
   finds no `#/components/schemas/` name at all and every schema behind that
   shared body drops out of the population — silently, with the guard still green.
2. Every `$ref` points into `#/components/schemas/`. `_schema_refs` matches that
   prefix, so a ref into any other bucket is invisible to it.

Both are now asserted, with a failure message that says what to fix rather than
just that a fact changed. Neither was asserted anywhere else, so a change in the
emitter would have shrunk this guard's reach without failing anything — the
"complete within a scope that has silently gone stale" case.

880 passed, 2 skipped.
2026-08-23 01:36:31 +02:00
timothy eff4bdfa22 test(807): state what the anti-vacuity floors do NOT catch
Probed the new guard against a partially-broken derivation rather than only the
total-failure case the floors were written for: deleting the transitive step from
`_request_reachable`, so only schemas named directly on a request body resolve.

Both floors stay satisfied and `test_the_derivation_reached_a_real_population`
stays GREEN. What reddens is the planted-member test — its plant is reached
through a nested `$ref` exactly so that it can — and the disposition test, since
the nested rows vanish and report as PHANTOM.

So the floors are the crude backstop against a parse that reached nothing at all,
and the planted-member test is what actually holds the walk honest. Recorded in
the docstring, because a floor described as anti-vacuity reads as coverage it does
not have, and the next reader deciding which of these to keep working should know
which one is load-bearing.
2026-08-23 01:36:31 +02:00
timothy 35e5d42ad2 fix(807): drop the verb allow-list from the derivation
Self-review of the new guard, before round 3 reported: `_request_reachable`
seeded its walk from POST/PUT/PATCH request bodies only. That reads as obviously
right and already had a counterexample in the document — `DELETE
/api/v1/media-items` carries a request body.

A DELETE body probably cannot cause a full-replace drop. "Probably" is what
produced both of this issue's earlier misses, and a verb allow-list is another
hand-drawn edge on a population that has now been drawn wrongly twice. Every
operation carrying a request body now seeds the walk, so anything it surfaces
must acquire a stated disposition rather than being argued away in advance.

Effect measured: reachable schemas 141 -> 142, droppable set unchanged at 8, set
equality against the registry still holds. So this buys no new rows today; it
removes a place to be wrong later.

Also states in the docstring why the walk is transitive and why `_schema_refs`
recurses over every dict value and list element rather than naming composition
keywords — `oneOf` appears 23 times in the document today, and naming keywords
is the same frozen-filter mistake one level down.

879 passed, 2 skipped.
2026-08-23 01:36:31 +02:00
timothy 685926a0d0 fix(807): derive the coverage boundary instead of writing it down a third time
Round 2 returned BLOCKED. The enumeration added in the previous commit was
itself incomplete: it omitted `ArtworkContentTypeModel`, which carries three
members outside its `required` array and is reachable from the full-replace
`PUT /channels/{id}` via `UpdateChannelRequest.logo`. It was sorted out on its
name — `...Model` reads as a response model — which is the same failure the
enumeration was written to eliminate one level up, and it closed with a false
universal negative ("the remaining schemas ... are never request bodies") sixty
lines below this document's own warning against exactly that.

Two misses from one mechanism, so the mechanism goes rather than the list
getting a third patch.

`scripts/tests/test_optional_request_members.py` derives the population every
run from `ErsatzTV/wwwroot/openapi/v1.json`: every schema with a property
outside `required` that is TRANSITIVELY reachable from a POST/PUT/PATCH request
body, asserted set-equal in both directions against a registry of per-schema
dispositions. Transitivity is load-bearing — `MultiCollectionItemRequest` and
`ArtworkContentTypeModel` are both nested, so a top-level-only check would have
reproduced both misses. Population derived, dispositions hand-written as the
reviewed SCOPE, per testing.guard-derives-population-from-source.

Witnessed red: removing the `ArtworkContentTypeModel` disposition reports it as
MISSING with its three members. That mutation is DECLARED in
`mutation_manifest.py` and re-applied every suite, so the row grades MUTATION
rather than resting on this message. Renaming the key rather than deleting the
entry keeps the module importable, so the red is the MISSING direction and not
an ImportError reddening for the wrong reason.

ArtworkContentTypeModel's real disposition is COMPUTED, not COVERED: the three
members are get-only properties derived from `Path` and are never deserialized,
so omitting them drops nothing — and `Complete<T>` must NOT be applied there,
because it would force a caller to fabricate server-computed values in an
outbound request. That caveat is now in spa-conventions §4b, where a reader
following "annotate each construction site" would otherwise have produced a
wrong payload.

Also reconciles the contradiction round 2 found between the wrapper-boundary
rationale and the snapshot: 13 of ~33 full-replace PUT wrappers carry
`Complete<>` today. The rest are stated as still contingent — the difference
from before is that the contingency is now MONITORED by the derived test rather
than assumed.

L5 the phantom denominator is ten sites, not nine (five accepted, three of those
without a spread or a local). N6 fixture field placed alphabetically. N7 the
record's three-act framing trimmed to the current statement plus the rejected
one, so a cold reader does not have to work out which is live.

Verification: 879 passed 2 skipped in scripts/tests (up from 874), typecheck 0,
eslint 0, 1200/1200 web tests, web build 0, catalog check clean, doc-narrative 0.

Decisions-Edit: yes
2026-08-23 01:36:31 +02:00