docs+ui(resources): document sandbox quotas, and finish the UI they point at - #709
Merged
Conversation
The capability has been shipped since #688 and nothing documents how to use it: `adding-an-app.md` never mentioned `resources` at all, and `configuration.md` had two rows in a lookup table and no section. Predictably, none of the four built-in Apps declares any. `adding-an-app.md` gains the app.json half — the block, the per-dimension fall-through, and the three things worth knowing before touching it: exceeding `per_app.max` fails at BOOT rather than trimming silently, cpu/memory are enforced by the sandbox backend so http deployments must redeploy sandbox-host, and `disk: "0"` means unlimited while `cpu: 0` means unset. `configuration.md` gains §6.5 (the file's own `11.5` precedent, so §7–§14 keep their numbers): the three-layer resolution, the stock/flow split that explains why disk and cpu behave differently, the per-user dimensions, and the per-person override — which is superuser API only, has no UI, and answers 404 rather than 403 so that "who has an exception" doesn't leak. Documented from a running server rather than from the plan: brought one up with `per_user.count: 1`, took the 507, and drove the recovery in a browser. The error payload, the page's own wording and the whole blocked → close → retry loop are quoted from what actually happened. Two things stated plainly because a reader would otherwise be misled: - `/my-resources` is functionally complete and visually unstyled — a bare bulleted list with the item name, its specs and the Close button run together. Worth knowing before pointing a blocked user at it. - `owner` is still editable by anyone (#687 is open), and it is not the permission holder, so every quota here is currently bypassable. The plan calls this a known trade; a config guide that omitted it would be selling enforcement that isn't there. mkdocs --strict clean. Docs only — no code touched.
…a UI
Two halves of the same gap: the backend for quotas landed complete, the
frontend stopped one step short in two places.
**The page had no stylesheet.** It shipped a full vocabulary of semantic
classes — `.page`, `.summary`, `.detail`, `.empty`, `.meter`, `.meter-fill` —
and not one of them was ever defined; `.page` appears nowhere else in the repo.
That is not "a bit plain": `Meter` rendered two zero-height divs, so a person
could not see how close to the limit they were, and each row read as one run of
text ("quota demo A1 cores · 512 MB Close"). This is the page every quota
refusal routes to (#692), so it is the page someone lands on at their worst
moment. Its own file, like sheet.css / topic-hub.css, tokens only.
**Per-person allowances had no UI.** P7 delivered the model, the two-layer read
and three admin routes, and called that the "admin entry point" — but only
someone who writes curl could use it. Now a superuser gets a section at the foot
of the same page; everyone else sees nothing, matching the backend, which
answers 404 rather than 403 so that "who has an exception" cannot be probed.
The form deliberately starts EMPTY rather than pre-filling from the person's
current numbers. `PUT` replaces all four dimensions and the read endpoint
returns EFFECTIVE limits (override merged over the deploy default), so
pre-filling would submit inherited values back as explicit overrides and pin
them — a later change to the site default would then silently skip everyone who
had ever been edited here. Blank means "keep the default", which is what the
backend's own 0/"" sentinel means. There is a test for exactly that.
Also: `1 cores` → `1 core` (a separate key, since Chinese has no plural form);
Close gets the house `.btn`; the admin labels say "limit" so they no longer
collide with the section headings they sit under.
Verified in a real browser in BOTH themes — dark masks light-mode palette
defects, not the other way round — and the whole loop driven from the UI:
second item refused 507 → raise the allowance in the admin form → same call
returns 200 → clear the override. A wrapping bug found that way and fixed: the
row broke between a label and its input, so "Memory limit" ended one line and
its box started the next beside a different field.
Doc claims updated in the same commit rather than left to rot: configuration.md
no longer says the override is API-only, and the warning that this page had no
styling is gone because it no longer does.
348 files / 2872 tests green; tsc clean; mkdocs --strict clean.
Two questions the admin section could not answer, both asked the moment it was first used: **"What is the default?"** Every number on the page was the EFFECTIVE limit — override merged over the deploy default — so looking someone up told you they get 5, but not whether 5 is their exception or simply what everybody gets. The backend already had `UserLimits.default`; it was just never exposed. (The CSS shipped `.overridden` / `.inherited` classes for a distinction the API could not express — written in anticipation and left dead. Now the baseline is on screen, so the comparison is one a reader can make.) **"Who has an exception?"** There was no way to find out. The only read was by id, so an operator could confirm an exception they already suspected and nothing else — "who is above the baseline?", the question someone inheriting the system actually has, had no answer at all. `UserLimits.list_overrides()` + `GET /admin/user-resources` return the exceptions and the baseline in one payload, because a number means nothing without the other beside it. Rows carry the RAW override, not resolved limits: a dimension left at 0/"" is one that person has no exception for, and merging would make every row look overridden in every dimension — the page would say everyone is special, which is the opposite of its job.⚠️ The list filters `is_deleted == False`. `clear_for` calls `rm.delete`, which soft-deletes, and `list_resources` returns soft-deleted rows — so without the filter a REVOKED exception stays listed for ever and the page reports privileges nobody holds. This is the same trap the activity ledger documents; it bites identically here. Probe-verified: dropping the filter turns `test_a_revoked_exception_leaves_the_list` red. Each row revokes in place, so the common admin action no longer requires typing an id you had to already know. 101 quota tests / 348 files / 2875 web tests green; ruff, ty and tsc clean; checked in a real browser in both themes against a live server holding two real exceptions (one count+memory, one disk-only) — each row shows only the dimensions actually granted.
Two problems in one line of markup. **Usage was hidden whenever a dimension had no cap.** cpu and memory were only rendered `limits.cpu ? … : ""`, so on a deploy that caps only the environment count — the shape a first rollout is most likely to have — you could not find out how much cpu or memory you were holding at all. The numbers were in the payload the whole time. An unlimited dimension now shows its usage with no denominator and no bar, since there is nothing to be a fraction of. **One bar stood under three numbers, and tracked only one of them.** At "1 / 2 · CPU 1 / 4 · Memory 512 MB / 2.0 GB" the bar sat at 50% while cpu and memory were both at 25%. A reader takes a bar under a heading as "how full am I"; this one answered whichever dimension it happened to be wired to. Any of the three can refuse a turn on its own, so each gets its own row and its own bar — and so does storage, which now goes through the same component instead of repeating the markup. Checked in a browser both ways round: with cpu/memory capped (three bars at 50% / 25% / 25%, each honest) and with them uncapped (`1 (no limit)` / `512 MB (no limit)`, no bar). 348 files / 2877 tests green; tsc clean.
HYChou0515
marked this pull request as ready for review
August 12, 2026 04:25
A 101-character assertion in the override-list test. My mistake was in how I checked: I ran `ruff check` against the two source files I had edited and never against the test file, and never ran `ruff format --check` at all — CLAUDE.md asks for both, and CI runs both.
HYChou0515
added a commit
that referenced
this pull request
Aug 12, 2026
`ruff format` at the repo root does not reach `sandbox-host/` — it is a separate package with its own config, and CI lints it as its own job. Same shape as the miss on #709: I verified with a subset of what CI actually runs.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Documents the sandbox resource quotas, which have shipped since #688 and had no
usage documentation — and finishes the two frontend gaps that writing the guide
exposed.
Why
docs/adding-an-app.mdnever mentionedresources.docs/configuration.mdhadtwo rows in a lookup table and no section. Predictably, none of the four
built-in Apps declares any — the capability is complete and unused.
What
adding-an-app.md— the app.json half: the block, the per-dimensionfall-through, and three things worth knowing before touching it.
resources.per_app.maxfails at boot, not by silent trimmingcpu/memoryare enforced by the sandbox backend, so anhttpdeploymentmust redeploy sandbox-host or the declaration does nothing
disk: "0"means unlimited;cpu: 0means unset — deliberately asymmetricconfiguration.md§6.5 — numbered like the file's existing11.5so §7–§14keep their numbers. Three-layer resolution; the stock-vs-flow split that
explains why disk and cpu behave differently; the four
per_userdimensions;and the per-person override, which is superuser API only, has no UI, and
answers 404 rather than 403 so "who has an exception" doesn't leak.
Also points the three quick-reference rows at the new section and adds one for
the override.
Verified against a running server, not against the plan
Brought the app up with
per_user.count: 1, took the real 507, and drove therecovery in a browser. The error payload, the page's own wording, and the whole
loop are quoted from what happened:
Then: the two things documenting it exposed
Writing the guide from a running server surfaced two places where the frontend
had stopped one step short of the backend, so this branch finishes them rather
than documenting them as caveats.
/my-resourceshad no stylesheet. It shipped a full vocabulary of semanticclasses and not one was ever defined —
Meterwas two zero-height divs, so aperson could not see how close to the limit they were, and each row read as one
run of text. This is the page every quota refusal routes to (#692).
Per-person allowances had no UI. P7 delivered the model, the two-layer read
and three admin routes and called that the "admin entry point", but only someone
who writes curl could use it. A superuser now gets a section on the same page;
everyone else sees nothing, matching the backend's 404-not-403.
The form starts empty rather than pre-filling from current numbers:
PUTreplaces all four dimensions and the read returns EFFECTIVE limits, so
pre-filling would submit inherited values back as explicit overrides and pin
them. There is a test for that specifically.
Verified in both themes in a real browser — dark masks light-mode palette
defects, not the other way round — and the whole loop driven from the UI:
refused 507 → raise the allowance in the form → same call returns 200. A
wrapping bug was found that way and fixed.
Still true, and stated in the guide
Every quota here is currently bypassable.
owneris the debtor, it is stilleditable by anyone, and it is not the permission holder (#687 is open). The plan
calls this a known trade; a config guide that omitted it would be selling
enforcement that isn't there.
348 files / 2872 tests green; tsc clean;
mkdocs build --strictclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01LJagZZoJ8jKF7QsQAM56tC