Repository navigation
fix: the panel's silent 125, its unscrubbed record strings, and what it cannot see (#337) - #366
Merged
Merged
Conversation
…it cannot see (#337) Items 2, 3 and 4, which are all the panel's. A SANDBOX THAT NEVER OPENED REDREW OVER ITSELF. `openSandboxSession` paused on a refusal, on a detach and on a spawn error, and not on a non-zero exit. The panel has suspended pi's TUI to hand over the terminal, so docker's own line is painted over by `tui.start()` before anyone reads it: the operator presses `b` at a run that never opens and no screen says why. It now names the code and waits. Worded around the CODE rather than docker's text, because Podman uses the same 125/126/127 convention with different wording, and 125 carries the hint its own code earns: the name may be taken, or the image is missing under `--pull=never`. EVERY RECORD STRING RUN_DETAIL PRINTS IS SCRUBBED. The `failedReason` belt covered one field in the deps layer; this pane renders `reason`, `host`, `backend`, `target`, `flow` and other runs' job ids straight through. The scrub lives INSIDE `show()`, not around the lines it builds, because `styler.link(styler.fg("accent", show(r.target)), url)` puts the value inside two layers of escapes the styler owns and scrubbing the composed string would take the hyperlink and the pane's width math with it. The class is unchanged, C0 plus DEL, shared rather than copied. This is belt-and-braces and the spec now says so rather than leaving it to be read as a boundary. `DES-ADMIN-VIA-PI-EXTENSION` argues that the injection boundary holds by PLACEMENT and not by filtering, and a filter that reads as the boundary invites the placement rule to be relaxed later. The record is PII-free and worker-written, so this is not a trust judgement about the data: it is a property of the terminal. A byte that moves the cursor, clears the screen or opens a hyperlink must not reach it from a stored field, whoever wrote it. THE PANEL SAYS WHAT IT CAN AND CANNOT SEE. RUN_DETAIL's sandbox block gains the egress posture a sandbox opened from here would get, and the sentence that it was read from this shell rather than from the deployment. That second line is the one the issue asks for: the panel resolves `PI_EGRESS` from its own process because that is what `openSandbox` uses when `b` is pressed, and it has no way to compare that against the deployment's, so it states the provenance instead of claiming a mismatch it cannot detect. A malformed `PI_EGRESS` is its own state: `egressArmed` throws and `openSandbox` refuses rather than opening a shell on the open network, so rendering that as "off" would be the one reading that is wrong in the dangerous direction. Two lines and not one, and the budget is why. `kv` pays a 12-column label out of an `inner` of 66 at the pane's DRILL_WIDTH, leaving 53 for a value that `fitLine` CLIPS rather than wraps, and the one-line form is 64 characters: it would have lost exactly the half that matters, silently. NEW `OQ-038` records the rest of the blindness, because fixing one instance should not imply the whole thing is answered. The panel also resolves the retention window it reports, the idle timeout the shell gets, which directory of retained runs it can see at all, and `DOCKER_HOST`. That last one is the sharpest and deliberately did not get a line: a Podman deployment points it at Podman's socket, so a panel reading it from its own process opens the sandbox on a DIFFERENT DAEMON than the worker's jobs ran on, and that surfaces as a missing image rather than visibly. Widening the deployment pointer is not the answer and the entry says why, citing `OQ-025`: that allowlist is paths and URLs and never capability grants, and a pointer that could set `PI_EGRESS=0` or redirect `DOCKER_HOST` is the second unreviewed door it exists to forbid. Specs: `DES-ADMIN-VIA-PI-EXTENSION` AMENDED twice, `OQ-035` AMENDED (its belt is no longer `failedReason`-only, and the reason it gave for leaving the record's fields alone is still why the wider scrub is braces rather than a boundary), `INT-SANDBOX-CONTRACT` AMENDED (one clause), NEW `OQ-038`. UNCHANGED, checked: `DES-SANDBOX-IS-A-FRESH-CONTAINER`, `INT-RUN-HISTORY-FILE-CONTRACT`, `OQ-016`, `OQ-025` (cited as the reason the panel cannot simply be told). Refs #337 Signed-off-by: Rob Boerman <robboerman@live.nl>
) Gate round one, and the headline finding is that the previous commit's central claim did not hold inside its own scope. EIGHT FIELDS BYPASSED THE SCRUB, and one of them is the first thing the pane prints. `outcome` built the header through `String(r.outcome)`; `fmtStamp` returned a raw string for a timestamp that does not parse; and the four numeric-by-contract fields (`budgetReserved`, `attempt`, `chainDepth`, `chainRefused`, plus `replicas`) are guarded for PRESENCE and never for type, so a corrupt record puts an arbitrary string on the terminal. `outcome` is not hypothetical: `exit-code.mjs` writes `unknown-exit-<n>` into exactly that field. All of them go through `show` now, and the enum comparisons read the raw value, so a scrubbed display cannot change which glyph or colour a run gets. THE PANE'S OWN TITLE WAS UNSCRUBBED. `frame()` gets `run <jobId>` and its `clipPlain` clips without stripping, so the frame around the scrubbed lines carried what they no longer did. AND THE OSC-8 URL, ON THE EXACT CALL THE RATIONALE CITES. The previous commit argued the scrub must live inside `show()` because the target is wrapped as `styler.link(styler.fg("accent", show(r.target)), url)` -- and left the SECOND argument of that call built from the raw field. `targetUrl`'s character class excluded whitespace, and JS's `\s` does not include ESC, BEL or NUL, so a github target carried them into an OSC-8 payload that a BEL terminates early. That class now excludes control bytes. That test was hard to write honestly and took three attempts, which is worth recording. Parsing the OSC-8 payloads is not enough, because a BEL inside one ends the sequence and a parser captures a clean prefix. `stripAnsi` is not enough either, because it swallows everything between an OSC opener and its BEL, which is exactly where a clear-screen hides. And the whole thing is vacuous under the default test theme, whose `link` is a byte-identical passthrough that emits no OSC-8 at all. The test now runs under a real theme, strips only the styler's own SGR, and requires that no escape and no bell survive anywhere. THE 125 MESSAGE CLAIMED WHAT NO EXIT CODE CAN CARRY. The sandbox runs `--entrypoint bash -i`, so `docker run` returns the interactive shell's status. An operator whose last command failed, or who typed `exit 1`, was told the sandbox never opened and made to read a pause for it; 126 and 127 are bash's own "not executable" and "not found", so a hint about the IMAGE fired on a healthy session. Measured against real docker. It is now 125 alone, which is the code a runtime reserves for its own pre-start refusal and the one the issue named. The hint also gains the cause an earlier draft omitted and a review measured: an unreachable DOCKER_HOST exits 125 too, and sending an operator to inspect container names when they are talking to the wrong daemon is the expensive kind of wrong hint. THE `detached` GUARD IS GONE RATHER THAN EXPLAINED. It was kept as insurance against a runtime that someday detaches with a non-zero code. That insurance cannot pay out: `openSandbox` sets `detached` only inside `if (network && !error && code === 0)`, so such a session arrives as `detached: false` and gets the message anyway. A guard that cannot do the job it is kept for is worse than none, because it reads as a handled case. TWO SMALLER ONES. A whitespace-only `PI_EGRESS_PROXY` rendered `egress on via` and then stopped, because `egressProxyName` falls back with `||` and whitespace is truthy; it now says the name is blank, which is what `b` will look for. And an operator-set proxy name longer than the budget was clipped silently by `fitLine`, which is the failure the two-line split exists to avoid, so it is shortened with a mark instead. THE BELT NOW COVERS THE LIST TOO. The issue named RUN_DETAIL, but a row that prints the same stored field raw into the same terminal is a belt the next reader will assume covers both. PROSE. `OQ-038` cited `OQ-025` for the pointer's policy, which `OQ-025` does not state: that doctrine is `INT-DEPLOYMENT-POINTER-CONTRACT`'s and `deployment-pointer.mjs`'s. Corrected in the entry, the row and two code comments. The entry also used the capability argument to close off `PI_SANDBOX_DIR`, which is an ordinary absolute path the allowlist could carry today, so the bullet now splits by key and says which half is a cheap allowlist review rather than a new interface. `OQ-035` answers the question #337 actually asked about the C1 range rather than restating the convention: on a UTF-8 terminal a JavaScript C1 code point is emitted as two bytes and is not read as CSI, so widening the class would change no terminal's behaviour and would mangle legitimate text. And the design entry's sandbox block is three lines that are not record fields, not two: the retention verdict already was not one. Refs #337 Signed-off-by: Rob Boerman <robboerman@live.nl>
…lipboard (#337) Gate round two. Three passes, and between them they found that the round-one repair was still narrower than its own sentence in four places, that one thing I added was a measured no-op, and that the narrowing I made to the exit message traded a false positive for a false negative. THE CLIPBOARD IS THE SHARP ONE, because it is the only route where this is not about a terminal at all. `y` in RUN_DETAIL hands a job id to `copyText`, which base64s it into OSC-52; the payload cannot break the sequence, but the terminal decodes it into the operator's SYSTEM CLIPBOARD, and a carriage return inside a job id then SUBMITS a line when it is pasted at a shell prompt. The bytes leave the panel there, so that is the last place that can decline to hand them over. THREE MORE ROUTES, all outside `renderRunDetail` and so outside where the first repair looked. The armed cancel question prints a job id straight into the LIST footer. The LIST row's replica badge printed `replica` and `replicas` raw, which is the same field pair the first repair fixed one pane down. And the frame TITLE goes through `clipPlain`, which clips without stripping. The title fix is in `clipPlain` itself rather than at the call site, and that is the point of it: two of the five frame titles are built from a record's job id, the first repair scrubbed RUN_DETAIL's and left LIVE_TAIL's, and a per-call-site rule is one a call site will miss. `panel.mjs`'s own `box` already titles through a stripping clip, so the two frame builders now agree rather than differing by which file a pane happens to use. THE PROJECT HAS TWO CONTROL-BYTE CLASSES AND I PICKED THE WRONG ONE. The narrow one is `triggers.mjs`'s VALIDATOR, C0 + DEL, which decides whether an operator-authored file is acceptable. The wider one is `panel.mjs`'s `CONTROL_CHARS`, C0 + DEL + C1, whose own comment calls it a defensive strip of untrusted input and which already backs `clip` -- so the plain and ascii render paths have been stripping C1 out of these same rows all along, while the themed path did not. The renderer was the outlier, not the convention. It is the wider class now. That also answers what issue #337 actually asked about C1, and answers it with code rather than with an argument about terminals. The argument was available and is not what this rests on: a JavaScript C1 code point leaves Node as two UTF-8 bytes and most terminals do not read that back as CSI, but "most" is doing real work there, and matching a class this project already shipped is better than a claim nobody here has measured. THE PROXY CAP IS GONE, AND ITS TEST WITH IT. I added it last round on the reasoning that an operator-set name is unbounded and `fitLine` truncates silently. A pass measured it across 568 renders: byte-identical to `fitLine`'s own clip at every width the pane can take, because the budget works out to exactly the column `styler.cell` was already cutting at. Worse, the hand-cap hardcoded an ellipsis while `fitLine` swaps that glyph under `PI_DISPATCH_ASCII`, so its one observable effect was pushing a non-ASCII character into the mode that exists to avoid them. The assertion that claimed to tell "shortened by us" from "clipped by the frame" could not, because they are the same bytes; it now pins the property that matters, which is that the caveat keeps its own budget whatever the name. THE EXIT MESSAGE NOW SAYS SOMETHING THE CODE CAN CARRY. Round one claimed the sandbox "never opened" for any non-zero code, which is false for an operator who typed `exit 1`, since the sandbox's entrypoint IS bash. Narrowing to 125 alone fixed that and introduced the opposite: a genuine `exec bash failed: No such file or directory` exits 127 and went silent again, which is the symptom item 4 exists to remove. No exit code separates the two, because docker reuses 125, 126 and 127 for both its own refusal and bash's last command. So the message states the code and offers the runtime reading CONDITIONALLY, which is true either way, and the redraw is stopped for all three. Telling them apart properly needs a docker read after the exit, which belongs with OQ-038's other unanswerable-without-docker question. THE PANEL NO LONGER DESCRIBES A SESSION IT CANNOT OPEN. `readSandboxInfo` stopped at the venue refusal, so a run whose manifest names no image, or whose local folder moved, was offered `b`, given two lines of egress detail about the session it would get, and refused the moment the key was pressed. Nobody reads two lines of detail about a door they are also being told is shut. It now asks the other two SYNCHRONOUS refusals `resolveSandbox` makes. The third, a proxy that is not running, needs docker and stays OQ-038's. Two wordings. A whitespace-only `PI_EGRESS_PROXY` is named rather than rendered as an empty tail, and shown untrimmed, because the sentence beneath only means something if the name shown is the name `b` will look for. And `egress off` now says `(docker's default bridge)`, because alone it reads as "no network at all" and it is the opposite. OQ-038 gains the live consequence that is its own strongest argument: `PI_LOGS_DIR` is on the pointer allowlist and `PI_SANDBOX_DIR` is not, while the panel resolves both, so a wizard-pointed panel already reads the deployment's run history beside its own sandbox directory and every retained run reports itself swept. Refs #337 Signed-off-by: Rob Boerman <robboerman@live.nl>
) Gate round three. The sharpest finding is that this PR re-introduced, one layer up, the exact duplication that `openSandbox`'s own docblock warns about, in the commit that quotes it. TWO CALLERS ASSEMBLING THE SAME ANSWER FROM PARTS. `readSandboxInfo` checked the venue refusal and then, last round, gained hand-copies of the other two `resolveSandbox` makes. That is the shape the docblock names: "how one of them drops a part", which is precisely how the panel came to offer `b` for a run with no image in the first place. There is now one exported `sandboxSyncRefusal` -- every refusal decidable from the manifest alone, in the order an operator should read them -- and both callers use it. The panel keeps its own WORDING, because `resolveSandbox` names the workspace path and this pane's rule is a retention state and never a path. A CAUSE THAT CANNOT PRODUCE THE CODE. Widening the exit message to 126 and 127 kept 125's three causes for all three: a name clash cannot produce a 127, and the cause the widening was FOR -- `exec bash failed: No such file or directory` -- was not in the list at all. Each code now carries what that code means, under the same conditional frame, so the sentence stays true whichever side produced it. Worth naming plainly: the previous round measured the 126/127 false positive and rejected it, and this round re-accepts it deliberately. What changed is that the wording no longer asserts which reading holds, so a healthy `exit 126` reads "if no shell opened, the image's entrypoint is not executable" rather than a claim about an image that is fine. A SPEC POINTER THAT DID NOT RESOLVE, for the second time in this branch. The code cited `OQ-038` for a residual `OQ-038` did not contain. Round one did the same with `OQ-025`. The fix this time is the other direction: the residual genuinely belongs in that row, so it is written there -- the panel cannot tell a runtime refusal from a shell exit without a docker read, which is the same shape as everything else that row records. THE BELT'S CLASS MOVED WITHOUT A ROW. `scrubReason` shares `scrubControl`, so last round's widening to C0 + DEL + C1 widened the `failedReason` belt too, and `OQ-035` recorded only that the cap was unchanged. Nothing observable moved -- a worker throw's message decoded as UTF-8 has no C1 code point -- but a contract that moves without a row is the gap this project's rule exists to close. Written down, and the fixture now carries a C1 byte so the class is pinned rather than implied. AND THE TWO FRAME BUILDERS AGREE ON THE CLASS, NOT THE BEHAVIOUR. `clip` deletes a match; `clipPlain` substitutes a space. That difference is deliberate -- `frame` computes its top rule from the title's length at the call site, so a deleting strip would silently change that arithmetic -- and the sentence claiming they simply agree is corrected rather than the code. `docs/sandbox.md` gains the `(docker's default bridge)` wording, which the last commit introduced and did not document. Refs #337 Signed-off-by: Rob Boerman <robboerman@live.nl>
…e panel did not write (#337) Gate round three, part two. The sharpest item is a regression this branch introduced one commit ago. MOVING THE TITLE SCRUB INTO `clipPlain` LOST THE PATH THAT NEVER CALLS IT. Last commit put the strip there so every frame title would inherit it, which is right for the framed path. But a title has TWO consumers: below `MIN_WIDTH`, or with a width that is not a finite number, the pane returns it as a bare array element and never goes near `frame()`. That degrade is a supported mode this suite already renders at width 4 and width 0. The earlier call-site scrub had covered it, and the "better" fix did not. Both consumers hold the property now, and `clipPlain` keeps its strip as the belt for any future caller. LIVE_TAIL turned out to carry the id a third time, in a plain header beside its own title. `targetUrl` WAS THE LAST NARROW CLASS IN `admin/src`. Everything else moved to C0 + DEL + C1 last commit; its character class stayed C0 + DEL, and `\s` does not cover C1 either, so a C1 byte in a github target still reached the terminal inside an OSC-8 URL, in both panes. That is the round-one hole reopened one class narrower, in the commit whose message said the drift was eliminated. TWO MORE VALUES THE PANEL DID NOT WRITE. `cancelNote` interpolates the cancel ack, which is a string ANOTHER PROCESS put in redis, and the queue-side job id, into the same footer and the same render call as the armed question scrubbed two lines above it. All three of its id-carrying branches are driven now, rather than the one that happened to be first. THE GATE'S CLAIM WAS WIDER THAN THE GATE. It said "every refusal decidable from the manifest alone", and there is one more: `decideSandboxJobUser` can refuse a linux run from `manifest.jobUser`. Folding that into the shared predicate would change what the CLI refuses and when, which is a separate decision, so the claim is narrowed to the three it asks and the exception is named where a reader will meet it. TWO TEST COMMENTS CLAIMED MORE THAN THEIR ASSERTIONS. The outcome test said its shape was "the only one that can tell the two readings apart" -- it cannot, and the source comment beside the code already said so. And an assertion guarded the absence of two earlier drafts' wording, which is history rather than behaviour; it now pins the conditional form positively. Filed rather than fixed, and the PR body says so: the HELD and FAILED panes render their own ids raw by the same mechanism (outside what this issue named, same file), two genuine pre-start refusals exit 1 and stay silent (`-t` with no TTY, a `DOCKER_CONTEXT` that does not resolve), a healthy shell whose last command was a typo buys one 2.5 s pause, and `readSandboxInfo` can never set `running` so the pane's "running" branch is unreachable. Refs #337 Signed-off-by: Rob Boerman <robboerman@live.nl>
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.
Issue #337, items 2, 3 and 4. Item 1 landed in #361. This is the last of the four, so the issue closes with it.
Item 4: a sandbox that never opened redrew over itself
openSandboxSessionpaused on a refusal, on a detach and on a spawn error, and not on a non-zero exit. The panel has suspended pi's TUI to hand over the terminal, so docker's own line is painted over bytui.start()before anyone reads it. The operator pressesbat a run that never opens and no screen ever says why.It now names the code and waits. Worded around the CODE rather than around docker's message, because Podman uses the same 125/126/127 convention with different text, and the message is already on the operator's screen because
stdiowas inherited. 125 carries the hint its own code earns: the name may be taken by a container still around, or the image is missing under--pull=never.Item 3: every record string RUN_DETAIL prints is scrubbed
The
failedReasonbelt covered one field in the deps layer. This pane rendersreason,host,backend,target,flowand other runs' job ids straight through.The scrub lives inside
show(), not around the lines it builds.styler.link(styler.fg("accent", show(r.target)), url)puts the value inside two layers of escapes the styler owns, so scrubbing the composed string would destroy the OSC-8 hyperlink and the pane's width math with it, and a test that asserted "no ESC in the output" would be both wrong and green. The class is unchanged: C0 plus DEL, C1 left alone, the same onetriggers.mjs's validator uses, now shared rather than copied.This is belt-and-braces and the spec says so, because
DES-ADMIN-VIA-PI-EXTENSIONargues that the injection boundary holds by PLACEMENT rather than by filtering, and a filter that reads as the boundary invites the placement rule to be relaxed later. The record is PII-free and worker-written, so this is not a trust judgement about the data: it is a property of the terminal. A byte that moves the cursor, clears the screen or opens a hyperlink must not reach it from a stored field, whoever wrote that field.Item 2: the panel says what it can and cannot see
RUN_DETAIL's sandbox block gains two lines:
The second is what the issue actually asks for. The panel resolves
PI_EGRESSfrom its own process because that is whatopenSandboxuses whenbis pressed, and it has no way to compare that against the deployment's: nothing here loads a.env, the panel may have been started anywhere, and the deployment pointer carries paths and never capability grants. So it states the posture and its provenance rather than claiming a mismatch it cannot detect.A malformed
PI_EGRESSis its own state.egressArmedthrows on a value it cannot parse andopenSandboxrefuses rather than opening a shell on the open network, so rendering that as "off" would be the one reading that is wrong in the dangerous direction.Two lines and not one, and the budget is why.
kvpays a 12-column label plus a space out of aninnerof 66 at the pane'sDRILL_WIDTH, leaving 53 columns for a value thatfitLineclips rather than wraps. The one-line form,retained · 19h left · egress on (this shell, not the deployment), is 64 characters: it would have lost exactly the half that matters, silently. Measured against the real renderer, not estimated.New
OQ-038, because one fixed instance is not the whole blindnessThe panel also resolves, from its own environment: the retention window it reports, the idle timeout the shell gets, which directory of retained runs it can see at all, and
DOCKER_HOST.That last one is the sharpest and deliberately did not get a line. A Podman deployment points
DOCKER_HOSTat Podman's socket, so a panel reading it from its own process opens the sandbox on a different daemon than the worker's jobs ran on, and that fails as a missing image rather than visibly. The egress posture at least fails loudly, because a missing proxy refuses.Widening the deployment pointer is explicitly not the answer, and the entry says why, citing
OQ-025: that allowlist is paths and URLs and never capability grants, and a pointer that could setPI_EGRESS=0or redirectDOCKER_HOSTwould be the second unreviewed door it exists to forbid. The resolution named is a deployment-owned answer the panel can read without a grant. The launcher being hard-wired to the docker CLI belongs to #354 and is named as out of scope rather than omitted.Review rounds
Three rounds, three reviewers each. The defect count is the headline: one defect in the original code and twenty-two in my own repairs, including two fixes I added and then withdrew when a reviewer measured them.
What the rounds found, in order of what it would have cost an operator:
yhands a job id to OSC-52. The payload cannot break the sequence, but the terminal decodes it into the operator's system clipboard, and a carriage return inside a job id submits a line when it is pasted at a shell prompt. The one route here that was never about the terminal.outcome, which the header is built from and whichexit-code.mjsgenuinely writes asunknown-exit-<n>; both timestamps throughfmtStamp's raw fallback; and five numeric-by-contract fields guarded for presence and never for type.show()because the target is wrapped asstyler.link(styler.fg("accent", show(r.target)), url)— and the second argument was built from the raw field. Fixed once, then found again one class narrower.triggers.mjs's validator (C0 + DEL) decides whether a file is acceptable;panel.mjs'sCONTROL_CHARS(C0 + DEL + C1) is the defensive strip for untrusted text heading to a terminal, and already backed the plain and ASCII paths for these same rows. The renderer was the outlier. That also answers what Sandbox loose ends: networks nothing sweeps, the panel's view of the egress setting, unscrubbed RUN_DETAIL strings, a silent 125 #337 asked about C1 with code rather than with an argument about terminals — which mattered, because the terminal argument does not hold for xterm.clipPlaincovered every frame title and lost the unframed degrade, which never calls it. A title has two consumers.exit 1since the entrypoint is bash. Narrowing to 125 alone made a genuineexec bash failed(127) silent. It now states the code and offers the runtime reading conditionally, with a cause that fits each code.readSandboxInfohand-copiedresolveSandbox's refusals; there is now one exported predicate and two callers.Two things I added were withdrawn on measurement: a proxy-name cap that was byte-identical to
fitLine's own clip across 568 renders and hardcoded a glyph ASCII mode swaps, and adetachedguard thatopenSandboxmakes unreachable by construction.Six residuals are filed in #367 rather than chased into a fourth round.
Verification
dated-fixture-check,test-count-checkandtemp-dir-checkgreen.admin/dist/rebuilt and not committed.PI_EGRESSas off; remove the scrub fromshow(); narrow the scrub from the shared C0-plus-DEL class down to ESC alone; drop the caveat line.!result.detachedcondition changes no test today. It is there so a runtime that someday detaches non-zero does not make the operator read two messages for one outcome, and the comment says exactly that.Specs
AMENDED:
DES-ADMIN-VIA-PI-EXTENSION(twice: RUN_DETAIL now carries two lines that are not record fields, and the render-time scrub is argued as braces rather than a boundary),OQ-035(its belt is no longerfailedReason-only, and the reason it gave for leaving the record's own fields alone is still why this is braces),INT-SANDBOX-CONTRACT(one clause). NEW:OQ-038.UNCHANGED, checked:
DES-SANDBOX-IS-A-FRESH-CONTAINER,INT-RUN-HISTORY-FILE-CONTRACT(no record field added, renamed or re-typed),OQ-016,OQ-025(cited).Docs:
docs/sandbox.mdandREADME.md.Refs #337