Skip to content

portrait: make the error space real; unblock the sandbox build - #1270

Merged
aaylward merged 6 commits into
mainfrom
claude/github-issue-1245-b80vl2
Jul 31, 2026
Merged

portrait: make the error space real; unblock the sandbox build#1270
aaylward merged 6 commits into
mainfrom
claude/github-issue-1245-b80vl2

Conversation

@aaylward

@aaylward aaylward commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #1267.

Two things, separable by commit: the portrait error-space work the issue asks for, and the sandbox tooling that made it verifiable.

The issue's finding #1 was wrong, and correcting it changed the fix

I filed #1267 saying an exception escaping the handler might be a crash under smithy-cpp's -fno-exceptions posture, and that the question wanted settling before anything else got designed. Settled (comment):

MoonBase sets -fno-exceptions nowhere, and every smithy-cpp server transport — Beast, socket, and loopback — routes the handler through smithy::http::InvokeHandlerGuarded, which contains a throw as a correlated 500. ADR-0003 governs smithy-cpp's public API, not its consumers' compile flags. So this was a contract bug, not a safety one:

  • TracerService::trace returns absl::StatusOr, which says every failure is a value. It wasn't.
  • The same class of failure produced two different 500 bodies depending on how it failed — the guard's {"message":"internal error","correlationId":…} for a throw, the generated server's {"__type":"InternalFailure",…} for a returned Error::Unknown.
  • The throw is genuinely reachable, which I also hadn't established: pngpp::imageToPng throws PngException from libpng's error handler, and a 1200×1200 Image is ~34 MB before toRGB() copies it.

Because loopback goes through the same guard as Beast, all of this is testable in-process.

A sixth finding the issue missed

The generated ErrorToResponse's modeled branch ends:

return helpers::JsonError(400, error.code(), error.message(), {});   // fall-through

A modeled error whose code string matches no declared shape doesn't fail loudly — it becomes a 400 carrying error.message() verbatim. So a typo in a code string is simultaneously the wrong status and the leak finding #2 is about, from something the compiler cannot catch. That's why every modeled error here gets a typed-detail assertion rather than just a code check: recovering detail() is the only thing that distinguishes "declared" from "misspelled". Pinned as its own test so the trap is visible in the suite.

What changed

TracerService::trace returns instead of throwingbad_allockResourceExhausted, other std::exceptionkInternal, plus a catch (...). what() goes to the log, never the wire. The whole body is guarded, not just the render, and the cache write is best-effort: a failure to store must not discard an image that already rendered. do_trace, lookupCache, and storeInCache are protected virtual seams; SmithyTracerHandler takes the service by unique_ptr so a test can install a failing renderer and drive the real handler.

One new modeled error. RenderCapacityError@error("server") @httpError(503) @retryable — for the one failure a caller can act on: ask for a smaller output. 503 because the condition is a property of the server's capacity right now, not of the request.

The mapping became a contract. ToSmithyError is a free function pinned by a table over every canonical absl::StatusCode, with EveryStatusCodeHasARow keeping the table complete. It pins the mapping; it cannot notice a new source of a status, and says so.

InvalidSceneError gained an optional field naming the offending member as a JSON-pointer path, in the same /member form ValidationException already uses. Optional rather than @required because the cross-field rules — camera vs focus, aspect ratio — have no single member to blame, and they leave it absent.

Pushing back on half of finding #4

Throttling is already handled one layer out: main.cc composes aura::ProductionChain with a sliding-window limiter (20 req/60s per client) and retry_after, so overload answers 429 before the operation is reached. There is also no deadline anywhere in the render path. Modeling ThrottlingError or a timeout would advertise errors the handler cannot return — the model would be describing something untrue. Only the memory ceiling got modeled, because only it has a real client recovery.

That ordering is now pinned by TheRateLimiterAnswersBeforeTheOperationIsReached, which counts handler invocations across the refused request rather than reading its status. The audit section below explains why the status alone was not enough.

The sandbox tooling

While verifying the above I hit the reason portrait was under-tested in the first place: the proxy 403s GitHub source archives, which is how most BCR modules fetch. That blocked boost (so every Beast target), libpng (so portrait and tracy_demo), opentelemetry-cpp, and — via gazelle's cel-spec dep — bazel run //:buildifier, meaning scripts/format-all could not run and the Bazel formatting CI gate could not be checked locally at all.

