portrait: make the error space real; unblock the sandbox build - #1270
Conversation
…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
Deploying with
|
| 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 |
…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
Review panel: 3 lenses, everything actionable fixed in
|
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
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-exceptionsposture, and that the question wanted settling before anything else got designed. Settled (comment):MoonBase sets
-fno-exceptionsnowhere, and every smithy-cpp server transport — Beast, socket, and loopback — routes the handler throughsmithy::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::tracereturnsabsl::StatusOr, which says every failure is a value. It wasn't.{"message":"internal error","correlationId":…}for a throw, the generated server's{"__type":"InternalFailure",…}for a returnedError::Unknown.pngpp::imageToPngthrowsPngExceptionfrom libpng's error handler, and a 1200×1200Imageis ~34 MB beforetoRGB()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: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: recoveringdetail()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::tracereturns instead of throwing —bad_alloc→kResourceExhausted, otherstd::exception→kInternal, plus acatch (...).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, andstoreInCacheare protected virtual seams;SmithyTracerHandlertakes the service byunique_ptrso 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 smalleroutput. 503 because the condition is a property of the server's capacity right now, not of the request.The mapping became a contract.
ToSmithyErroris a free function pinned by a table over every canonicalabsl::StatusCode, withEveryStatusCodeHasARowkeeping the table complete. It pins the mapping; it cannot notice a new source of a status, and says so.InvalidSceneErrorgained an optionalfieldnaming the offending member as a JSON-pointer path, in the same/memberformValidationExceptionalready uses. Optional rather than@requiredbecause 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.cccomposesaura::ProductionChainwith a sliding-window limiter (20 req/60s per client) andretry_after, so overload answers 429 before the operation is reached. There is also no deadline anywhere in the render path. ModelingThrottlingErroror 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, meaningscripts/format-allcould not run and the Bazel formatting CI gate could not be checked locally at all.scripts/make-git-overrides.shis smithy-cpp'sbazel/make-git-overrides.shadapted here. It scansMODULE.bazel.lockfor modules whose source URL is a blocked archive endpoint, clones each at its pinned tag, and replays the BCR's patches, overlay files, and registryMODULE.bazelfrombcr.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 --branchonly takes refs), astrip_prefixpointing 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.shis 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 bybcr.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_MODULESline that downgraded rules_perl and broke@opensslloading), and twelve surviving mutations, all now killed. Everything actionable is fixed in620c7ea.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.
149d7cdcloses them, and one of the three turned out to be false as written.The throttling pushback above. I cited
production_chain_testpassing 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 omitThrottlingErrorwrong. The new test counts handler invocations across the refused request. Confirmed by substituting a middleware that callsnext()before refusing: the pre-existing test still passes, the new one fails — which is the gap, demonstrated.The deployment claim, corrected below. The audit is what caught it.
-fno-exceptionsset 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#erroron__cpp_exceptionsat the pointtrace()relies on it. Thetry/catchwould 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 atry/catchasserting it would only restate what the compiler already guarantees.Verification
//domains/graphics/apis/portrait/...production_chain_test//domains/games/apis/golf_hub/...//domains/platform/libs/aura/...,otel/...//deploy/consolidated:consolidated_testbazel build --nobuild //...scripts/format-all,//:buildifier_test,gazelle --mode=diffMODULE.bazel.lock--lockfile_mode=offThe documented
.bazelrc.userrecipe was executed verbatim from an empty file rather than eyeballed, which is how a hardcoded-/rootbug got caught: a bazelrcimportexpands neither~nor$HOME.Deployment check, since a 503 on
/traceis 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 incompose.yamlnor as a DockerfileHEALTHCHECK. 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_testagainst 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::getleaves a dangling iterator ifpush_frontthrows — pre-existing, reachable only under the same OOM).