…y render (#1536)
`FountainWeb.SchemaGuard` validates every response the suite renders against
the schema its operation declares. It had never validated one of an
extension's, since the day the first extension existed, and nothing failed —
because a guard that checks nothing looks exactly like a guard that finds
nothing.
`SchemaGuard.template/1` resolves through
`Phoenix.Router.route_info(FountainWeb.Router, ...)`, and the `/api` scope ends
with `forward "/", ExtensionDispatch`. A forward is opaque to `route_info/4`:
the router reports the forward's own route, `/api`, which matches no
documented path, so `operation/2` returned `{:skip, :undocumented}` for every
extension response. The described-vs-served half of this opacity was
compensated for by `openapi_paths/0` (ADR 0043 decision 3); the
rendered-vs-declared half was not.
`template/1` now re-resolves through the mount, reading `Fountain.Extensions`
— the host's own registry, so this crosses no ADR 0043 boundary. Each
extension's `test_helper.exs` attaches the guard too, because
`scripts/test-libraries.sh` runs those suites from their own directories where
`apps/fountain`'s helper never runs; `attach/0` is idempotent now, since a
root `mix test` calls it three times and the second keeper used to die on
`:ets.new`.
## What it found on the first run
Measured, not inferred — both suites, every documented operation resolved:
* `POST /api/support/reports` renders an undeclared 401. This entry was on
`SchemaGuardAllowlist` until #1528 deleted it as stale, and it was not
stale: the staleness check reads the operations `apps/fountain` serves, and
that run installs no extension. The defect stopped being observable, and the
deletion read as a fix landing.
* `POST /api/buzz/agents` renders an undeclared 404 (`environment_not_found`),
a real disagreement nothing could have caught before.
## What it does about them
Both extensions declare the statuses rather than going on the allowlist. The
ratchet stays at 70 entries and gains no extension entry it could not
evaluate: `apps/fountain` installs no extension, so an entry naming one is
reported stale by the run that cannot see it. `SchemaGuardAllowlist`'s
moduledoc now says so, and `FountainWeb.ExtensionSchemaGuardCase` enforces it
from each extension's suite, which is the only run that can.
That is nine declarations across eight operations (401 on all eight, plus the
404), against the 66 core operations #1432 still owes. The published document
moves, so `sdk/contract/contract.json` and the TypeScript generated types are
rebuilt here — which is why this could not ride along with #1528, whose gate
was a byte-identical spec.
The fixture extension is an installed extension in the test VM like any other,
and the guard now sees it too: `:whoami` declared no 401 and `:deep` declared
nothing at all. Both declare their responses now. The fixture describes
OpenAPI paths only while the suite runs, so the published artifact is
unchanged by that half.
## Verified by reverting
* Removing the `ExtensionDispatch` clause from `template/1` fails the new
coverage test, naming all three support operations resolving to `GET /api`.
* A `required: [..., :never_rendered]` planted on `SupportReportResponse`
fails 2 tests across 3 operations; the same on `BuzzIdentityResponse` fails
8. Before this change both were `0 failures`.
`mix precommit` green: 4,206 + 114 + 33 tests, 0 failures, credo clean,
dialyzer 0 errors, prod release assembles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015unQSjdFCfcmt4XJHPHtAY
Closes #1536. The first gate of tracker #1538, and the correctness gate the tracker puts before repository graduation.
FountainWeb.SchemaGuard(#1427) validates every response the suite renders against the schema its operation declares. It had never validated one of an extension's, and nothing failed — because a guard that checks nothing looks exactly like a guard that finds nothing.Why it saw nothing
template/1resolves throughPhoenix.Router.route_info(FountainWeb.Router, ...), and the/apiscope ends withforward "/", ExtensionDispatch. A forward is opaque toroute_info/4: the router reports the forward's own route,/api, which matches no documented path, sooperation/2returned{:skip, :undocumented}for every extension response.This is the same opacity that made
openapi_paths/0necessary (ADR 0043 decision 3). The described-vs-served half was compensated for; the rendered-vs-declared half was not.template/1now re-resolves through the mount, readingFountain.Extensions— the host's own registry, so this crosses no ADR 0043 boundary andFountain.ExtensionGuardTeststays green. Each extension'stest_helper.exsattaches the guard too, becausescripts/test-libraries.shruns those suites from their own directories whereapps/fountain's helper never runs.attach/0is idempotent now: a rootmix testcalls it three times, and the second keeper used to die on:ets.newagainst a table the first already owns.What it found on the first run
Measured, not inferred. Every documented extension operation resolved, and two rendered a status nothing declared:
POST /api/support/reportsTenantAPIAuth, undeclaredPOST /api/buzz/agentsenvironment_not_found, undeclaredThe 401 was
{"POST /api/support/reports", 401}onSchemaGuardAllowlistuntil #1528 deleted it as stale — and it was not stale.SchemaGuardrailTest's staleness check reads the operationsapps/fountainserves, and that run installs no extension, so the entry named an operation the run could not see. The defect simply stopped being observable, and the deletion read as a fix landing. The allowlist's own moduledoc calls out the inverse ("a stale allowlist entry is a fix nobody noticed landing"); this was that failure mode running the other way.The 404 is a real disagreement between
AgentController.create/2and its schema, added with theenvironment_idsupport and catchable by nothing until now.What it does about them
Both extensions declare the statuses rather than going on the allowlist: 401 on all eight operations, plus the 404. That is nine declarations, against the 66 core operations #1432 still owes.
The ratchet therefore stays at 70 and gains no entry it could not evaluate. That rule is written down in
SchemaGuardAllowlist's moduledoc and enforced byFountainWeb.ExtensionSchemaGuardCase, from each extension's own suite — the only run that can see an extension's operations at all.The published document moves, so
sdk/contract/contract.jsonandsdk/typescript/src/generated/openapi.tsare rebuilt here. That is exactly why this could not ride along with #1528, whose gate was a byte-identical spec. All four SDK contract verifiers pass.The fixture extension is an installed extension in the test VM like any other, and the guard sees it too:
:whoamideclared no 401 and:deepdeclared nothing at all. Both declare their responses now. The fixture describes OpenAPI paths only while the suite runs, so the published artifact is unchanged by that half — verified withbuild.sh --check.Verified by reverting
A guard that stopped guarding looks exactly like one finding nothing, so both halves were broken on purpose:
ExtensionDispatchclause fromtemplate/1fails the new coverage test, naming all three support operations resolving toGET /api.required: [..., :never_rendered]planted onSupportReportResponsefails 2 tests across 3 operations; the same onBuzzIdentityResponsefails 8. Before this change both suites were0 failureswith the schema broken.Gate
mix precommitgreen: 4,206 + 114 + 33 tests, 0 failures; credo clean; dialyzer 0 errors; sobelow clean; prod release assembles. Formatting checked with the pinned 1.19.2.Left for the tracker
POST /api/mcp/buzz/{conversation_id}stays:undocumented—McpControllerdeclares nooperation, the same as core's own/api/mcp/team/...transports. It is a sandbox transport rather than public API, so this is the correct outcome, not a gap.🤖 Generated with Claude Code
https://claude.ai/code/session_015unQSjdFCfcmt4XJHPHtAY