scripts/make-git-overrides.sh is smithy-cpp's bazel/make-git-overrides.sh adapted here. It scans MODULE.bazel.lock for modules whose source URL is a blocked archive endpoint, clones each at its pinned tag, and replays the BCR's patches, overlay files, and registry MODULE.bazel from bcr.bazel.build — registry metadata isn't blocked. Three things MoonBase's graph needs that smithy-cpp's doesn't, each commented at the site: commit-pinned modules (clone --branch only takes refs), a strip_prefix pointing into a subdirectory, and retrying fetches. It also covers the repos that arrive via module extensions and so never appear in the lockfile scan: the bats toolchain and raylib.

scripts/bazel_restricted_egress.sh is deleted as superseded. That corrects a documented claim which had been discouraging exactly this fix — it said a BCR-overlay library "cannot be overridden from a bare clone", true as far as it goes, but the overlay is served by bcr.bazel.build, which isn't blocked.

Review panel

Ran, three lenses, findings and disposition here. It found a real defect (the cache-write failure above — none of my nine mutations could have caught it), a regression in my own tooling (an inherited EXTRA_MODULES line that downgraded rules_perl and broke @openssl loading), and twelve surviving mutations, all now killed. Everything actionable is fixed in 620c7ea.

Follow-up audit: three claims that were arguments, not tests

After the panel I re-read the diff against the working agreement's "a test beats an argument" rule, asking which claims here required reasoning and got only prose. The error-space work came out clean — every finding the issue raised, plus the sixth one it missed, has a test named after it. Three claims did not. 149d7cd closes them, and one of the three turned out to be false as written.

  1. The throttling pushback above. I cited production_chain_test passing 5/5 as evidence, but that suite predates this PR and asserts only the 429 status. A chain that rendered first and rate-limited second would satisfy every assertion in it while making the decision to omit ThrottlingError wrong. The new test counts handler invocations across the refused request. Confirmed by substituting a middleware that calls next() before refusing: the pre-existing test still passes, the new one fails — which is the gap, demonstrated.

  2. The deployment claim, corrected below. The audit is what caught it.

  3. -fno-exceptions set nowhere — the fact that makes this a contract bug rather than a safety one, and so the fact that redirected the whole fix. Checked by grep, never pinned. Now a #error on __cpp_exceptions at the point trace() relies on it. The try/catch would fail to compile under the flag regardless; the difference is a message about the contract being broken rather than one about exception syntax. No runtime test: exceptions being enabled is a build property, and a try/catch asserting it would only restate what the compiler already guarantees.

Verification

//domains/graphics/apis/portrait/... 5/5, including the Beast-dependent production_chain_test
//domains/games/apis/golf_hub/... 14/14 (the capturing recorder moved to a shared target)
//domains/platform/libs/aura/..., otel/... 2/2, 1/1
//deploy/consolidated:consolidated_test 1/1, new — the deployment invariants below
Mutation testing 9 pre-panel + 12 post-panel + 9 post-audit, all killed
bazel build --nobuild //... analyses clean, every fetch resolves, no exclusion list
scripts/format-all, //:buildifier_test, gazelle --mode=diff all clean
MODULE.bazel.lock untouched — overrides run under --lockfile_mode=off

The documented .bazelrc.user recipe was executed verbatim from an empty file rather than eyeballed, which is how a hardcoded-/root bug got caught: a bazelrc import expands neither ~ nor $HOME.

Deployment check, since a 503 on /trace is new — corrected, because the first version of this paragraph was wrong. Nothing in the deployment treats a 5xx on the trace route as evidence the container is sick, so a 503 cannot eject the backend. Caddy does no passive health checking, and portrait declares no healthcheck at all — it serves /health, but nothing probes it, neither in compose.yaml nor as a Dockerfile HEALTHCHECK. An earlier revision of this description claimed "portrait's healthcheck is a separate /health"; that was not true. The conclusion survives, the stated reason did not. Both halves are now pinned by //deploy/consolidated:consolidated_test against the directives that would falsify them — Caddy's passive-ejection set, an active probe aimed at an operation route, and a compose healthcheck on the trace route, which would restart the container over a render the client is meant to retry smaller.

Follow-up filed, out of scope: #1271 (LRUCache::get leaves a dangling iterator if push_front throws — pre-existing, reachable only under the same OOM).

claude added 3 commits July 31, 2026 12:19
…city failure

portrait.smithy declared one error — InvalidSceneError, 400 — and everything
else a render can do wrong was unmodeled, untested, or both (#1267).

TracerService::trace recorded a counter and rethrew, so its
absl::StatusOr<TraceResponse> signature lied: the render path genuinely
throws (pngpp::imageToPng raises PngException from libpng's error handler,
and a 1200x1200 Image<RGB_Double> is ~34 MB before toRGB() copies it, so
std::bad_alloc is reachable under a thread pool). That was not the crash the
issue guessed at — every smithy-cpp server transport routes the handler
through InvokeHandlerGuarded, which contains a throw as a correlated 500 —
but it did mean the same failure produced two different 500 bodies depending
on how it failed, and the handler never got to shape either.

trace() now returns instead: bad_alloc as kResourceExhausted, anything else
as kInternal, plus a catch(...) for non-std throws. what() goes to the log,
never to the wire. do_trace is a protected virtual seam so a test can make
the render fail; it shares one catch with the PNG encode and the cache
insert, so whichever of them throws takes the same path out.

RenderCapacityError (@error("server") @HttpError(503) @retryable) is the one
new modeled error, for the one failure a caller can act on — ask for a
smaller output. Overload deliberately stays unmodeled: the deployment
already answers 429 from aura's rate limiter before the operation is
reached, so a ThrottlingError on Trace would describe something the handler
cannot return.

The status -> error mapping moves to ToSmithyError, pinned by a table over
every canonical absl::StatusCode. absl::StatusCode is an open enum so the
compiler cannot make this exhaustive; the table is the substitute, and
EveryStatusCodeHasARow keeps it honest.

InvalidSceneError gains an optional `field` naming the offending member as a
JSON-pointer path, matching the "/member" form ValidationException already
uses for the trait-expressible constraints. Optional rather than required
because the cross-field rules — camera vs focus, aspect ratio — have no
single member to blame, and they leave it absent.

Tests, all of which run in the sandbox because loopback goes through the
same InvokeHandlerGuarded as Beast:
  - each throw shape converts to the right status, and a failed render is
    not cached (with a non-throwing subclass as the control)
  - the mapping table, retryability, and that unmapped statuses are not
    kModeled — the generated server's fall-through for an unrecognised
    modeled code is a 400 carrying the message, so kind() matters as much
    as the code string
  - the wire JSON for each error branch, including that an Unknown error's
    message reaches neither the body nor any header
  - a throwing handler is contained as a correlated 500, pinned so a
    smithy-cpp bump that dropped the guard fails CI instead of restarting
    production under load
  - the misspelled-modeled-code trap, so it is visible in the suite

Nine mutations across the three files were applied and all were killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA
…whole graph

The sandbox proxy allows git and GitHub release assets but 403s GitHub
source archives, which is how most BCR modules fetch. The existing
scripts/bazel_restricted_egress.sh unblocks two repos; everything else
failed one library at a time. In practice that meant no Beast transport, no
libpng (so nothing in domains/graphics), no opentelemetry-cpp, and — via
gazelle's cel-spec dep — no `bazel run //:buildifier`, so scripts/format-all
could not run at all and the Bazel formatting CI gate could not be checked
locally.

scripts/make-git-overrides.sh is smithy-cpp's bazel/make-git-overrides.sh
(their docs/development.md, "Sandboxed sessions") adapted here. It scans
MODULE.bazel.lock for modules whose source URL is a blocked archive
endpoint, clones each at its pinned tag, replays the BCR's patches, overlay
files, and registry MODULE.bazel from bcr.bazel.build — registry metadata is
not blocked — and writes one --override_module line per module.

Three things MoonBase's module graph needs that smithy-cpp's does not, each
commented at the site:

  - commit-pinned modules (envoy_api, googleapis, xds, rapidjson,
    opencensus-cpp). Their clone uses `--branch`, which only takes refs, so
    those fall back to init + fetch the object by name.
  - a strip_prefix pointing into a subdirectory rather than at the archive
    root (opencensus-proto is ".../src"). Patches, overlay files, the
    registry MODULE.bazel, and the override path all have to land there,
    and the patch_strip is counted from it.
  - retrying fetches; the proxy resets connections under load, and one drop
    part-way through a 78-module run wasted the whole run.

It also covers the two repos that arrive via module extensions rather than
the registry, so the lockfile scan cannot see them: the bats toolchain
(an empty stub, as before) and raylib (a clone plus this repo's own
bazel/3p/raylib.BUILD, since an override does not carry build_file over).

.bazelrc gains a try-import of .bazelrc.user, and .gitignore gains that
file, which is where the generated overrides.bazelrc gets imported. Nothing
depends on it existing, so CI is unaffected.

Verified: `bazel build --nobuild //...` analyses all 463 targets clean —
every fetch resolves, no exclusion list — where before it failed at libpng
and then raylib. //domains/graphics/apis/portrait/... builds and tests 5/5
including the Beast-dependent production_chain_test, aura's middleware_test
passes under plain `bazel`, and scripts/format-all runs end to end.
MODULE.bazel.lock is untouched: the overrides run under --lockfile_mode=off.

The docs that said this was impossible are corrected — CLAUDE.md,
docs/BUILD_AND_IDE.md, docs/WORKING_AGREEMENT.md, and the superseded
wrapper's own header, which claimed a BCR-overlay library "cannot be
overridden from a bare clone". True as far as it went: the overlay is not
in the clone, but it is served by bcr.bazel.build, which is not blocked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA
The setup recipe in docs/BUILD_AND_IDE.md (and the script's own usage
block) hardcoded /root/bazel-overrides, which is only right for a container
running as root. It cannot be fixed by writing ~ or $HOME into the file: a
bazelrc `import` takes a literal path and expands neither. So the heredoc
becomes unquoted, expanding $HOME as the file is written, with a note
saying why it is not quoted — otherwise the next person "fixes" it back.

Also corrected: the script grew raylib handling after that paragraph was
written, so "it also stubs the bats toolchain" was no longer the whole
story; and the advice for a module the script missed pointed only at
EXTRA_MODULES, which is right for a registry module and wrong for a repo
created by a module extension — those need --override_repository against
the canonical name Bazel prints. Dropped the "463 targets" count from the
claim about //... analysing clean, since that number rots.

Ran the corrected recipe verbatim from an empty .bazelrc.user to check it,
rather than reading it: it expands correctly and analysis passes with the
two lines documented, because the bats and raylib repo overrides now live
inside the generated overrides.bazelrc rather than needing hand-adding.

No portrait doc needed updating for the new error space — domains/graphics
README.md is a one-line index and the service has no API doc, so the
model's own doc comments are the contract and they carry it. Checked the
deployment too, since RenderCapacityError introduces a 503 on /trace:
Caddy does no passive health checking, and portrait's healthcheck is a
separate /health, so a 503 there cannot eject the backend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 31, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
1d4-web 149d7cd Commit Preview URL

Branch Preview URL
Jul 31 2026, 03:33 PM

claude added 2 commits July 31, 2026 13:35
…rides.sh

The wrapper covered two repos and left the rest of the blocked graph broken;
make-git-overrides.sh covers every blocked module, and container_structure_test
turns out to be a registry module the lockfile scan already picks up, so
nothing the wrapper did is now unreachable.

Deleting it made six comments false rather than one. Each said some variant
of "non-Beast deps only, so this runs in restricted-egress sandboxes" — a
rationale that stops holding the moment Beast builds here:

  domains/platform/libs/aura/{BUILD.bazel,smithy_contract_test.cc}
  domains/games/apis/golf_hub/{BUILD.bazel,golf_hub_wire_test.cc,
                               smithy_contract_test.cc}
  docs/WORKING_AGREEMENT.md ("the sandbox's restricted-egress stub")

The design choice those comments defend is still right — those targets
should not need anyone to have run a setup script first — so they now say
that, which is the durable reason, rather than naming a limitation that no
longer exists.

Also corrected make-git-overrides.sh's own header, which pointed at the
deleted wrapper for the bats/container_structure_test cases and was already
wrong before this commit: the script handles bats itself, and
container_structure_test needs no special case at all.

Left alone deliberately: docs/superpowers/plans/2026-07-26-room-chat-persistence.md
records "Observed: not runnable in the restricted-egress sandbox" as a dated
observation. It was true when written; a plan's record of what happened is
not a claim about today.

Verified: aura's two tests and golf_hub's smithy_contract_test and
golf_hub_wire_test all pass, and no reference to the deleted script remains
outside that plan document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA
…ose the panel's test gaps

Review panel, three lenses. It found one real defect I introduced, one
regression in the sandbox tooling, and twelve mutations my own nine missed.

## The defect (lens A)

cache_.insert sat inside the render's try, one line after the response was
fully built. A bad_alloc from the *cache write* therefore discarded an image
that had already rendered and answered 503 "try a smaller output" — telling
the client to retry smaller for work that succeeded, and sending the retry
back through the same 34 MB render. It is also the peak-allocation instant of
the call (image, png_bytes, the response's copy and the cache's copy all
live), so it is the most likely allocation in trace() to fail, not a corner.

None of my nine mutations could have caught it: do_trace was the only seam
and it fires before the response exists. Storing is now best-effort — the
cache is an optimization — behind a lookupCache/storeInCache seam pair, which
is what makes the failure reachable from a test at all.

Related, same lens: the cache-hit path sat outside the try entirely, so a
bad_alloc while copying a cached PNG escaped as the transport's generic 500
rather than the shape the handler produces, incrementing neither failure
counter. The guard now covers the whole body, which is what the header
already claimed.

## The tooling regression (lens C)

EXTRA_MODULES="rules_perl/0.5.0", copied verbatim from smithy-cpp along with
its comment, was wrong on both halves here. Our lockfile does record
rules_perl — at 1.1.0, whose BCR source is a release asset the proxy allows,
so the scan rightly skips it. And --override_module forces the *version*, so
the line downgraded the graph under @openssl (reached via curl, boost.asio,
postgres), which then failed to load with "no such attribute 'perlopt'" and
took every bazel query touching it down with it. Reproduced, removed,
verified fixed. The list is gone rather than corrected, and the comment now
says why adding one back is dangerous.

Also from lens C: raylib's tag is read out of bazel/extensions/raylib.bzl
instead of hardcoded, because --override_repository wins silently and a
bumped raylib would otherwise keep building stale sources forever; and
absl::Cord had neither its include nor its dep, compiling only because
absl/status/status.h happens to pull cord.h in and layering_check is off.

## The test gaps (lens B)

Twelve of its fifteen mutations survived. All twelve are now killed:

  - the out_of_memory metric label, and trace_requests_failed itself. These
    needed a seam: MetricsRecorder was held by value. TracerService now takes
    a shared_ptr, and golf_hub's CapturingMetricsRecorder is promoted to
    //domains/platform/libs/futility/otel:capturing_metrics_recorder rather
    than copied — "this counter fired with this label" is not one domain's
    assertion. Asserted in both directions, since one label recorded for both
    branches passes a one-sided check.
  - `<< e.what()` in the render-failure log. Two tests pinned that the cause
    never reaches the status or the wire; nothing pinned that it still
    reaches the operator, so deleting it left the suite green while erasing
    the only diagnostic. absl::ScopedMockLog now pins the positive twin.
  - five field paths in types.cc, whose test file the original change never
    touched. These are unreachable through the generated server today (the
    constraint traits intercept them first), but `types` is a public library
    and invalidField is new public API, so they are pinned where they are
    produced.
  - the "one convention" claim. PortraitConstraintTest matched substrings,
    and every member name also appears in its own message text, so the
    fieldList "path" key could be renamed or dropped with all 13 rows green.
    Now asserted exactly, plus a test making the cross-layer agreement its
    own subject: radius 0 (handler) and radius 20000 (server) must name
    /scene/spheres/0/radius identically.

Two comments were overclaiming and now say what is true: the mapping table
cannot notice a *new source* of a status, only a changed mapping; and
OutOfMemoryIsARetryable503WithTypedDetail asserts the model's @retryable
trait, which the generated client hardcodes, not the handler's flag.
EveryStatusCodeHasARow's bound comes from absl rather than a hardcoded 16.

## Simplifications the panel asked for

validateColor was dead — an unused parameter and an unconditional OkStatus
behind two four-line call sites; the knowledge it carried about Color being
unsigned char moves onto the type. validateVec3 takes const Vec3& and its
path, which deletes both const_casts and stops flattening "Vec3 contains
NaN" into "Invalid sphere center". The payload URL is passed as the
string_view SetPayload/GetPayload already take. The handler's service pointer
is const, which is what makes concurrent Trace safe.

Not done, with reasons in the model: no Retry-After on the 503 (memory
pressure has no knowable duration, and a fabricated number is worse than a
bare 503). Documented instead: the generated client maps *any* 503 to
RenderCapacityError, so callers must branch on detail<>() rather than code().

Verified: portrait 5/5, golf_hub 14/14, aura 2/2, otel 1/1;
bazel build --nobuild //... analyses clean; twelve mutations re-run and
killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA

Copy link
Copy Markdown
Collaborator Author

Review panel: 3 lenses, everything actionable fixed in 620c7ea

Correctness / control flow; tests and verification; build, docs, tooling. Each agent refuted its own findings before reporting, and I verified every survivor before acting. Two of the three found something that changes the diff.

The defect (lens A)

cache_.insert sat inside the render's try, one line after the response was fully built. A bad_alloc from the cache write therefore discarded an image that had already rendered and answered 503 "render exceeded available memory; try a smaller output" — telling the client to retry smaller for work that had succeeded, and sending the retry back through the same 34 MB render. It's also the peak-allocation instant of the call (image, png_bytes, the response's copy, and the cache's copy all live at once), so it's the most likely allocation in trace() to fail.

Verified against the source: LRUCache::insert(const key_type&, const value_type&) takes a const ref, so the std::move(png_bytes) I wrote was a no-op and the insert allocated a second full PNG.

The sharpest part of the report is why my nine mutations couldn't have caught it: do_trace was the only seam, and it fires before the response exists. Storing is now best-effort behind a lookupCache/storeInCache seam pair — the fix and the testability are the same change. Same lens also caught the cache-hit path sitting outside the guard entirely, so a bad_alloc copying a cached PNG escaped as the transport's generic 500 with neither failure counter incremented; the guard now covers the whole body, which is what the header already claimed.

A regression in my own tooling (lens C)

EXTRA_MODULES="rules_perl/0.5.0" — copied verbatim from smithy-cpp, comment and all — was wrong on both halves here. Our lockfile does record rules_perl, at 1.1.0, whose BCR source is a /releases/download/ asset the proxy allows, so the scan rightly skipped it. And --override_module forces the version, so the line downgraded the graph under @openssl (reached via curl, boost.asio, postgres), which failed to load with no such attribute 'perlopt' in 'perl_binary' rule and took every bazel query reaching it down with it. Reproduced, removed, verified fixed. The list is deleted rather than corrected, and the comment now explains why adding one back is dangerous — that recipe was in the docs too, and is rewritten.

Also from C: raylib's tag is now read out of bazel/extensions/raylib.bzl rather than hardcoded (--override_repository wins silently, so a bumped raylib would otherwise keep building stale sources with nothing reporting it), and absl::Cord had neither its include nor its :cord dep — compiling only because absl/status/status.h happens to pull cord.h in and layering_check is off repo-wide.

Twelve surviving mutations (lens B)

It ran 15; 12 survived. All twelve are killed now, re-run to confirm:

  • Metric labels. out_of_memory is the operator's only signal separating an OOM render from any other, and nothing asserted it. That needed a seam — MetricsRecorder was held by value — so TracerService takes a shared_ptr, and golf_hub's CapturingMetricsRecorder is promoted to //domains/platform/libs/futility/otel:capturing_metrics_recorder rather than copied. Asserted in both directions; a one-sided check passes if both branches record the same label.
  • << e.what(). Two tests pinned that the cause never reaches the status or the wire; nothing pinned that it still reaches the operator, so deleting it left the suite green while erasing the only diagnostic for every non-OOM render failure. absl::ScopedMockLog pins the positive twin.
  • Five field paths in types.cc, whose test file the original change never touched. B was honest that these are unreachable through the generated server today — the constraint traits intercept them, and it checked whether a JSON overflow literal was a way in (it isn't; the decoder 400s first). Pinned anyway: types is a public library and invalidField is new public API.
  • The "one convention" claim. PortraitConstraintTest matched substrings, and every member name also appears in its own message text — so the fieldList "path" key could be renamed, retyped, or dropped and all 13 rows stayed green. Now asserted exactly, plus a test making the cross-layer agreement its own subject: radius 0 (handler) and radius 20000 (server) must name /scene/spheres/0/radius identically.

Two comments were overclaiming and now say what's true: the mapping table cannot notice a new source of a status, only a changed mapping — a first-time kFailedPrecondition already has a green row reading UnknownError; and OutOfMemoryIsARetryable503WithTypedDetail's retryable() assertion pins the model's @retryable trait, which the generated client hardcodes, not the handler's flag. EveryStatusCodeHasARow's bound now comes from absl rather than a hardcoded 16.

Simplifications

validateColor was dead — unused parameter, unconditional OkStatus, two four-line call sites — and the knowledge it carried about Color being unsigned char moved onto the type. validateVec3 takes const Vec3& plus its path, deleting both const_casts and stopping the flattening of "Vec3 contains NaN" into "Invalid sphere center". The payload URL is passed as the string_view the API already takes instead of allocating a std::string per failure.

Declined, with reasons

No Retry-After on the 503: memory pressure has no knowable duration, and a fabricated number is worse than a bare 503. Recorded in the model instead, along with the consequence A flagged — the generated client maps any 503 to RenderCapacityError, so callers must branch on detail<RenderCapacityError>() rather than code() to tell the renderer running out of memory from something in front of it being down.

Panel claims I confirmed rather than acted on

AFailedRenderIsNotCached is not vacuous (B planted the exact bug it names and it was killed, along with two other cache mutations). AThrowingHandlerIsContainedAsACorrelated500 fails cleanly rather than crashing when the guard is bypassed. The TheSameHarnessSucceedsWithAWorkingRenderer control is real.

Verification

portrait 5/5 · golf_hub 14/14 · aura 2/2 · otel 1/1 · bazel build --nobuild //... clean · 12 mutations re-run and killed.

One follow-up worth its own issue, out of scope here: LRUCache::get erases the list node before push_front, so a throw from that push_front leaves a dangling iterator that a later get on the same key would erase. Pre-existing in futility/cache, untouched by this diff, and only reachable under the same OOM.


Generated by Claude Code

An audit of the diff against "a test beats an argument" found the
error-space work well covered but three load-bearing claims shipped as
paragraphs. Each is now a test that fails on the regression it describes.
One of them turned out to be false as written.

## The throttling pushback (the one that mattered)

The PR declines to model ThrottlingError on the grounds that the chain
answers 429 *before* the operation runs, so the handler has no way to
return one. The cited evidence was production_chain_test passing 5/5 —
but that suite predates this PR and asserts only the 429 *status*. A
chain that rendered first and rate-limited second would satisfy every
assertion in it while making the modeling decision wrong.

TheRateLimiterAnswersBeforeTheOperationIsReached counts handler
invocations across the refused request, so the ordering is what is
measured rather than the status. Verified by substituting a middleware
that calls next() before refusing: the pre-existing test still passes,
the new one fails. That is exactly the gap it was written to close.

A second client is driven afterward so the count assertion cannot pass
by way of a globally wedged chain.

## The deployment claim was wrong; the conclusion survives

The PR said "Caddy does no passive health checking and portrait's
healthcheck is a separate /health". The first half holds. The second
does not: portrait declares no healthcheck at all — not in compose.yaml,
not as a Dockerfile HEALTHCHECK. It serves /health and nothing probes
it. So a 503 on /trace still cannot eject the backend, but not for the
stated reason. The PR body is corrected to match.

deploy/consolidated:consolidated_test pins both halves against the
directives that would falsify them — Caddy's passive-ejection set
(fail_duration, max_fails, unhealthy_status, unhealthy_latency,
unhealthy_request_count), an active probe aimed at an operation route,
and a compose healthcheck on the trace route, which would restart the
container on a render the client is meant to retry smaller. Six
mutations, all killed, including renaming the service — a guard that
reads config by name goes vacuous when the name moves, so the block
lookup is itself asserted.

Named consolidated_test after the package directory: gazelle generates
that name, and under any other it appends a second, data-less rule that
could never find the configs.

## The -fno-exceptions assumption

trace()'s contract — every failure is a value in the StatusOr — is
implemented by catch clauses, so it holds only while throws are
catchable. That was checked by grep and never pinned. A #error on
__cpp_exceptions now states it at the point it is relied on. The
try/catch would fail to compile under the flag regardless; the
difference is a message about the contract being broken instead of one
about exception syntax. Verified by inverting the condition.

No runtime test here: exceptions being enabled is a build property, and
a try/catch asserting it would restate what the compiler already
guarantees.

Verified: portrait 5/5, aura 2/2, deploy 1/1; buildifier_test green;
gazelle --mode=diff clean; nine mutations across the two new suites, all
killed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012URbCariWNbKb58XsrQtSA
@aaylward
aaylward merged commit e503edf into main Jul 31, 2026
14 checks passed
@aaylward
aaylward deleted the claude/github-issue-1245-b80vl2 branch July 31, 2026 15:56
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.

portrait: the modeled error space is one 400 — everything else is an untested 500, and one path escapes as a C++ exception

2 participants