A local web desk for a Judgment Pack project. jpack-desk is one Go binary: it
serves a single-page application and relays JSON-RPC between that page and a
jpack mcp subprocess running in your project directory.
The browser is the MCP client. The Go program is a chassis rather than a feature server: what it carries, it does not read. JSON-RPC bytes cross the relay untouched, so the desk can show anything the runtime's tools expose without a matching change on the Go side.
It does have the few routes the browser cannot do for itself — the file API, because the runtime has no write tools; the assistant's key, because a credential must never be pasted into a project; and the model relay, a per-feature route that carries the assistant's model traffic because the key must never reach the page. None of them parses what it carries either: the relay adds one header to a request and reads no request body and no model name. It reads one kind of answer, and only to refuse it — a model listing, which is the one relayed answer the desk renders, so that an endpoint cannot hand the machine-held credential to the browser as a model id. See Where the assistant key lives.
A project home. / is what the project declares — its packs, and the two
rehearsals that can be run over them. The matrix and graph entries appear only
where the project has one: list_packs reports a matrix flag per pack, and the
configured graphs come from experimental_list_graphs where the runtime serves
it, or from running their matrices where it does not.
A shell around all of it. A header, a left rail, an Inspector, a Console and
a status strip — described under Shell. The rail's first entry creates
a pack, and asks three things: a name, a description, and a
template. The name gives the id, the id gives the file name, and where that
file goes is storage.packs in jpack-desk.json — configuration, not a
question for whoever is creating a pack. The templates are the runtime's own
examples plus an empty pack derived from the runtime's own schema; the desk
ships none of its own, because a desk-authored skeleton would be the desk
asserting what a pack is.
The id is derived live and shown under the field: diacritics folded, every run
outside a–z0–9 becoming one -. A name that cannot become the runtime's
decisionId is refused rather than repaired, and the refusal states the
alphabet — a name written in another script is made of letters, and telling its
author otherwise would be false. A Latin letter that neither decomposes nor has
a standard transliteration is named in the refusal rather than deleted:
Łódź is lodz and Straße is strasse, but Azərbaycan is refused saying
which letter it could not carry, because silently dropping one produces an id
nobody would recognise as their own name.
The empty pack is offered only once get_schema has actually answered with a
skeleton that carries a specVersion — not on the strength of the tool being
advertised, which is a claim about a tool rather than about a template, and a
file with no specVersion is not an incomplete pack but one nothing can read
as a pack at all. While either listing is still being asked the field says so
and selects nothing; a listing the runtime refused says that instead, in the
runtime's own words. Nothing is said about what checks will make of the result:
that is the runtime's verdict to report, on the page this opens.
Or describe it. Beside the template choice is a disclosure — Describe it
instead — where you type what the pack should decide in your own words and
press Propose. The desk runs the runtime's own author_pack prompt through
the assistant, and the dialog shows what it did — every tool call, every
guardrail, every failure — and then the proposal: the document summarised, the
unknowns it declared, the validate report and the rehearsal evaluation quoted
as the runtime wrote them, and the whole document behind Show document.
Create then writes that document exactly as it writes a template.
What it never does. No file exists until Create is pressed — the section proposes and nothing else, which is ADR-0001's rule that the proposal is the only sink. The name field wins: the id and the title come from what was typed, whatever the proposal called itself, and the dialog says so in one line where the two differ. The document that is written is the desk's after shaping — applied to the frozen snapshot that was on screen, so what was shown is what was written.
A proposal belongs to the submission that produced it. Pressing Propose
again withdraws the previous one at the press, and so does an error after a
proposal: an engine that proposes a document and then fails has said the work
does not stand. So a second Propose whose prompt is refused, or which is
stopped while the prompt is still being read, leaves nothing on offer — and
Create is held rather than quietly falling back to a template nobody chose.
Whatever the section is holding it for is in the button's own title: a run in
flight, a refused prompt, a run that ended without a document, a proposal that
could not be read as JSON data.
The runtime says whether a proposal is a pack, before either write. The
desk fills the four members the dialog asked about — the name, the id, the
version and the description — and leaves everything else exactly as the model
wrote it, specVersion included. Nothing is stripped and nothing is repaired
in silence: what the assistant proposed is what the runtime is asked about. The
exact bytes that would be written go to validate, Create is offered only
where the answer is valid for those bytes, and a refusal shows the check
strip's own sentence and every diagnostic the runtime returned, in the same
rendering the Checks panel uses — code, codeStability, layer, severity,
the message and the pointer, each as the runtime gave it and none of them
reworded. Where the answer sets diagnosticsTruncated, the runtime stopped at
its own limit and the desk says so in a line of its own naming that limit; the
sentence is the desk's, and the number in it is the desk's copy of the
runtime's cap rather than a figure the answer carries. A proposal is refused
outright where the connection serves no validate to ask.
Losing the assistant ends the session, it does not merely hide it: the key leaving this machine or the endpoint leaving the file stops the run through the run hook — one terminal event, one connection close — discards the proposal and holds Create with a sentence saying so. A route change is a dismissal: this dialog is mounted by the rail, above the route, so a Back or a Forward would otherwise leave it standing over another page with its run alive; it closes on one, exactly as Escape does. Closing ends the session and discards the proposal; nothing about it is persisted. Without an endpoint and a key the section is one line saying where those are configured, and the runtime's prompts still run in any chat client you already use.
Two writes, in this order, and nothing is sent until everything that could
refuse has been asked. The pack is written with PUT /api/file,
baseSha256: "" and createParents: true — so a file already under that name
is refused rather than overwritten, and the missing configured parent
directory is created: storage.packs.dir, whatever it is set to and however
deeply it nests, not the literal packs/. Then
jpack.json is amended with one entry, written against the digest the read
that answered the id question returned, so a change made while the dialog was
open is refused rather than overwritten. Where the second write fails, the pack
file is on disk and nothing names it: the dialog says exactly that and stays on
screen to say it, because the file API has no delete verb and claiming an
unwind would be worse than the residue.
A pack browser:
/packslists the project's packs in a pane beside the document, and/packs/:idreads one — the whole document, in the order the file writes it, with every member the file leaves out stated as left out, and links from it to the what-if view and the test matrix. Described under Pack view.
Conditions are rendered as an indented tree and never paraphrased into English. A paraphrase of a policy condition would be a claim about what the policy means, and only the document gets to make that claim.
An evaluation and trace view:
/packs/:id/evaluateruns the pack over documents you supply, through the runtime'sexperimental_evaluatetool, and renders the payload it returns.
That view keeps three things apart, because the payload does:
- The disposition is the portable JPS Core §8.3 answer and the authoritative part of the payload. It gets the first panel and a frame of its own: kind, outcome id, the retained reason set, and the handoff state with what triggered it.
- The handoff target is shown beside the disposition and never inside it, because §8.3 keeps it outside one. It is what the pack configures. No delivery is observed, and the desk claims none.
- The trace is informative. It is rendered as the staged walk it is —
applicability, then exceptions, then rules, in the payload's own order — with
each entry's id, its condition verdict colour-coded across
true,falseandunknown, the effect or outcome where the entry carries one, and badges forskipped,suppressedandonUnknown. It decides nothing.
The envelope panel reports the facts about the run rather than about the answer:
the experimental flag, the specVersion the pack declares beside the
evaluatorSpecVersion of the contract applied to it, the packId and packVersion
read off the document that was evaluated, the bundled artifact digest, and
conformanceClaimReference — displayed as what it is, a locator for the file
that states the runtime's claim, and not a claim the payload itself makes.
Nothing is invented: a member the payload omits is absent from the view rather than filled in, and a verdict is shown as the payload spells it. A refused evaluation carries no disposition at all, so a refusal is reported as its §8.4 class and phase with the runtime's diagnostics, and never as a substitute answer.
A what-if loop. experimental_evaluate takes the facts and evidence
documents as JSON text rather than as paths, so the loop needs nothing from the
chassis: edit the documents in the page, press Re-evaluate, and a What
changed table puts the previous disposition beside the current one — kind,
outcome id, reasons, handoff state, what triggered it, and the handoff target.
Unchanged members are listed too, so the diff never hides what held. The trace
is not diffed: a trace that moved while the disposition held is not a change in
the answer.
The two editors keep the tri-state the tool asks for. Leaving the evidence box unchecked omits the key entirely, which is what "no evidence document at all" means; a key present with an empty string would be a supplied empty document, and is refused as malformed-input.
A matrix and coverage view:
/matrixruns every matrix the project declares, throughexperimental_test_packs./packs/:id/matrixruns one pack's.
Two things are on that page, and they answer different questions.
The rows say whether what a project wrote about its own packs still holds.
Each row shows the disposition it expects beside the one the evaluator produced
— the comparison is on RFC 8785 canonical bytes, so the view parses those bytes
to show kind, outcomeId, reasons and handoff, and reports the runtime's own
verdict rather than recomputing one. A row that expects a refusal carries no
disposition at all, and is shown as the §8.4 class and phase it names.
A row may additionally assert where the decision goes (ADR-0025), and that
assertion is kept visibly apart from the disposition, because §8.3 keeps the
target outside one. It is the case the view most needs to make legible: a pack
edit touching only escalation.target.name leaves every disposition byte
identical, so such a row fails with its expected and actual dispositions
matching exactly. The page says so in as many words rather than leaving a red
row to be puzzled over. The three states the report distinguishes are kept
distinct too — a named target, the literal null for "no target at all", and
unavailable where the report cannot state one — because "no target" is an
answer and "unavailable" is the absence of one.
The coverage report says how much of each pack those rows are about, and it usually has more to say than the rows do: a matrix can pass everything it has while stating nothing about most of what its pack can do. So the gaps lead and the witnessed probes fold away underneath. Each gap carries the runtime's own sentence naming what no row said, including the derived boundary probes (ADR-0023) — the exact value at which a strict and a non-strict encoding of a threshold would differ, which is the one input a matrix is most likely to lack.
None of it gates. A missing probe moves no status, and the page says so, because a report that looked like a failing check would be read as one.
Graph views:
/graphsruns every graph the project configures, throughexperimental_test_graphs;/graphs/:idruns one.
A graph composes packs: one node's outcome lands at a fact pointer the next node's rules read, and its resolution state feeds that node's evidence. No JPS version defines a graph, a composition, or a composite result — the format is the runtime's own convention, and only each node's pack evaluation reaches the shared evaluator. The page carries the payload's own label saying so.
Each graph is drawn as the walk it is, and what that diagram can claim
depends on what the connected runtime serves. The desk feature-detects both
graph tools by name at connect time and never reads a version string — following
nextCursor to the end of the listing, because a tool on a page nobody asked
for would read as a tool the runtime does not have. A listing that does not
answer leaves what the runtime can do unknown rather than absent: the
optional surfaces stay off, and a banner says the listing is what is missing,
because a page that quietly withdrew them would be claiming the runtime lacks
them.
With experimental_get_graph (ADR-0029) the desk fetches the graph
document itself and draws the composition: every node the document declares,
laid out in layers by the document's own edges, with one real arrow per
declared edge labelled with what that edge carries — the fact pointer it
writes, the evidence requirement it feeds, and the tri-state that requirement
takes when the upstream disposition is not an outcome. The node the document
declares as its result is marked, and the arrow from it to the composite
headline is the one further relationship the document states. Each node names
the pack it evaluates. Layering is longest-path over the declared edges; where
two nodes sit in one layer the tie is broken by the runtime's own evaluation
order, read off the coverage report, so nothing invents a sequence.
The runtime's verdict on its own bytes is final. experimental_get_graph
reports status: valid where its own strict decode succeeded and
undecodable where it did not, and that decode is stricter than a browser's:
duplicate member names alone are refused there and resolved last-wins by
JSON.parse here. So the desk reads nothing out of a document the runtime could
not — it falls back with the runtime's own sentence, verbatim, rather than
drawing a graph out of bytes the runtime had already refused.
Past that verdict, what the views draw from is checked here, member by member,
because valid means acceptable JSON with an object root and not a schema
verdict. A nodes member that is not a map, a missing edges array (the format
requires it even when empty, so absent is not none), an edge missing an
endpoint, a member of the wrong type — each declines the drawing and says which
member declined it, rather than being coerced into a shape the document never
stated. A cycle or a self-loop declines it too: the format requires the
edges to form a DAG, so every way of drawing one is a repair — and an edge
naming an endpoint the document does not declare is listed rather than drawn.
The document also shows what coverage alone could not: a node the coverage report names no probe for is declared by the document and drawn, and the page says coverage names no probe for it rather than showing it with a gap count of zero. Why coverage names none is not something either payload states, so the page does not say.
The two accounts on screen — the document, and the matrix run whose coverage and
rows it is joined to — come from two calls, and ADR-0030 is what proves they
describe one file. A graph matrix entry reports graphSha256, the digest of
the exact bytes that run decoded; experimental_get_graph reports the sha256
of the bytes it served. The desk compares the two.
Where they agree, the walk is drawn and the page says so in one line: one revision, a binding of bytes and not a verdict on the revision. Where they disagree, the graph file was edited between the two calls, so the two answers are about two revisions and the desk does not join them at all — the document walk is withdrawn, the coverage fallback stands in with a line naming the divergence, and both queries are asked again so the next pair can re-bind. That re-ask is one cycle per pair: the pairs a connection has asked about are remembered whole, digests folded to one spelling, so a file still being edited settles into the withdrawal instead of spinning, and one edited back and forth between two revisions asks about each once rather than alternating forever. Combining one revision's rows with another revision's arrows is the thing this prevents, and it prevents it by not drawing rather than by choosing a winner: neither revision is called wrong, because which is right is not a question two digests answer. Where the matrix entry states no digest — jpack 0.18.0 and older, or an entry whose document did not load at all — there is nothing to compare, so nothing is claimed in either direction and the older bounds below stand exactly as they were.
Those bounds stay in every case, because the digest upgrades the join rather than replacing what keys it: the document query is keyed by the connection, a document is drawn only while the runtime still advertises the tool, and neither is joined to the other while either call is in flight.
Without it — jpack 0.18.0 and older — the fallback is unchanged: the nodes
represented in the coverage report, on the evaluation-order axis the runtime
enumerated them along, ending in the composite headline. Coverage is then the
only account of the graph's shape that reaches this wire, and it can omit a node
the walk holds, so that diagram claims representation and not
completeness. It draws no arrow between two nodes, because the wire carries
the walk's node order and the edge indices coverage represents but not which
node feeds which — and in a graph with independent branches that arrow would be
false. Its edges are reported beside it as the indexed slots the payload
describes them as, each with the witness its resolved and unresolved branches
have. Nothing is reconstructed by parsing the English in a detail sentence,
which would make a contract out of prose.
A runtime that serves documents but could not decode this one falls back the same way, with one line saying so: serving is not validating, and the runtime returns a mid-edit document deliberately rather than going silent on it.
What holds either way: choosing a row colours the nodes with the comparisons that row reported and nothing else; a node the selected row reports no comparison for is shown exactly that way rather than as having passed; and the row's own verdict — which covers the headline and every reported node comparison together — is shown beside the diagram as the row's, never painted onto the composite.
What the project configures is listed above the run where
experimental_list_graphs is served: the configured id beside the document's
own id and version, its declared format version and result node, its node and
edge counts, and the configuration's description. It costs one call that
evaluates nothing, so it lands before the matrix has finished — and it lists a
graph whose rows would not load, which a matrix run reports only as a failure.
Counts absent from a row are printed as not read rather than as 0: the runtime
omits them, never zeroes them, exactly so a malformed document cannot look
honestly empty. A listing that refused is reported on the page with the
runtime's own message, and the section is left empty rather than showing what a
failed call could no longer confirm — the matrix still runs beside it, and still
reports the graphs whose rows loaded. Without that tool the page behaves as it
always did and finds the graphs by running their matrices.
Graph coverage is grouped per node, in the runtime's evaluation order, and reported exactly as the pack coverage is.
Traces of the compared nodes the walk evaluates, where you ask for them
(ADR-0031). A control on the page — present only where the connected runtime
advertises the include_traces argument on its own graph matrix tool, and off
by default — re-runs the matrix asking for them. Off omits the argument
entirely, so the untraced call is unchanged; the two answers are kept in
separate cache entries, because a payload carrying no traces answers a different
question and must never be shown as the answer to this one. Each trace is drawn
by the same renderer the evaluation view uses, since it is the same artifact
under the same contract, and a comparison that mismatched shows its trace too
— that is the one worth reading. A trace of [] is said to carry no entries; an
absent member shows nothing, because absent means not asked or not evaluated,
and a comparison naming a node the graph does not declare has none even when
traces were asked for. The comparisons are listed lexicographically by node name
and each trace is the evaluator's own walk order: two different orders, said so
where they meet.
A traced request can fail, and traces riding inside the runtime's report budget is one reason among several — a suite that fits without them can be over it with them. The desk does not say which reason. A tool error arrives in one unstructured shape whether the cause was the budget, an argument this runtime rejects, a configuration it could not find, or a graph id it does not have, and an error that is not a refusal at all covers a response the runtime did produce and the desk could not read. So the page shows the runtime's own message as the reason and adds only what it actually knows: that this request asked for traces, that it did not produce a usable answer, and what clearing the ask will do — return to the untraced answer where one is still in hand, or retry the untraced request where none was ever received. The control stays on screen through the failure. It never renders a failure as an absence of traces: the question was not answered, so nothing is known about the answer.
Where a composed decision is handed off (ADR-0032). A rows document
declaring graphMatrixVersion "2" may assert the handoff target of the
composite and of each node it names, and the run reports each assertion as an
expected/actual pair — on the row, and on that node's comparison. The two
members are one pair: they appear together, exactly when a well-formed
assertion rode a run this walk performed, and a row whose assertion was itself
defective reports that defect in its detail and no pair at all. The desk reads
them through one accessor that applies that rule, and shows nothing where only
one half arrived. The composite's target is the result node's own — a named
target exactly when that disposition requested a handoff, and the literal null
otherwise.
Both pairs are drawn with the component the pack matrix uses, in the same three
states: a named target, the literal null for "no target at all", and
unavailable. That third state is reachable on the row alone, where a
refused run leaves nothing to state; a node comparison exists only because the
walk evaluated that node, so it reports a rendering or null and never
unavailable. The renderings are display values and are never compared here,
exactly as on the pack side — a capped rendering can differ from its own pair
past the cap — so the comparator decided on decoded targets, and the row's own
status is the only verdict shown.
A project that configures no graph is an answer rather than an error: the walk
reports skipped with no entries, the home page offers no graph entry, and the
graphs page says the project configures none.
A shell around all of it. Six regions — a header, a navigation rail, the
routes above, an Inspector, a Console and the status strip that is its
collapsed face — plus two pages of their own: /admin, which renders the desk's
configuration as two groups of cards in one shape under a status line, and
/help, which names what this runtime advertises and renders its own authoring
prompt as text. The shell derives no verdict:
no status colour in the rail, no rollup count, no "N failing" pill anywhere. A
red badge in a nav rail would be a gate the runtime never issued.
Six regions, on a CSS grid of a definite viewport height —
height: 100dvh and not min-height, so the content row divides the viewport
instead of growing to fit a long page, .desk-main is the route's scroll
container, and the 28px strip stays on screen. The three pane sizes in the table are the
configured values, written onto the grid as --rail-w, --inspector-w and
--console-h; collapse writes one of two values into a second custom property
and never a third number.
A route publishes into the Inspector through useInspectorPortal(node), which
portals into the element the slot hands it and claims the slot while it is
there. The context sits above <main>, so a route can reach it, and the pane
publishes its target through a callback ref — a drawer that starts closed
reports no target rather than a detached one, and a route that is told there is
nowhere to publish renders nothing.
The claim exists because a portal cannot tell React it happened: the pane used
to render its empty-state paragraph unconditionally, so the first route to
publish showed its panel and the empty state underneath it. A CSS :empty
sibling rule would have been shorter and is rejected — vitest runs with
css: false, so nothing in this repo could hold it and the mutation harness
could not discriminate it.
A route may add a landmark inside main. The pack route adds two: the
packs pane is a list of navigations, so <nav aria-label="Packs"> is the
correct markup for it, and the document carries its own <nav aria-label= "Members"> outline. The six regions below are the shell's, each still exactly
one, and a test holds that with both of the route's own landmarks mounted.
| Region | Default | Collapse | Landmark |
|---|---|---|---|
| Header | Always visible, 48px | Never | banner |
| Left rail | Expanded, panes.left.width (248px) |
→ 56px icon rail; a drawer below 900px | navigation, named "Project" |
| Main | Always visible | Never | main, id="main", the skip link's target |
| Inspector | Closed, panes.inspector.width (360px) |
→ 0px; a drawer below 1100px | complementary, named "Inspector" |
| Console | Collapsed to the 28px strip, panes.console.height (240px) |
→ the strip, never below it | region, named "Console" |
| Status strip | Always visible, 28px | Never | contentinfo |
The measure is left-aligned at one gutter. A route's content is not centred
in the main pane — .desk-measure is margin: 0 with padding: var(--density-section) var(--density-gutter) 4rem — so the content's left edge
is the rail's right border plus one gutter (2rem, 1.25rem compact, 1rem below
600px) and is the same on every route at every width, which is what a
persistent rail is for; a centred column beside a rail is aligned to neither.
Each route names its width kind, with data-measure="form" | "wide" | "full" on its top-level element: form is --measure-form (44rem: Admin,
Help & About), wide is --measure-wide (72rem: the packs layout, a pack's
evaluate route, project home, author) and is the declared default, full is
max-width: none (matrix and coverage, graphs) — and a route that names none
fails web/src/routes/measure.test.ts, which derives the set of routes from
App.tsx, rather than quietly rendering at the default.
The document never scrolls, because every scroller is a containing block.
A scroll container clips and scrolls only the descendants whose containing
block lies inside it, so every rule that authors a scrolling overflow
declares a position that positions in the same rule — relative on sixteen
of the eighteen, and fixed on the two that were already out of flow, the
dialog's content and the shell's drawer, which the rail and the Inspector both
use. So do the frame and its four panes, under their own exact selector. A
rule that merely clips is not held — an overflow: hidden on an ellipsis
label or a popup clips text, and text has no containing block to be laid out
against.
Measured by scripts/containment-check.sh on a build of e2d1dee, in the
script's own configuration: Admin at 1400×800 measured a document 2355px tall
inside an 800px window, scrollY reached 1555 after a scrollTo(0, 5000), and
the whole shell could be scrolled up out of the frame, because Radix renders a
1px hidden <select> beside every Select trigger that sits inside a <form>:
three of them, at y 1725, 2257 and 2354, absolutely positioned against the
initial containing block, neither scrolled with the main pane nor clipped by
the frame, and counted into the document's own overflow.
Two things check it, and they check different halves.
web/src/ui/containingBlock.test.ts reads the source. It holds the
declaring rules — each rule that authors a scrolling overflow, and the frame
and the four panes under their own exact selector. What it cannot hold is the
cascade: a rule that takes a pane's position
back by any other selector — an ancestor in front of it, an id, an attribute,
a nested &, a :global, an inline style — is a computed result and not a
sentence in a sheet, and three drafts that tried to emulate it were each
defeated by a construction nobody had thought of. Its docstring says exactly
that, rather than leaving it to be found. scripts/containment-check.sh
measures the cascade, in Chrome, against a built chassis: the computed
position of the frame and each pane, scrollHeight against innerHeight,
scrollY after a scrollTo(0, 5000), and whether any absolutely positioned
element still resolves its offsetParent to BODY. Every route App.tsx
declares, at every width the sheets author a breakpoint for, four pane
configurations above 1099px and three at or below it — below that the Inspector
is a modal drawer whose overlay owns the pointer, so the console cannot be
toggled while it is open — 242 rows a build. It measured 0 of 242 contained at
e2d1dee and 242 of 242 after. CI supplies no runtime binary and no project, so
there is nothing for the chassis to serve; the gate is run by hand. Run before
every merge that touches a stylesheet. This is a convention; nothing automated
enforces it.
A collapsed pane is removed from the accessibility tree, not merely made
invisible: closed is the hidden attribute plus [hidden] { display: none !important } in the shell sheet, so a viewer who has closed the Inspector
cannot tab into it.
Below 900px the rail is an overlay drawer, opened from the header. In drawer
form the rail draws no collapse toggle, so the opener has to live outside it —
a control inside a closed drawer opens nothing. It is the Project navigation
button at the left of the header, present only at that width, carrying
aria-expanded always and aria-controls="desk-rail" only while the drawer
is open — a closed Dialog unmounts its portal, so the id is not in the
document and naming it would offer assistive technology a broken relationship
rather than none. The same holds for the Inspector's toggle below 1100px. Both
drawers hand focus back to the header control that opened them, by reference:
neither has a Dialog.Trigger to restore to, because both openers are in the
header two grid cells away. The drawer carries the
navigation landmark with it, so the region table above holds at every width,
and it carries a visible close button — Escape and the overlay are not
affordances a viewer can see. Every navigation inside it closes it: the
drawer is modal, so a link that navigated and left it standing put the
destination behind an overlay.
Both drawers are modal: while one is open the page beneath it is
aria-hidden, which is what a modal is for and is why the landmark count is not
the same in that state.
Shortcuts. Mod is Ctrl or Cmd.
| Chord | Does |
|---|---|
Mod+B |
Collapse or expand the navigation rail |
Mod+Alt+I |
Open or close the Inspector |
Mod+Alt+J |
Open or close the Console |
Mod+S |
Save, while editing a pack |
Every shell chord is suppressed while focus is in an input, a textarea
or a contenteditable — which is exactly an editor — and every one has a
visible button, so a chord the browser claims costs a click and not a feature.
Mod+S is on the list and is deliberately not installed by
installShortcuts, and the two facts are one fact: save is the chord that has
to fire inside a text field, which is where that rule silences everything
else. The pack editor registers it on document for exactly as long as edit
mode is on screen — not on a subtree, because document.body is where focus
sits the moment the mode opens (the Edit button unmounts itself) and from a
subtree listener the chord neither saved nor called preventDefault, so the
browser's own "Save page as…" opened over unsaved work. The shell's rule stays
as written and the label says where this one applies.
Every modifier a chord does not declare is rejected: Ctrl+Shift+B is not
Mod+B, and Ctrl+Cmd+B is neither of the two spellings of Mod — an undeclared
chord is left to the browser unprevented rather than claimed and swallowed.
On macOS, Cmd+Alt+I and Cmd+Alt+J are the browser's own developer-tools chords
and Cmd+B is Firefox's bookmarks sidebar; the Ctrl spelling works everywhere.
Mod+J (Downloads) and Alt+<digit> (Firefox tab switching) are deliberately
not bound, and F6 is left to the browser.
A pane is not a dialog, so Escape does not close one — with one exception,
stated rather than hidden: below 1100px the Inspector renders as a drawer, and a
drawer is a dialog, so Escape closes it there. Swapping to the drawer remounts
the subtree, so inspector-local state resets at that breakpoint.
What is remembered is what somebody chose. A layout that came from the
configuration file or from the built-in defaults is not written down: it is
re-derived on every load from inputs that are still there, and a record of it
would be preferred over the file on the next visit — which is how a panes
block becomes permanently inert on a browser that opened the desk once before
the file existed. The record appears the first time a pane is moved by hand.
Where it is kept. Which panes are open is per viewer and per project, in
localStorage under
jpack-desk:shell:v1:<projectKey>
where projectKey is the project root the chassis pins at startup,
percent-encoded whole. It is that root and not the runtime's configPath,
because a project with no jpack.json reports no config path — so every
configless project on one origin used to map to the literal default and share
a single record, two directories with one layout between them. The literal
default remains and now means exactly one thing: the file listing has not
answered yet, and nothing is written under it.
The whole path, not a slug and a short hash. It was a 64-character slug plus
eight hex digits of FNV-1a, and eight hex digits collide: two roots differing
only past the 81st character produced one key, so one project's reset removed
the other's record. Percent-encoding is injective — % is itself escaped, so
the encoding is prefix-free and decodable — which makes distinct roots distinct
keys by construction rather than with probability, and it carries no :, so no
root can be read as part of the key's own prefix. Records under the old keys are
never read again, which is what this store does with every record it cannot
use.
The whole path means the bytes the chassis reported, and it used to be
trimmed first — which made the claim false exactly where it matters: a POSIX
filesystem permits a trailing space, so /srv/project and /srv/project are
two directories that shared one key, and one project's record was restored and
reset for the other. That is the collision the encoding replaced, reintroduced
one line above it. Whitespace still decides whether there is a project at all,
and that decision belongs to one predicate so the two cannot disagree.
One desk on one origin serves whichever project it was started against, and a
layout chosen for a three-pack project is not the one chosen for a forty-pack
one. Only the collapse flags and the console's channel are stored — no widths,
because nothing on this desk can yet change one, and a stored number no viewer
could have chosen would be a record of a choice nobody made — and only for the
panes the viewer has actually moved, one bit each. A record that carried all
three because one was toggled would be two built-in defaults outranking the
configuration file for ever. Every read and write is in try/catch: a private
window and a browser with site data blocked throw on the accessor rather than
answering null.
Reset panes is in the user menu, and it is in the shell rather than in Admin
because the record it clears is this browser's and this viewer's — the same
class of thing as the two settings links beside it, and about all three panes,
so it is not one pane's own header control. It clears exactly that one key:
localStorage.clear() would take the session token's neighbours and every other
project's layout with it, and a reset that logged the viewer out of something
would be one that lied about its scope. And only a record this shell wrote —
the key is derived from a path the viewer never chose, on an origin this desk
shares with whatever else has been served from it, so a value that is not JSON,
is not an object, or carries another shell version is left exactly where it is.
The reset runs inside the provider that owns the record, so it cancels a write
already on its way, refuses to clear the provisional default key before the
chassis has said which project this is, and reads the key back afterwards — and
it reports what happened, in four sentences rather than one, because
"cleared", "this browser refused", "the project is not known yet" and "what is
there is not ours" are four different facts.
The menu stays open while it answers, since a menu that closed would take the
answer with it.
The pane dimensions are not a setting on a settings page. They are in the
schema, they are decoded, validated and applied, and Admin offers no control for
them: a page that edits the frame it is drawn in is a control looking for a
pane, and the write path went with the form. panes in jpack-desk.json is
read exactly as it always was.
Reduced motion is respected: prefers-reduced-motion: reduce sets every pane
transition to zero, and collapse is instant.
Appearance is yours. Theme and density are set from the user menu, as two radio groups that apply the moment they are picked — there is no Save, because a preference is not a file — and they are stored in this browser, under one key per project, on exactly the grammar and discipline the pane record uses:
jpack-desk:appearance:v1:<projectKey>
The project file's appearance is the default for everyone who has not
chosen, and that is the whole of the change. It used to be a card on Admin that
wrote the member on a Save, so one person choosing dark chose it for everybody
who ever cloned the repository; the member is still in the schema, still decoded
and still applied, and it is now the value the menu names under the groups —
Project default: <theme>, <density> — so that "Use the project's default" is
not a leap in the dark. The ladder is preference, then the project file, then
the built-in default, computed in one place and applied in one place.
Every read and write is in try/catch, a record this desk did not write is left
where it is, and a stored value outside the decoder's own unions is a record
this desk did not write: never applied, never shown as chosen, and never
deleted. A record is this desk's when it
carries the version, no member this writer does not write, at least one that it
does, and a value in its own union for every member present — ownership is every
byte of the record and not the version number, because localStorage is one
namespace shared with everything this origin has ever served under a key derived
from a path the viewer never chose. So {"v":1,"writer":"another-app",…} and
{"v":1,"theme":17} are both somebody else's value: not applied, not deleted,
and named as such. The question is not "is this legible" but "could this desk
have written it", and one member this writer could not have written says the
writer was not this one, whatever the member beside it says. The write serializes only the
member the viewer actually chose, for the reason the pane record gives — a
density stored because the theme was picked would be a built-in value
silently outranking jpack-desk.json for ever. A choice is stamped with the key
it was made under, and a chassis that names a different project discards it
rather than hiding it: hidden, it came back when the tab returned to the first
project, ahead of that project's own record and over whatever another tab had
written there meanwhile. Clearing it says which of four
things happened, on the panes' reset's own terms.
Nothing is applied that this desk has not established. The record is
unreadable until the chassis names the project, and appearance is the schema's
until the file has been read — so until each of those answers, the root element
is left exactly as it was found and the menu shows nothing as chosen, with the
project-default line saying it has not been read yet. A member the viewer has
actually picked needs neither answer: it is the top of the ladder, so it applies
the moment it is picked. With a stored dark over a file that says light, one
load applies dark, once, and neither system nor light at any point.
The record is this browser's, not this person's. The identity slot has no real user yet; when it does, the record can move server-side and follow somebody between machines. Until then a second browser is a second preference, and the menu does not pretend otherwise.
What a theme does. appearance.theme writes data-theme on the root
element — light and dark pin a palette, system removes the attribute and
leaves prefers-color-scheme to answer, so under system the desk follows
the operating system's own setting and changes with it. Both palettes are
authored: every colour token on :root has a dark value, in both of the blocks
that select dark — the media block and the attribute block — and they carry the
same value token for token, which a test holds, because they cannot be written
once and nothing else stops one being edited and the other forgotten. The
condition verdicts are re-authored rather than inverted: green, neutral and
violet are what true, false and unknown mean, so the dark values keep
the hue and change the lightness.
The contrast is measured, not chosen by eye. web/src/ui/palette.test.ts
computes the WCAG ratio for every text on every background it is painted on,
every semantic foreground on its own background, and the focus ring against
both grounds — 4.5:1, and 3:1 for the ring — from the token bytes themselves,
in both palettes. It caught the light one first: --ink-faint was #86867d
and reached 3.4:1 on the page, and it is darker now because of it. The borders
are deliberately not in that set and the test says so where the pairs are
listed: they separate regions rather than identify controls, and holding them
to 3:1 would be a dark palette re-authoring the light desk on its way past.
One case still flashes, and it cannot be fixed with a script. Under
system nothing does: the media block paints dark on the first paint, before
any JavaScript runs. A viewer who has explicitly chosen dark while their
computer is set to light gets one light frame, because the record that holds
that choice is keyed on the chassis' project root and the page does not know it
until the file listing answers — a pre-paint script would have to guess the
key, and a wrong guess applies one project's preference to another's desk.
What compact does. appearance.density writes data-density on the same
element — compact sets it, comfortable removes it, because comfortable is
the scale on bare :root. That scale is a list row's height, a control's
height, a table cell's two paddings, the gap between items in a list, the type
size of the surfaces that are dense to begin with, the two vertical rhythms a
flat page keeps, and the page measure's own gutter — the count is deliberately
not written here or in the sheet, because it said "six" through two chunks that
added three. Compact tightens every one of them, and everything that shrinks
reads one — the packs list, the inspector's rows and its diagnostics, the
assistant's event list and proposal, the disposition diff's cells, the Admin
cards' field rows, the console log, the pane heads, the Button and Select
heights, and the gutter the content starts at. Tokens rather than
per-component rules: a pane with a [data-density] selector of its own would
be one more answer to a question the scale already answers. A test
holds every compact value strictly smaller than its comfortable one, in the
same unit — a density that is offered, stored and applied while changing
nothing is exactly what this replaces. The one number that could not stay in
the sheet is the packs list's row height, because that list is windowed and
reserves its off-screen rows in JavaScript; ROW_HEIGHT carries it and the
same test holds it equal to --density-row in both blocks.
/packs is a layout: the project's packs on the left, the selected one beside
them. The pane survives every change to the selection, so a filter and a scroll
position are not lost by opening a pack.
The document reads in the file's own order. A pack that writes rules
before outcomes renders rules first, because that is the document someone
wrote and a page that re-sorted would be showing one nobody did. The order the
schema declares is used for exactly one thing: where to put the line for a
member the file does not declare.
Every top-level member finds its own place, the five identity members
included. They were drawn as one unit positioned at the earliest of the five,
so a document writing decision before specVersion, id and version had
those three moved in front of decision — the page re-sorting, in the one
place its own test was written not to look. Grouping them under a single
Identity outline entry is a nav decision and is made in the nav: five
near-identical entries would be a worse nav, and that is not a reason to move
anything on the page.
An omitted optional member gets a line saying it is omitted, spliced in at
the position the schema's order gives it — so applicability's "not declared"
sits between the decision and the evidence requirements, and /description has
one too: while the identity members were one unit the unit was present because
title was, so nothing ever said the description was absent. A required
member that is missing gets no such line: its absence is a refusal rather than
an omission, the runtime issues one at that pointer, and a block there would
take that diagnostic off the strip — where every reader sees it — and put it
behind a selection nobody has made. Every outline entry is a link, omissions
included, because the omission block is a place on the page like any other.
This is the difference between "the pack does not narrow its own scope" and
"the page did not draw that part", and the view this replaced could not tell
them apart: its section wrapper returned nothing when it had nothing to
render.
Three things the old view dropped are here: metadata.reviews, the per-member
extensions objects (eight $defs may carry one, plus the root), and the
condition tree. Reviews are rendered and never written: this surface has no
reviewer identity, so a review it wrote would be signed by nobody.
The fixtures the whole of this is asserted against are documents jpack spec validate reads, and a test holds every enum-valued member of each of them
against the spec's own closed lists — including each condition node's own op,
at every depth, which the walk used to descend through while checking only the
operators inside fact nodes. Two of the three are accepted outright;
full.pack.json is structurally accepted and then refused as unsupported
with exit 2, deliberately — it declares example.review-window as a required
extension, which is the case that exists to show a runtime refusing a document
it can read perfectly well. Saying all three were "accepted" was a claim about
an exit code none of them had in common. They were not: full.pack.json wrote an
evidence-requirement id into escalation.triggers, which is what made the wrong
reference model above look correct and froze it in a passing test.
Every block carries its RFC 6901 pointer, and that one string is five things:
| Where | As |
|---|---|
| The renderer | data-pointer="/rules/1" |
| The DOM | the element's id, verbatim |
| A deep link | #/rules/1 — scrolls to the block, focuses it, selects it |
| The Inspector | the ?at search parameter |
| A diagnostic | instancePath, which is the same string from the runtime |
The escaping mirrors the runtime's own carrier.Pointer byte for byte: ~ to
~0, / to ~1, and the document itself is the empty string. Three
consequences are written into packs/pointers.ts because all three are silent:
- An id containing
/or~is legal HTML and is not a valid CSS selector, so every lookup by pointer value usesgetElementByIdand neverquerySelector. Fixed selectors —[data-pointer],a, the article itself — are enumerated withquerySelectorAllin a few places, which interpolates nothing and cannot hit this; the rule is about pointer values, and stating it as "noquerySelectoranywhere" was a claim the code does not make. - A fragment is percent-decoded before it is compared.
- An address that is not an address names nothing. RFC 6901 admits exactly
two escapes, and
~2or a bare trailing~is neither — those parsed as ordinary characters and named a member nobody wrote. An array index must be0or a digit string with no leading zero, and is bounds-checked; a member is looked up as an own property, so/constructorand/toStringname nothing rather than selecting something no JSON document has. One evaluator decides all of it, because three of them disagreed.
Selection is held in the route and never in the pane: RightPane swaps its
wrapper at 1100px and remounts the subtree, so a selection the pane held would
be lost at that width. Selecting writes with replace, because choosing what to
inspect is not a navigation and must not fill the Back stack — from a block and
from an outline entry alike, and both carry the rest of the address through. One
element carries one pointer, and a test holds that no pointer appears twice.
Selecting also opens the Inspector where it is closed, which is the shell's default in a fresh profile. It is a response to a gesture and not a seed: a reader who picks a member has said what the pane is for, and without it the panel filled behind a closed pane and the only thing that changed on screen was the block's own border.
The document is one tab stop, not ninety-seven. A roving tab index puts
tabIndex={0} on exactly one block: the arrow keys move it in document order,
Home and End reach the ends, and Enter or Space selects. Otherwise the only
keyboard route into ?at was the outline, which addresses the twelve member
units and nothing under them — no rule card, no condition operand, no review. No
role is claimed for a block: these are the document's own regions, nested
inside one another, and role="button" on a container holding more of them
would be a lie about both.
- Member — the pointer, the member's own JSON subtree pretty-printed in a
container that scrolls sideways, and the provenance beside it: the declared
path, the byte count, the digest of the loaded document, and the line
"matches the file the editor holds" only when the two digests are equal.
get_pack'ssha256and the chassis' file read are two answers about one file, and only equality proves they describe one revision. - References — what the member names and what names it, in both directions,
computed from the document. Where an id resolves to nothing the line says "no
declared outcome carries this id" and stops:
JPS-SEMANTIC-UNRESOLVED-OUTCOMEis the runtime's to issue, and the desk must not shadow it with a word of its own. A member that names no id at all produces no line:escalation. triggersis a closed enum of five reason words, not a list of ids, and resolving one printed a dangling-reference claim on every conformant pack. - Checks — the diagnostics anchored at or under the pointer, each printing
the runtime's own
code,layer,severityandcodeStability, with the pointer at the foot; then which bytes were checked. An empty set is not a clean bill and the panel does not dress it as one — and it does not say even that much while the check is still in flight, where the list was truncated, or where the check is stale, because in each of those the empty set is not an answer.
240px in main's left: a filter over the pack id, a sort (name ascending and
descending, and nothing else — list_packs reports no date and no size, so any
other order would be one the desk invented), the rows with their versions, and
"Show all N" past the first screenful, offered only while the listing has
currently succeeded — a refetch error keeps the last good data, and a button
underneath a failure sentence offering to show all N of a listing the pane had
just said it could not read is an offer about nothing.
Rows are links, so tab order is native; the arrow keys step between them and Home and End reach the ends of the list, not of the window. A destination that is not rendered is scrolled into view and focused in the render that brings it in: navigating by the rendered anchors clamped every key to the window, so with 300 rows in a 400px viewport focus stopped at row 21 and ArrowDown from there prevented the default and moved nothing. Filtering resets the scroll, because a window computed from the old position can begin past the end of the new list and render no rows at all.
A pack whose document the listing could not read is still listed, with packId
and packVersion sent as empty strings and the reason in detail. Such a
row carries the runtime's own sentence instead of a version: an empty version is
not a version, and a bare "v" asserted a member of a document nothing could
read.
Past a screenful the list is windowed — a fixed row height, an overscan, and no new dependency. A viewport that cannot be measured renders every row, which is the case in jsdom, where nothing is laid out and every measured height is zero.
A refused listing shows the failure. "This project declares no packs" and "the
listing did not answer" are different statements and only one is about the
project — which is why the rail's Packs entry carries a count only where the
listing actually answered, and never a 0.
The pane is a <nav aria-label="Packs">, because it is a list of navigations.
That is a seventh landmark on the page while this route is open, inside
main, and the document's own member outline is an eighth — three
navigations in all, each named, so a screen reader can tell them apart. The
shell's own six are unchanged, and a test mounts both of the route's landmarks
and holds that each of the six is still exactly one.
The check runs on load, over the file's bytes where they loaded and over the
served document where they did not — and the strip says which. The query is
keyed on the bytes and the connection epoch: identical bytes answer
differently on a runtime bundling different specification artifacts, so a report
cached across a reconnect would be a different binary's opinion of the same
file. validate is sent {document} and nothing else; omitting through is
what makes the runtime run its own default, which is the whole ladder.
The sentence is derived from the payload's own layers rows and quotes its
status word verbatim — every row it was given, and the status, in one
shape whatever happened. A failure used to be printed as the failing layer
alone: invalid with [carrier passed, structural failed] came out as
structural — 1 diagnostic, naming neither the verdict the runtime reached nor
the layer that ran. A layer the payload does not list is one that did not
run: the ladder short-circuits, so a carrier failure reports [carrier failed] alone and a structural failure returns before the semantic layer. Two
unsupported shapes must not be confused and each has a test — a specification
version the runtime does not bundle reports one layer row and a capability
diagnostic whose layer appears in no row at all, while an unsupported required
extension reports all three layers passing.
The document is one of three views on a pack, and it carries the links to the
other two: Try it to /packs/:id/evaluate, and Test matrix where the
listing says the pack declares one. Both were reachable only from the view this
replaced.
Diagnostics anchor on an exact instancePath match, else on the nearest
rendered ancestor with the diagnostic's own pointer printed verbatim beside
it, else on the document strip — which prints them, under the layer
sentence, with the runtime's code, message and the pointer it named. A pack with
no specVersion is refused at /specVersion and nothing draws a required
member that is not there, so its diagnostic reaches the strip and nothing else;
counting it in the sentence and printing it nowhere would be a page that says a
member is wrong and never says which. That ancestor walk is what makes a missing
member reportable: the runtime reports one at the pointer including the absent
name, so /rules/0/when on a rule with no when lands on that rule's card. A
diagnostic computed against different bytes is never re-anchored — deleting
rules[0] moves every /rules/N, so a /rules/0 diagnostic would land on a
rule that is not the rule it is about, which is worse than no diagnostic at all
because it looks like an answer. The report carries the exact bytes it checked
and they are compared with the bytes on screen; where they differ the check is
stale, the strip says so, and no diagnostic is anchored — not the ones that
still resolve, because nothing in a pointer says which of them would still be
right. The check runs over the file on disk where it loaded and over the served
document where it did not, and those are two artifacts: the digest warning is
about two answers from two sources and can be quiet while these bytes still
differ.
An empty document is not checked at all, and the strip says so in words: the call is disabled, and a disabled query has no data for ever — which the strip used to read as "Checking…" and print until the page was left.
Where diagnosticsTruncated is set the runtime stopped at its own limit of
100, and the panel says the list was cut rather than that nothing else was
found.
?edit on the same route, and a search parameter rather than a path segment
for one reason: the dirty blocker's predicate is
currentLocation.pathname !== nextLocation.pathname, so a mode in the path
would ask "leave without saving?" every time a viewer switched back to Read,
and a predicate loose enough to allow that would stop asking on the exits it
exists for. The toggle is the same page — same mount, same scroll, same
selection, same buffer — and ?at and ?edit are both written with
replace: true, because how you are looking at a document is not a
navigation.
The buffer is the document, and nothing stands behind it. Both modes draw
indexDocument(buffer).value rather than the parsed pack get_pack served, so
a keystroke in the JSON view moves the reading document above it and a form edit
is in the bytes the moment it is made. A page over one revision while the form
writes into another is the digest-binding failure one component further in.
The served document is drawn only before a file has been loaded at all — the read has not answered, or the listing names no path. Once the editor holds bytes, those bytes are the page whatever they say: bytes that do not scan are the JSON view with the position they stop at, bytes that scan into something that is not an object are the JSON view too, and a member of the wrong shape states itself at its own pointer with its bytes in it. Falling back to the runtime's last good answer there would draw a document that is on no disk, over a file that no longer holds it — and the Inspector beside it would list members and references the file does not carry. The digest sentence still says when the two sources disagree.
And the buffer follows the address. /packs/:packId is one element inside
the packs layout, so another pack is another parameter and nothing unmounts —
which is what lets the mode toggle keep the mount, the scroll and the buffer.
Everything the page holds about a file therefore has to follow the address
itself: the buffer is seeded once per path, and the last write's verdict and
any unwritten operands are dropped when the path moves. A watcher refetch
carries the same path and still does not rebase, which is the rule the base
depends on. The Inspector's provenance group says the same thing from the other
side: while the buffer is dirty it stops claiming the file matches what the
editor holds, and says the figures are the file on disk instead.
The toolbar is edit mode's: a reading page carrying a Check button and a Save
that can never be pressed is chrome about a mode nobody is in. The way in is
one control beside the two standing links, and it writes ?edit with
replace: true for the same reason selecting a member does.
Forms in place. A member's card becomes its form where it stands, and it
keeps the block's pointer as its data-pointer and element id — so a
diagnostic still anchors on the field, a deep link still reaches it, and the
Inspector still selects it. ui/Field owns the label, the aria-describedby
and the aria-invalid; what the field adds is the runtime's own code and
message, printed under the control and named in the description, so a screen
reader reaching the input is told what the runtime said rather than that
something is invalid. Phase 2 covers the flat members — identity, decision,
outcomes, evidence requirements, sources, escalation — and rules and
exceptions. What a field says before the runtime answers is said in words: an
id's hint is "lowercase letters, digits and hyphens", not the pattern, because
the pattern is shape.ts's and a regular expression is not something to read
aloud.
A member the document does not carry is stated, not drawn. A field whose
container is absent has no span to splice into — a missing object is a different
edit, and inventing one would write members nobody asked for — so a control for
it would take a keystroke and move no bytes. source.locator,
source.citation, escalation.target and a rule's when are each drawn as
"not declared" with an offer to write the schema's own required members, empty;
the fields appear once the object does. A condition removed from a not is the
same case, and it is offered a condition back rather than being described as a
node kind this desk does not know.
Form | JSON is a third view of the same buffer, kept in sync both ways over
a monospace textarea with a scroll-synced, aria-hidden line gutter and no
editor dependency. Bytes that do not scan keep JSON available, withhold Form,
and print where the scanner stopped as a line and a column. So does a document
this desk's scanner and JSON.parse read differently — a duplicated member
name is the case that exists in the wild, and a form that wrote through a
reading nobody else shares would edit a document nobody has.
Every edit is a splice. documentText.ts indexes the bytes once and each
write replaces exactly one value's span; every byte outside it survives,
including the ones the desk has no opinion about. Blanking a nonEmptyString
removes the member rather than writing "", and a member the document does not
carry yet is inserted — at the position the schema's own property order gives
it, in the layout a neighbour already uses. Dirty is a byte comparison, so
a whitespace-only change is unsaved. Undo is a capped stack of buffer
snapshots, one per committed action, with typing coalesced per field — so a
sentence typed into a description is one Undo and not nine — and it is a
toolbar button rather than a chord, because Mod+Z inside a text field is
that field's own undo and taking it away would trade per-character undo for
per-action undo without asking. Past the cap the oldest entry is dropped and
the control goes disabled rather than the stack lying about its depth. Discard
restores the base and clears the last save attempt's verdict.
The condition builder draws the schema's five node kinds — literal,
all/any, not, fact, evidence-present — recursing through $ref. Each
group is a role="group" named by its operator and its pointer, its controls
are real buttons, and a nested group collapses to "collapsed · N conditions". A
fact node's operand control switches on the operator: the four ordered
comparisons write a decimal string, in a list, equals and not-equals
any JSON at all. It shapes and it never refuses — an empty in, an
unquoted 5000 and an id nothing declares are all writable, and validate
names them at their pointers. Changing an operator keeps the author's operand
rather than retyping it. A node kind this desk has never seen is printed as its
JSON and offered no controls, exactly as the reading tree holds it. One
deliberate exception to "every keystroke reaches the buffer": the operand
controls that take arbitrary JSON hold what is typed until it parses, because
writing each intermediate keystroke would withhold form mode with a parse error
in the middle of a word. Nothing is refused and nothing is corrected — the
field says it is not written yet and names the bytes still on disk, the text is
held by the editing session so it survives the switch to the JSON view and
back, and the toolbar says how many fields are in that state beside the unsaved
dot. Changing a node's kind moves one word where the new kind needs no member
the old node lacks, which is what all → any is: re-serializing the subtree
for it would re-indent every nested condition and re-print the author's own
number literals.
Rule order is §7-significant, so it moves by keyboard and not by drag: two
buttons on each card and Alt+ArrowUp / Alt+ArrowDown inside it, through the
writer's moveElement. Focus follows the card to its new address and a live
region names the position it landed in. A move invalidates every /rules/N
pointer past it — ?at, the Inspector's subtree and every anchored diagnostic
— so the check is marked stale and nothing is re-anchored.
Checks run on idle and on demand. The first bytes go at once; every later change waits for a pause, and the toolbar's Check and the save path close the gap. The report carries the bytes it checked, and where those are not the bytes on screen no diagnostic is anchored at all — the strip says the check is behind the buffer, and the panel says the same rather than listing what it found. In the JSON view the strip prints every diagnostic with its own pointer, because there are no blocks to distribute them to and a report visible only to whoever has the Inspector open is a report the page is keeping to itself.
Try it runs the draft without saving it. experimental_evaluate takes
pack as JSON text XOR pack_id, so the source control sends one or the
other and never both: the tool's required list is ["facts"] alone and the
handler enforces exactly-one-of by hand, so both and neither are each refused
on an argument mistake rather than on anything about the pack. A text pack
never reaches the reviewed set — applied is built only where a pack_id was
supplied, and the consult is gated on it — so a draft run is lock.DraftRun,
never refused for being unlocked and proving nothing about a recorded decision.
The audit writer, though, runs for every call including a text pack, and
only rehearsal: true suppresses the record: the declaration is sent wherever
the runtime advertises the argument, and where it is not, the pane says the run
would be recorded in a project declaring an audit directory and requires an
explicit second click. The evidence rows come from the draft's own
requirements, so one added in the editor appears and one deleted stops sending
a key. The disposition, the reasons and the trace are the existing evaluation
view, verbatim; the foot prints the payload's own packId and packVersion —
which is the pack document's id, a URI, and not the project's decision id.
A preflight refusal is rendered as the runtime's answer, class and phase and
diagnostics, with no disposition anywhere near it; mid-edit that is the
ordinary answer rather than an error. The result is stale the moment the buffer
moves, and says so, and the confirmation for an unadvertised rehearsal
remembers the bytes it confirmed rather than being a flag — the editor beside
the pane is most of what would be sent. The pane sits beside the editor where
the editor keeps 512px, and takes the Inspector's place where it does not. The
workspace is what is measured, not the editor column: the column is the
pane's flex sibling, so placing the pane shrinks the box the decision was read
from, and a predicate whose input depends on its own output has no fixed point
across a wide band of ordinary widths.
Save is PUT /api/file {path, content, baseSha256}, and the base moves
only on load, on an explicit reload, and on a successful save — never on a
watcher refetch, which would silently rebase onto bytes nobody saw and make the
next save overwrite them without the 409 that exists to prevent exactly that.
The read-back is compared to the submitted snapshot rather than to the live
buffer, so typing after a save cannot turn a true "verified" into a false "does
not match". It is never gated on the check: the chassis writes bytes and
the runtime judges them, in that order, and outstanding diagnostics stay on
screen through the save. A 409 shows both digests behind a digests
disclosure — printed short and carried whole, so a reader can compare one
against sha256sum — distinguishes exists from stale from the chassis' own
code, and offers Reload — which says that it discards — and Overwrite anyway,
which is never the primary control. On success list_packs, get_pack and the
validate queries are invalidated.
A save still in flight when the author leaves the pack completes on disk, and its read-back is dropped with the editor. The per-save callbacks reach the route through react-query's observer, and leaving the pack — or a reload landing first — detaches it: the write happens, and nothing here is moved onto it. Nothing is retained, because the file on disk is the truth and the next open re-reads it; what the page owes the author is to say so, and it does — This save finished, and this page has no account of it, with the read that would settle it beside the sentence. The same line stands where an answer does arrive and the buffer refuses it, which is a read-back for a file this buffer is no longer about.
A read or a write that lands over an edit is refused. Both take as long as they take, and what comes back is a whole file. The ticket a reload carries names the file, the incarnation of the buffer and the edit revision it was issued at — a number every commit, undo, discard and unwritten operand moves, because text typed into a field that is not JSON yet is work too (holding it counts once, where it changes; releasing it does not, because the write that follows a release is the edit) — so an edit made while the read was in flight makes the answer stale and the buffer declines it, keeping both the work and the undo entry that could take it back. The stale-file offer stays on screen, which is the honest state: the file did move, and this buffer has moved too; and the offer says what it would cost — Reload, losing these changes — for unwritten text exactly as it does for unsaved bytes.
A save carries the same identity, and landed refuses a read-back for
another file: a PUT in flight across a navigation used to make one pack's bytes
another pack's base. An edit made during a save is not refused — that is the
case the save's own text comparison answers, by keeping the work and leaving it
dirty against the revision that landed. (The counters are JavaScript numbers, so
the claim is bounded and stated as such: a ticket cannot collide within 2^53
edits of one page session.)
A refusal does not take the page away. Save writes bytes the runtime may
then refuse to serve, so get_pack failing is a state this editor can produce —
and the way out of it is the editor. The refusal is printed above the bytes
rather than in place of them: the file API returns whatever is on disk, the path
comes from the listing where get_pack cannot name it, and the JSON view stands
in both modes (read-only until the mode is Edit). Bytes shaped like nothing the
desk expects — rules pasted as an object — are read as what they are rather
than taking the route down with the unsaved buffer inside it.
The lock line. Where jpack.lock.json is in the file listing, one
sentence: the project keeps a reviewed set, and updating it is the project's
own step. Where it is not, silence — not "this project keeps no reviewed set",
which would be a claim about a file that may simply not have been read. No tool
reports lock state, the Evaluation payload carries no lock member, and packs lock is a CLI verb (ADR-0019), so the desk cannot know it and computes none of
it.
Keyboard. Mod+S saves. It is registered by the editor rather than through
the shell's installShortcuts — every shell chord is suppressed inside a text
field, which is exactly where save has to fire — and it is bound for as long as
edit mode is on screen rather than to a subtree: document.body is a reachable
resting place for focus, and it is where focus sits the moment edit mode opens,
because the Edit button unmounts itself. The chord is claimed there too, so the
browser's own "Save page as…" never opens over unsaved work. Alt+ArrowUp /
Alt+ArrowDown move a rule, from the card itself — which is the element the
move focuses, so the chord works twice in a row. Escape does not discard.
What phase 2 does not do: add or remove an entry in a list (an outcome, a
requirement, a source, a rule), write metadata.reviews, or edit a condition
node kind it has never seen. Each is a line in the JSON view.
No desk-computed verdict of any kind: no conformance claim, no lock state, no
health, no pass/fail chip. The runtime judges documents and this page quotes it.
Nothing about this pack's standing in the reviewed set appears here — not
whether it is in the set, not whether the entry is current, not whether an edit
would invalidate it. No tool reports any of that, so the desk cannot know it and
must not compute it. The one thing it does say is that the set exists, which
is the file listing's own answer and nobody's inference: where the listing
contains jpack.lock.json, one line says the project keeps a reviewed set and
that updating it is the project's own step.
The editor adds four more. No English paraphrase of a condition: "5000"
keeps its quotes and greater-than stays the document's word, in the reading
tree and in the builder alike, because "is greater than" is a second,
unversioned statement of the rule. No re-lock button — packs lock is a
CLI verb (ADR-0019) and the lock line says whose step it is. No generated row
expectation, and no claim to call packs suggest, which is CLI-only
(ADR-0024). And no form that refuses a value: what an author types is
written, and the runtime is what names it.
One optional file in the project root, read through the existing file API like any other project file — no new endpoint, no new proxy entry, and no Go change.
{
"deskConfigVersion": 1,
"organization": { "name": "Acme Co.", "mark": null },
"user": { "displayName": "local user" },
"appearance": { "theme": "system", "density": "comfortable" },
"panes": {
"left": { "mode": "expanded", "width": 248 },
"inspector": { "open": false, "width": 360 },
"console": { "open": false, "height": 240 }
},
"storage": {
"packs": {
"kind": "filesystem",
"dir": "packs",
"idBase": "https://example.invalid/judgment-packs/"
}
}
}Each pane dimension is bounded, and zero is refused like anything else: a
pane the file declares open at zero pixels is an open pane nobody can see
with a toggle that appears to do nothing, and an enormous one pushes the strip
out of a frame that does not scroll.
| key | minimum | maximum |
|---|---|---|
panes.left.width |
160 | 640 |
panes.inspector.width |
240 | 720 |
panes.console.height |
80 | 720 |
A value outside its range is refused by name like every other problem, and the
whole file with it. Beside that the sheet caps each pane against the viewport
it is actually in — neither side column past 40vw, so main keeps at least
20% with both open, and the console no further than leaves 120px of route
under the header and above the strip — because a size that is legal on a
monitor is still able to eat the frame on a phone.
A cap is not the same as a configured value. An accepted 720px Inspector
renders 440px at a 1100px window; the Inspector's drawer form is 320px unless
the file states a width. Nothing on a settings page reports that any more — the
Panes card is gone, and with it the reader that measured the frame by its ids —
but useInspectorSlot().size is still measured rather than configured, so a
route laying something out beside the pane is laying it out against the width
the pane actually has, updated as the window is dragged.
On a viewport too short for the reserve, the routes give way and the console
does not. An open console never renders below 80px — the smallest height the
schema accepts for one — even where the cap would otherwise reach zero, because
a pane of no height whose toggle still says it is open is a control that lies.
.desk-main scrolls; it is the one that can afford to lose the pixels. The
one thing that never gives way is the strip: where the viewport has less room
between the header and the strip than the floor asks for, the console takes all
of it and no more. At a 203px viewport an open console renders 80px with 47px
of route above it; at 109px it renders 33px, which is everything there is. A
collapsed console is still exactly 0px, and the strip is its collapsed face
as always.
Every key is optional except deskConfigVersion. organization.name is a
non-empty string or null; null is how a file asks for the desk's own name,
and "" is refused by name rather than rendering a blank brand. appearance is
decoded and validated, and it is the default rather than the answer: what
this desk paints is the viewer's own preference where they have one, set from
the user menu and held in their browser. theme is applied as above and
density is applied as above. organization.mark is null,
an inline <svg …> string, or a data: URI of at most 65,536 bytes of UTF-8
(measured with TextEncoder, not in UTF-16 code units — the two disagree by up
to three to one on a mark carrying non-ASCII; three and not four, because a
four-byte astral character costs two UTF-16 units and is therefore only 2:1,
while the three-byte character that costs one unit is the worst case), carried in the JSON
itself and encoded to a data: URI in the browser — never injected as
markup, and never a file path (the file API refuses non-UTF-8, so it could not
carry a raster image, and no endpoint is being added for a logo). Absent an
organization name, the header reads judgment‑pack desk — never an invented
company, and never a name taken from a token claim.
storage.packs is where a new pack goes, and it is the whole reason the
Create-pack dialog has no path field: the name gives the id, and the id gives
the file name inside dir. Every member is optional and takes the default
above. dir is project-relative and slash-separated, and is refused here for
the lexical shape the file API would refuse anyway — so Admin names the key that
is wrong rather than the dialog failing later on a path nobody chose to look at.
dir and idBase are refused more widely than that, for a control
character: every code point from U+0000 to U+001F and U+007F, at any
position, tested against the value as it was written rather than after any
trimming. That is the decoder's own rule, applied at Save — wider than the
chassis, which refuses U+0000 in a path outright — because a name carrying
one is a name this desk could never write, and new URL will not catch it on
the prefix either: it percent-encodes a U+0000, silently deletes a tab, and
takes a U+007F. The refusals also cover the directories the chassis excludes
from its endpoints altogether (.git, node_modules, dist, .venv, vendor, and a staging
name): "dir": "dist" is a plausible thing to type, and a configuration that
decodes clean while making every create fail is worse than one refused where it
was written. The list is mirrored from internal/desk/watch.go and held to it
by a test that reads that file.
idBase must parse as a URI, because a pack document's id member is
format: uri, and it is normalised at decode to end in / (or left alone
where it ends in #), so a pack's id is a plain concatenation everywhere it is
used and Admin shows the prefix that will actually be written.
kind admits only "filesystem" today, and its refusal names the other two by
name: "database" and "cloud storage" are not available yet. Admin names
them in the decoder's own words, as text rather than as disabled controls, and
nothing in the desk branches on this member — a pack is created by writing a file,
always. The create UI never asks which kind is configured.
A second optional file, this one on the machine rather than in the project:
~/.config/jpack-desk/desk.json # $XDG_CONFIG_HOME/jpack-desk/desk.json where that is set
It is read through its own read-only endpoint, GET /api/desk-config, under
the same token and origin guard as everything else — not through the file
API, and that is not an inconsistency. The chassis resolves every file-API path
through the project's pinned os.Root, which is exactly what stops it reading
anything outside the project; a file in ~/.config is therefore not
addressable there and never will be. An absent file is answered 200 with
present: false and the path it would be at, because "there is none, and it
would be here" is an answer rather than a failure to answer, and Admin needs
the path in order to tell you where to write one.
The same answer carries what this process was launched with, in both
states: project: {dir, file} — the project root the chassis resolved and
pinned, symlinks already followed, and the project's own jpack-desk.json
inside it whether or not one is there — and runtime: {bin}. Admin prints all
three and composes none of them: a page joining a directory to a file name
would be asserting a path on a filesystem it cannot see, and would be wrong the
first time a project was reached through a symlink. Neither is in the
configuration schema at any depth, for the reason neither ever was: the chassis
executes the binary it was given.
It takes every key the project file takes, plus the two that may only appear here:
{
"deskConfigVersion": 1,
"identity": { "provider": null },
"assistant": {
"endpoint": {
"url": "https://api.example.invalid/v1",
"kind": "openai-compatible",
"model": "a-model",
"tools": [
"get_schema", "list_examples", "get_example", "validate", "experimental_evaluate"
]
},
"engine": "vercel",
"thinking": "off"
}
}project.file says which project this machine's desk opens when
jpack-desk is launched with no directory argument. It is an absolute path
naming a jpack-desk.json, and the desk opens the directory that file is in;
null or absent means it names none. It is desk-level only — which project a
machine opens by default is not a fact about any one project, and committing one
would push one operator's filesystem onto every clone.
{
"deskConfigVersion": 1,
"project": { "file": "/home/someone/a-project/jpack-desk.json" }
}Writing it is an operator's, and Admin may only nominate the project it is
running in. The two are different authorities and the difference is the whole
of this member's safety. The desk pins one project root at startup and serves
the file API through it; project.file chooses the root of the next launch.
So page code that could write any path could hand its successor a root outside
the authority the page itself had: {"project":{"file":"/jpack-desk.json"}}
would have pinned / on the next argument-less start, and the file API would
then have served the host. That is the key-retarget class, in a member instead
of a credential.
PUT /api/desk-config therefore accepts exactly two values for it from the
page: this project's own file, spelled as the chassis reports it in the same
answer, which nominates the project the desk is already serving and grants
nothing it does not already have; and null, which withdraws a default and so
takes authority away. Anything else is 422 desk-config-refused naming
project.file, with nothing written. Both are stated: {"project":{}} is
refused too, because an omission is not a withdrawal — a client that meant
nothing by leaving the member out would otherwise clear whatever an operator
had written. And the accepted value is compared exactly, with no
normalisation: a padded or otherwise re-spelled path is a second rule about
which spellings mean the one value a page may write, and it is the re-spelling
that would get stored.
Admin's Project card is that rule as a shape rather than as a validation on top of one: one button, Use this project as the default (or Clear the default where it already is), and no field for a path. A different default is set by editing the desk-level file yourself, which is custody-validated and is the operator's own authority.
A configured default is validated on the host that is about to act on it,
and refuses the launch where it fails. The decoder's rule is lexical, because it
is shared with a browser that has no filesystem to ask — a leading separator, or
a drive letter with one — and a spelling that is absolute on one platform is a
relative path on another: C:\p\jpack-desk.json is one path component to Go
on Unix, so filepath.Dir answers . and the desk would open whatever
directory it happened to be launched from. So the launch checks the value again,
against this machine. The whole refusal matrix, in the order it is applied:
the configured project.file |
refused because |
|---|---|
| is not absolute on this host | a path written for another platform is a relative one here, and would open whatever directory the desk was launched from |
| is directly in the filesystem root | this desk will not serve a project rooted there — checked before anything is asked of the filesystem, so it is refused for where it is rather than for not existing |
| does not resolve | a default that is not there is not a project; EvalSymlinks is what says so |
| does not resolve to a regular file | a directory, a socket or a device is not a configuration file |
| resolves to some other name | a link cannot point the name this desk reads at something else |
| resolves into the filesystem root | the root check again, on what the link actually reached |
A default that fails any of those refuses the launch by name — never a silent fall back — because somebody who configured a default and got some other project would have no way to see that the member they wrote was ignored.
And what is validated is what is served. The directory that passes is then
pinned as a held descriptor, and the identities of the directory and of the
jpack-desk.json that chose it are compared against that descriptor before
anything is served: validating a pathname and then resolving it again to open
is a window in which a rename can substitute another tree for the one that was
checked. It is the pattern the credential directory is already held to.
Two consumers cannot go through os.Root at all, and they are why that
sentence needs a second paragraph rather than a footnote. A subprocess's working
directory is set by the kernel with chdir, and an inotify watch is taken by
path; neither accepts a *os.Root. Both used to be given the resolved
spelling, so a rename-and-replace at that spelling left every new jpack mcp
judging one tree while the file API edited another.
- On Linux, both follow the descriptor.
/proc/self/fd/Non this desk's own pinned directory resolves to the open file description rather than to a name, so it means that directory however it is called afterwards, or whether it is called anything at all. The watcher is given that path directly. - The runtime gets there through a shell trampoline, so that nothing rests
on an
os/execinternal. The descriptor is passed inExtraFiles, which is documented to make it descriptor 3 in the child, and the child runssh -c 'cd /proc/self/fd/3/. && exec 3<&- && exec "$0" "$@"' <jpack> mcp: thecdresolves through the inherited open description at a number this desk knows rather than one it inferred,exec 3<&-closes the descriptor before the runtime is executed — so the runtime inherits a working directory and not a capability — and the finalexecleaves no extra process in the tree. The one cost is a dependency on a POSIXsh, and a host without one is refused by name at the spawn rather than failing somewhere a reader cannot see. - On every other host it is check-then-use, and the desk says so rather than
implying otherwise. There is no portable way to hand a subprocess a working
directory by descriptor, so the pathname is re-verified by identity
immediately before each spawn and a moved project refuses the relay rather
than starting a runtime somewhere else. The window between that check and the
child's
chdiris not closed by it. Only Linux is race-free here, and Linux is the only host on which this desk keeps a key at all.
The paths GET /api/desk-config reports are informational. project.dir,
project.file and runtime.bin are the spellings captured at launch and are
never re-read, so a rename cannot make the desk report something new — and the
nomination rule compares against that same captured spelling, so a pathname that
has stopped naming the pinned root cannot authorise anything either. What a page
could persist through it is at most the string this desk already told it. And a
principal who can rename the project directory out from under a running desk
already holds more than the page does: what this section promises is that the
desk's own halves do not come apart, not that such a principal is harmless.
Precedence: project file → desk-level file → built-in default, and for the
three pane flags one layer in front of all three — this browser's record of
what the viewer chose, for the panes they chose it for. The project's own file
wins because it is the more specific statement: the desk-level file is this
machine's answer for every project it opens, and a project that says something
different is saying it about itself. Two sections do not take part in that
order at all, because they exist in only one of the files: identity and
assistant are refused by name in a project file, so their only source is the
desk-level one. There are no shell flags on the command line.
Each file is refused on its own. A bad key in one does not refuse the
other, neither is repaired by the other being fine, and Admin reports each as
its own with its own path. The status strip's configuration refused cue
fires for either, because the argument the cue exists for — that a mistyped
key must not look exactly like having written no file — does not care which
file carried the typo.
Any problem refuses the whole file, and every refusal names its key.
Partial acceptance would let a typo'd key sit there doing nothing while its
siblings applied, which reads as a setting that does not work rather than a
spelling that is wrong. An unknown key — at the top level or nested — is refused
by name. identity in the project file is refused by name with its own
reason: a project is a shared checkout, and committing an issuer would push one
operator's directory onto every clone. There is no clientSecret key in the
schema at all, so one pasted in is refused by name rather than silently
persisted. A missing file is the defaults, with no banner and no error — a
refused file is the defaults too, and the two are told apart on the status
strip, which reads configuration refused — see Admin and links to the page
that names every problem. Without that cue a mistyped key looked exactly like
having written no file at all from every surface except /admin.
A third state sits beside those two and gets its own cue: a file that could
not be read. A 404 is an absent file and stays silent; a 413, a permission
refusal or a non-UTF-8 body is a configuration that exists and was not
honoured; and a socket that never answered establishes only that absence was
not established — it is not evidence that the file is there. The strip reads
configuration could not be read — see Admin for all of them, and Admin
separates the two provenances: where the chassis answered, its status is named
and its reason is quoted as the chassis'; where nothing answered, the reason
shown is the browser's own and is said to be. Reporting any of this as the
defaults, silently, is a desk describing itself as unconfigured when it is
merely unread.
On a phone the strip paints a short spelling of that cue — config refused,
config unread — because the full sentence is wider than a 320px strip has
left beside the console button, and a link that neither shrinks nor wraps
painted straight across it. The link's accessible name is the full sentence at
every width.
A settings page carries only settings. That sentence is the whole of this shape, and three things follow from it. Every setting is editable in place. Status is not a setting and lives elsewhere. And a per-viewer preference is not an administrator's setting either.
Under the heading is a status line, not a card. One line — two on a narrow
shell — carrying the connection and the binary the chassis was launched with,
both of them the connection's or the chassis' own answer and neither composed on
the page. The connection's verdict is its status, never the runtime it last
met: server is retained across a reconnect, so every surface that read
"connected" off its presence said so while the socket was down and the banner
said the connection was lost. One producer — connectionSays — answers for the
status line, Help & About and the status strip; the runtime is named only where
the connection is actually up. It replaced a Runtime card whose four slots held nothing anybody
could edit; the card's own content is in Help & About, which is where a
reader goes to ask what they are connected to.
Neither configuration file is on that line, and the omission is the point: each group below names its own, and naming a file twice is what the grouping exists to stop. The line is what the desk is running, which is the one thing no card is about.
/admin is an overview: two groups, one per file, each stating its file's
path and read status once, and under each a row per section carrying that
section's title, a one-line summary drawn from the decoded configuration, and
its own status only where it differs from its group's — the comparison being by
what the status says rather than by which state it is, because two refusals
naming two keys are not one status.
| Group | Header states | Rows |
|---|---|---|
| This project | jpack-desk.json's path, its read status, and the default-project nomination |
Organization, Storage |
| This desk | the desk-level file's path and its read status | Assistant, Identity provider |
A row is a link to /admin#<id> and the fragment is the state: the section
it names opens beside the list — a 14rem column of rows on a wide shell, the
list stacked above the section below the Inspector's own breakpoint with every
row the reader is not in collapsed to its title — aria-current marks the open
row, Escape or the All settings link at the top of the open section clears
the fragment, and a fragment naming no section is the overview. Those addresses
are the ones the rail's menu and the user menu have linked to since these were
headings; nothing else changed about them, and they scroll as they did.
The summaries come from the decoded configuration and are composed nowhere
else: the organization's name or none, filesystem · <dir>, none or
<kind> · <model> · thinking <tier>, None or the issuer. A row that fell back
to a name this page made up — the project it happens to be open on, the desk's
own brand — would be a value nobody wrote that a reader cannot tell from one
that is in the file.
The page was a stack and is now a list, and that is the whole of the change. Four sections each with a heading, a status, a disclosure and a form, one under another, meant a reader who came to change one thing scrolled past three they did not — and the page grew by a screen with every setting the desk gains. What a settings page is instead is an overview of what each setting currently is, and the one section that was asked for.
A section's Status is its read state and its own write. The group's status
is the file's read state, so a section that only ever reported its read state
showed nothing at all while it was writing, while a refusal stood against it, or
while the file had moved underneath it — three things the section knows and the
group does not. The form publishes what its write is doing up to the card it is
inside, on the pattern the Inspector slot already uses: the form owns the draft
and the save, the save node is handed to the card as a prop, and neither can
reach into the other. A write in the air says so first, then a file that moved,
then any other refusal in whoever's words refused it; a save that landed
publishes nothing, because the file was read back and the read state is the
truth again. A member the other file supplied is not one the
group's header speaks for: it is given no group, and states its own Location and
Status exactly as it did before there were groups. The heading levels follow the
document — a group is an h2 and its cards are h3 — so the outline is the one
on the screen.
The file itself is in the right pane. The bytes are context rather than a
setting — nobody edits a file's text here — so Admin claims the Inspector slot
the way the pack routes do, through useInspectorPortal, and releases it when
the route leaves, which is what puts the next route's panel back. The panel names
the file and the member (jpack-desk.json › storage, desk.json › identity, or
the project file alone on the overview), states the Location the chassis
reported and the Digest the read carried, says the file's Status in the
same closed vocabulary the sections use, and quotes the member's own bytes —
never a re-serialisation of the decode, and where those bytes cannot be
established, one line saying so rather than the decoded value in their place.
Below 1100px the pane is the existing drawer, opened from the existing control;
nothing here opens it on the reader's behalf, because a restored layout is not a
page's to override.
The forms stay in the main column, and that is a measurement rather than a
preference: the pane is 360px, a label column alone is 9rem, and below 1100px
the pane is a drawer over the very page it would be editing. Each open section
is what it was — its fields, and a Save where it has a write path and
nothing where it does not. A Location is still the path from the chassis,
never composed on the page and never stood in for; where the chassis has not
answered it says the desk has not said, because the relative name this page
reads the file by is a file-API address rather than an established location on a
filesystem. A Status is still one line from a closed set: read, not present — defaults in use, refused: <key>: <the decoder's own reason>, not read — <who said so>: <their reason>.
Nothing on the page is a box. Hierarchy is type, spacing and hairlines: one
rule between groups, one above each row, one type scale, and every title in
sentence case — a frame is drawn only around an object, which on Admin is the
code block in the right pane and an alert. One primary button per
section, and it is the Save that writes the form; the default-project
nomination sits on the row it changes as a secondary, and a Reload inside a
stale panel keeps its own because an alert has one action. Three tests hold it
— the sheets carry no background and no four-sided border on a container,
styles.css resets fieldset, and every button under the article comes out
carrying Button's class.
Panes is gone from Admin, and the reset went to the shell. Its three pane
dimensions were a settings page editing the frame it is drawn in; its reset
cleared a record in this browser's own storage, which is a per-viewer
convenience rather than a deployment's configuration. Reset panes is now in
the user menu — see Shell — and the panes member is still in the
schema, still decoded, still validated and still applied. The write path left
with the form: CARD_POINTERS names the 2 members a card may write —
/organization and /storage — and a test drives every Save on the page and
compares what came off the wire to it. Appearance left the same way and for its
own reason; see Shell. That count is read out of the source by a test,
so this sentence cannot drift from the list the way it did when the list was
three. It is a declaration and not a type constraint, deliberately:
narrowing the save hook to it would make routing a Save through /panes a
compile error, and the only mutation left would break the expectation the test
compares against — a comparison against itself, which proves nothing about the
write path. The guarantee is behavioural, and the rows that hold it point a real
Admin Save at /panes and at /appearance.
storage.packs.kind is a value, not a control. The union has one member, so
a Select there would look like a choice, read like one to every enumeration of
what a reader can change, and offer none. The card states what the file says,
with the decoder's own sentence about the two kinds that are not available yet
under it; the refusal of any other kind is unchanged. One code comment says what
brings the control back, and it is a second kind existing.
There is no narration. A test sweeps every text node the page writes and fails on one over 140 characters, exempting quoted material — a path, a decoder's own refusal, a member of the file as it is written. The sweep runs over the states the configuration cannot express, too: a connection opening, being retried and failed; a tool listing that did not answer; a save in the air with a refusal beside it; a stale write with its digests disclosed. And over the panes' reset in the user menu, which is a portal outside the page, for each of its four answers — the two standing sentences in that menu are over the bound on purpose and the sweep is asserted against exactly them, so a third one fails. The standing disclaimer, the deployment-state list, the warning notes and every paste block are gone: a real problem is a section's Status line, and the Copy buttons went with the blocks because the Location line says where the file is and the pane shows what is in it.
A refused file's bytes are never rendered. The decoder refuses a whole file
for one credential-shaped member, and the point of refusing it is that the desk
will not act on it — so a pane that quoted it anyway would put the member the
refusal is about, and on the overview the whole document around it, into the DOM
of the surface reporting the refusal. On a refusal, and on a read that produced
no file, the pane shows its Status line and no bytes at all. The rule has one
spelling: showsContent is exported from the card and imported by the pane,
because two spellings of one rule are invisible to a harness that breaks one of
them — which is exactly how a second, redundant gate inside the old disclosure
survived as a mutation nothing could catch.
Two things write the desk-level file, and each writes one member of it. The
project group's header nominates this project as the default (or withdraws one),
the Assistant form writes assistant, and neither sends the other's — a member
absent from the request is carried across untouched. That header's other slots
are about the project's file, so the one line under its control names the file
it actually writes, from the chassis' own answer.
Storage and Organization each Save one member of jpack-desk.json through
the file API, by splicing that member's own bytes
and decoding the whole file before any of it is sent — so every other member
keeps its bytes, order and whitespace, and a value this desk would then refuse
to read never reaches the disk. The write states the digest its read carried
and asks for no override: a file that moved underneath the card is a 409
with nothing written, and Reload reads it again while keeping every value that
was typed. The bytes and that digest are one revision, held rather than
read live — the chassis invalidates every query when it sees this file change,
and a card that followed would rebase onto bytes nobody saw and overwrite them
with no refusal at all — so it moves on an arrival while nothing is unsaved, on
Reload, and on a save that landed, and nowhere else. A card writes only the
fields that differ from what the file supplies, so a member nobody touched stays
undeclared — and where a value comes from the desk-level file,
which this page does not write, the card says so and offers no Save.
Two things are written, and each is exactly as wide as its reason. The key
is one, below. The other is the desk-level file, over PUT /api/desk-config,
under the same token and origin guard as everything else. Its body is
{assistant?, project?, ifMatch}: a member that is present is replaced and a
member that is absent is untouched, which is what lets two Admin cards write
two members of one file without either sending the other's. A body naming
neither is a 400 — a conditional commit that would change nothing is a request
with no meaning, and answering it 200 would report a write that did not
happen. ifMatch is required and never defaulted: it is a pointer, so an
omitted member and the empty sentinel are two different requests. They were one,
and where desk.json is absent the actual digest is the empty string too — so a
body carrying no ifMatch compared equal and created the file, which is a write
with no precondition from the route whose whole argument is that the commit is
conditional. "" is a claim about the disk and has to be made. It exists because choosing a model and a thinking tier is something an
author does while working, and the alternative is telling them to edit a file
in ~/.config by hand between attempts. Four things bound it:
- The request names no file. There is no path in the body: the chassis writes the one file on that machine, through the same pinned custody directory the key is written through — validated once at startup, and every operation through the descriptor rather than a pathname.
- It is a conditional commit. The page sends
ifMatch, the digest of the bytes it last read —GET /api/desk-confignow answers that digest beside the content, and the empty string means "there is no file". A file that moved underneath the page is409 desk-config-changed, with both digests, and nothing is written. There is nooverride, unlike the file API: this file names the endpoint a credential is presented to, and "write anyway" is not a choice a page should be able to make about it. - The chassis composes the bytes and decodes them before any of them reach
the disk. Not the object the page sent — the file this desk would store —
through the whole-file decoder the browser shares. A key-shaped member, an
unknown kind, a missing
tools: each is422 desk-config-refusedcarrying the decoder's own problems key by key, with nothing written. So a page cannot store a configuration Admin would then report as refused, and the credential scan applies to a write exactly as it applies to a file somebody typed. - Every other member survives.
identity,deskConfigVersionand anything else present are carried across by their own bytes, in their own order — copied out of the file verbatim, whitespace included, and never re-serialised or reflowed, so1e2does not silently become100and a member's place and shape in a file somebody wrote stay theirs. Only the members this route was asked about —assistant,project, or both — are rendered, and each is replaced where the file already has it and appended where it does not. A file with a duplicate top-level member is refused rather than composed over (422, naming the member):encoding/jsonkeeps the last value and a reader in another language may keep the first, so a rewrite would silently choose one. The file is written owner-only (0600) by staging,fsync, and rename inside the same directory; a0644file is still read, because a checkout or an editor leaves one, but this desk publishes its own writes at the mode it chose. - The bytes are checked as bytes. The request body must be UTF-8 and
exactly one JSON object with nothing behind it, and the composed file must be
UTF-8 and within the same bound every read applies — each refused before
anything is staged. Go's JSON decoder replaces an invalid byte inside a
string while decoding and
json.RawMessagekeeps the original, so without the first of those a0xffin a model name decoded clean and would have been written into a file every later read then refuses. A file this route writes and this desk cannot read is worse than a write refused. - The digest is compared twice: once against the bytes this transaction read, and again after the new bytes are staged and immediately before the rename that publishes them. The second is what covers an ordinary editor, which takes none of this desk's locks — without it a write that landed between the two was overwritten and the route reported success. The residual is the rename itself: a writer whose own write lands between that second check and the rename still loses, and no compare-and-swap on a POSIX rename exists to close it.
The answer carries the new digest and the decoded slot — read back off the disk rather than echoed, defaults applied — so the page can verify what landed and has the digest its next write needs.
Admin › Assistant is what calls it. The section is a form — the wire protocol, the endpoint, the five tool grants, the model, the engine and the tier — and Save is this route. Three things bound what the page may get wrong, and each is held by a test that reads the request on the wire: the digest it sends is the one its read carried, and a read that produced none disables Save and says why; the object is composed by naming its members, never by spreading the form's draft, so a member nobody declared cannot reach this file; and a refusal is rendered in the decoder's own sentences, against the field each problem's key path names. A 409 keeps every value that was typed and offers Reload, which reads the file again so the next Save states a digest that is true — there is no overwrite, because this route offers none.
The key is the other, on Admin › Assistant, and the exception is exactly as
wide as its reason. A key must never be pasted into a
project file — a project is a shared checkout, and a key committed to one is a
key published to every clone — so it cannot go through the file API, which
writes only inside the project, and it is not in the configuration schema at
any depth. It gets its own endpoint instead. Everything else in the desk-level
file — identity and every section the project file also takes — stays a value
you write in a file yourself; the two exceptions above are a key, which cannot
live in a file at all, and the assistant object, which a chassis route
rewrites in place under the four bounds listed there.
That is a claim about Admin, and it is deliberately not the broader one it
used to make. jpack-desk.json is an ordinary project file — the desk reads it
through the same GET /api/file every other file goes through, and now writes
one member of it through the same PUT /api/file — so the generic Author
editor lists it and can write it exactly like any project file. Saying "nothing
is ever PUT to a configuration file" was a sentence this repository's own file
API refutes. What is true is narrower and is the whole of it: a configuration
surface writes one member of one file per Save, states the digest it read, and
decodes the bytes before it sends them — the chassis composing and decoding
for the desk-level file, and the page splicing and decoding for the project's
own. The editor that will write either of them whole is the one that treats a
file as bytes and forms no opinion about what they mean. runtime.jpackBin and
project.dir are not in the schema at all: the chassis executes the binary it
was given, so a config-supplied path would be a way to run code on this machine
by editing a file.
Identity is display, never a gate. identity.provider is one nullable
field — null, or an object. There is no kind, no vendor string and no third
shape, and that absence is what stops an issuer someone else operates from
acquiring anything an issuer you run yourself lacks. Configuring one changes
what the header shows and nothing about who may reach the desk.
A key is refused by name wherever it is written. There is no
clientSecret and no apiKey in this schema at any depth, and a member whose
name looks like a credential — key, secret, token, password,
credential, bearer, authorization, in any casing — refuses the whole file
with a sentence saying that keys are never stored in configuration and where
the one key this desk holds goes instead. That is deliberately not the "unknown
key" refusal every other misspelling gets: whoever pasted a key into a
configuration file has made a mistake about where keys live, and a refusal
that only says the spelling is wrong invites them to go looking for the right
spelling.
assistant.endpoint is one nullable field — null, or an object — on the same
pattern as identity.provider and for the same reason. Beside it are two
settings that say how an assistant runs rather than whether there is one:
assistant.engine and assistant.thinking. There are three deployment
states and they are not three shapes:
| None | The default. endpoint is null, no key is asked for, and nothing renders an assistant. The runtime's authoring prompts still run in any chat client you already use. |
| Bring your own | An OpenAI-compatible, Anthropic or Gemini endpoint you already have. The desk stores the endpoint, keeps the key on this machine, and has no relationship with whoever issued it. |
| Supplied | An endpoint someone else operates for you. Configured in exactly the four fields above — an ordinary endpoint, the same code path, nothing it can do that yours cannot. |
There is no vendor, no operator, no mode and no third shape, because the
last two rows are the same object with a different URL in it. The one member
that does branch is kind, and it names the endpoint's wire protocol
rather than who runs it: each protocol puts the credential in a different
header and the call on a different path, so no single request could satisfy
them. There are three:
kind |
credential header | what the probe asks for |
|---|---|---|
openai-compatible |
Authorization: Bearer <key> |
GET <base>/models |
anthropic |
x-api-key: <key> |
POST <base>/v1/messages, one output token |
gemini |
x-goog-api-key: <key> |
GET <base>/v1beta/models?pageSize=1 |
gemini is Google's native Gemini API and deliberately not that vendor's
OpenAI-compatibility layer, because three things exist only on the native wire
and the assistant needs all three: thought parts, thought signatures carried
back across tool turns, and an explicit thinking budget. The base is whatever
the endpoint documents — https://generativelanguage.googleapis.com for the
service Google runs — and its reference is
the models list and
generating content. That API also
accepts its key as a ?key= query parameter, and this desk never uses it:
a credential in a URL is a credential in a log, a Referer and a proxy's
access record, which is the same rule that refuses userinfo in a configured
URL. Every row of that table is a header.
Nothing in the desk reads the host, compares it to a list, or behaves
differently for one endpoint than another — which an enforcement test holds in
place by enumerating every host comparison in the source and requiring each to
be a loopback name. A proxy or a self-hosted endpoint speaking any of those
three wires is that kind, at its own URL.
url must be an https: URL, or an http: one on localhost or
127.0.0.1 — a rule about transport, because a bearer credential sent in clear
text over a network is a credential given away, and one about transport only.
It may not carry a user, a password or a fragment: a key is never written
into configuration, and that includes into a URL. It may carry a query
string, because some gateways route on one — and that query string is never
logged. The query is held to a rule of its own, in both decoders, because
PUT /api/desk-config makes the file page-writable and the configured query is
the one part of a relayed request that then travels upstream byte for byte on
every later call. Three refusals, each named against assistant.endpoint.url:
a credential-shaped name by the same reading a member name gets (key,
apiKey, api_key, access_token, secret, password, … and auth, which
that reading does not otherwise catch); a name the relay reserves (alt,
which the relay may add itself, and pageToken, which would page a listing
this desk documents as first-page-only) — reserved on every kind, because a
per-kind rule would make a URL legal until somebody changed kind beside it;
and a semicolon anywhere in it, which is the relay's own rule verbatim. The
reserved names are compared without regard to case — ?ALT=sse would
otherwise be accepted and the relay would add its own pair beside it, which is
two copies of one name to an upstream that folds case — and every pair's name
and value must decode to valid UTF-8, because %FF is one byte and no error
to Go's decoder and an exception to the browser's, and a configuration the
browser refuses must not be one this desk sends a key on. An ordinary
?route=eu&api-version=2024-10-21 is accepted and unchanged, and so is any
percent escape both sides read the same way. An escaped path is carried through exactly as configured: %2F stays
one segment, because re-encoding it into a separator would send the credential
to a different resource than the one written down.
It is the base the endpoint documents for its own protocol: for
openai-compatible the base carrying /models and /chat/completions, which
usually ends in /v1; for anthropic the base carrying /v1/messages; for
gemini the base carrying /v1beta/models, which is the origin alone. The
desk appends the path its protocol prescribes and never guesses a version
segment.
tools is required, is validated against a closed list — get_schema,
list_examples, get_example, validate, experimental_evaluate — and
refuses anything else by name. It is required rather than defaulted because a
defaulted tool list is a capability granted by a file that never mentioned it;
[] is accepted and means an assistant that may call nothing. Every one of the
five is a read: four questions put to the runtime and a rehearsal, which
consults no reviewed set and decides no outcome. list_examples is on the list
because the runtime's own author_pack prompt tells the model to call it — a
list without it grants a capability the prompt then asks for and cannot have.
The list is mirrored in
internal/desk/assistant.go and held to it by a test that reads that file,
because both sides refuse by it.
engine names the loop that runs the assistant, and thinking the depth it
runs the model's reasoning at:
{ "assistant": { "endpoint": { }, "engine": "vercel", "thinking": "off" } }Both are optional, both default — vercel and off — and both are allowed
with endpoint: null, because they describe how an assistant would run and a
desk that has configured none may still have an opinion about that. Each is one
string from a closed list, on the identity slot's precedent: no discriminator,
no vendor, and a value outside the list refused by name (assistant.engine,
assistant.thinking) rather than ignored — a setting that appears to grant
something is a grant to whoever wrote it.
engine admits vercel, the default, and builtin, a fallback that adds
nothing to what this desk already ships. The slot exists so that the desk's
promises — propose-only, rehearsal-only, the tool allow-list, key custody — are
held below whatever runs the loop, and so that an engine ships only once it
has passed the desk's own conformance session
(ADR-0001). thinking
admits off, on and ultra; the two states it cannot express — a model that
always thinks, and an endpoint that offers no thinking at all — are the desk's
to report when it meets them rather than settings anyone selects.
Both are acted on now: the page loads the named engine's chunk and runs it —
and this build certifies both ids, so the tab's status line names the engine
the file asked for and nothing is ever substituted — and a tier other than off
puts real parameters on every request and runs the refutation pass below.
What the slot has shipped so far, in ADR-0001's own order: the slot and the
key custody; the Assistant tab, with propose and accept-into-draft; the engine
slot itself, with vercel and builtin both certified against the conformance
session; Describe it in the Create dialog, which runs the same session with
no draft and hands what comes back to Create rather than to a diff; the thinking
tier with its refutation pass; the native Gemini wire on both engines —
function declarations, thought summaries streamed into the tab as reasoning,
thought signatures carried back verbatim across tool turns, and the tier mapped
to Gemini's own thinking configuration — certified by the same session that
certifies the other two families; and the Admin form below, which chooses
the endpoint, the model and the tier and writes them.
Admin › Assistant is a form, and the paste block that stood here is gone — it existed because the page could not write the file, and a second way to do one thing where the second is hand-editing a file this desk also rewrites is worse than either alone.
| the form asks for | and it is |
|---|---|
| Wire protocol | one of the three, by name. Choosing one offers the base that protocol's own reference documents — https://api.openai.com/v1, https://api.anthropic.com, https://generativelanguage.googleapis.com — as a default in an editable field, replaced by typing over it. Nothing reads those back, compares an endpoint to them, or treats an endpoint at one of them differently: the enforcement guard admits the three literals only as values of one table in one module, and the sharper guard beside it — every host comparison in the page's source is a loopback name — is untouched. |
| Endpoint | the base, held to the transport rule and the configured-query rule by the decoder's own function, so a URL those rules refuse is shown refused in the sentence the file's reader would write and is not sent. |
| Tools it may call | the five, as five checkboxes. All on for a desk that has configured nothing, because [] is a real choice — an assistant that may call nothing — and a form opening on it would have a blank field making it. |
| Model | typed, with List models beside it. |
| Engine | vercel or builtin, with the two things the SDK-backed one cannot do named beside it: it shows the model a tool schema narrowed where the SDK declares one narrower (the tab says narrowed when it does, and on the Gemini wire list_examples arrives with no parameters at all), and it cannot carry an empty signed thought part back across a tool turn on that wire. Both are measured in this repository's own suite, and both are why an author might choose builtin for a Gemini endpoint. |
| Thinking | off, on or ultra, with what that tier puts on this protocol's wire read off thinking.ts itself rather than restated beside it — so the line changes when the table does. On the Gemini rows it also says that the two budgets are this desk's choice inside a documented field. |
Remove endpoint writes the other state the slot has: endpoint: null,
through the same conditional commit and with the same digest. Until it existed
the form could not reach it — clearing the boxes sends an object the decoder
refuses — so a desk that had configured an endpoint could get back to None
only through the generic file editor, while this page described None as one of
three deployment states. It confirms in one line first, and the line is about
the key rather than the endpoint: the key stays on this machine, still entered
for the endpoint being removed, and this desk will not present it anywhere.
engine and thinking survive, because they say how an assistant would run
and not whether there is one.
Save writes the assistant object over PUT /api/desk-config and nothing
else in the file moves. The status line on the Assistant tab and Describe
it name the new model and tier at once, and from the write's own answer:
it carries the slot the chassis read back off the disk and the digest the next
write states, both of which are set into the cached configuration before the
re-read is asked for. Leaving them to that second read meant a write which
landed while the read hung left every one of those surfaces describing the
endpoint that had just been replaced, under a form saying "Saved". The re-read
still happens, for the parts a write cannot speak about — the project's own
file, every other section's badge — and the key is re-read with it, because a
write can move the binding in either direction. A read that answers and
refuses is newer information about the same file than the write's answer, and
this desk says nothing about a file it could not read rather than describing
one from memory: Admin reports it and Save is refused until it can be read.
And "nothing" is its own state rather than the defaults. The slot every
consumer reads has a third value — unavailable — because falling through to
the built-in defaults told every surface that no assistant is configured,
which is an absence this page had not established about a file it could not
open. All three surfaces read it: the tab and Describe it say what happened
and offer no control, and Admin — where a reader goes to find out why —
says the configuration could not be read, claims no absence, and shows its
fields disabled, because they are the built-in defaults there and typing into
them would compose a write over a file nobody has seen. Nothing latches: a
later read that works puts the configured form back. The key row is unaffected
throughout, because it reads the chassis' own configuredOrigin,
configuredKind and bound and never the page's copy of the configuration —
a row that read the page first said "save an endpoint first" while a perfectly
good key read beside it named the endpoint.
The test drives the real provider over a stubbed file rather than a fixture,
because a fixture would hold the mechanism constant and prove nothing about
it.
List models reads the endpoint's own listing through the relay by naming a
path suffix — models, v1/models, v1beta/models — and fills a picker. Two
things gate it and one of them is new: the stored key must be bound to the
endpoint that is saved, because the relay refuses a credential entered for
another destination before opening a socket; and the form on screen must be
that endpoint. The family and the suffix used to come off the editable draft
while the gate came off the file, so choosing Gemini without saving sent
v1beta/models to a still-saved OpenAI-compatible endpoint — a request the page
composed for one destination and the desk sent to another. The endpoint it asks
about is captured at the click, and the rows are dropped from state the
moment the form says a different host or protocol: a picker left standing after
that is a list of models from somewhere else, and rows merely hidden came
back when the URL was typed away and back again — an arbitrarily stale listing
with no request behind it. The field beside it never goes away: the listing is
first-page-only, an endpoint may refuse to list at all, and a gateway may route
on a name of its own — a picker that was the only way to choose would make
every one of those unconfigurable. What is saved is the id and never the
label, which differ on two of the three protocols — and an id is an option
only if the configuration decoder would take it, asked of that decoder rather
than re-stated here: a copy of the rule is how a whitespace-only id came to be
offered, saved cleanly into the field, and produced a 422 on the next Save.
Typing the same value still gets the decoder's own sentence against the field,
because the chassis is what decides. A refusal is its status
and one word from the probe's own closed vocabulary, and the body is not read;
an answer that is not JSON gets a fixed sentence, because JSON.parse quotes
the text it failed on and that text is the body.
The key row says which endpoint the key is for, in five states: not read
yet; no endpoint, where the entry field is not offered at all because
storing a key requires one to bind it to; none stored, with the field
labelled for the host it would be entered for; stored and bound, with
Replace and Remove and no masked box standing beside a working key; and
stored for somewhere else, naming both hosts, because a reader has to be
able to see which of the two moved. A write answering keyRebindRequired
moves the row at that instant rather than waiting for the key read.
The Check reachability button still reports the desk's own probe. The key
and the endpoint stay separate: removing the endpoint does not remove the key
from this machine, so the page says none — no endpoint configured and lets
the key line say whether one is still kept here.
What the assistant is, and is not, in the sentence the page carries: it proposes edits to the draft; you accept them; the runtime checks them. It never saves a file and never decides an outcome.
On a pack's route — reading or ?edit — the right pane carries two tabs,
Inspector and Assistant. The Assistant tab renders only where an
endpoint is configured and a key is stored on this machine; otherwise it says
in one line where that is configured, because a control that would refuse is
worse than a sentence that explains.
Type what the pack should decide and press Run. The desk fetches the
runtime's own author_pack prompt over prompts/get with what you typed as its
policy argument, hands it to the engine, and shows what happens: each tool
call by name, each answer's isError and byte count, each guardrail in the
desk's warning colour, and at the end the proposal — the diff below, the
document as read-only JSON, the unknowns the assistant declared, and beneath
them the runtime's own checks quoted whole: the validate report and the
rehearsal evaluation, as the runtime wrote them. A summary of a verdict is a
second verdict, so there is none.
Escape stops a session; leaving the route stops it too. A session has two
phases — the desk reading the runtime's prompt, then the engine running — and
Stop ends either. Every session emits exactly one end, whichever way it
finishes. Pressing Run again with the text unchanged is a second run, because a
model is not a pure function. Nothing about a session is persisted — coming back
is a new one.
The session is given the draft it is editing. The first user message carries
the runtime's prompt and then the bytes in the editor, verbatim and fenced,
under one fixed sentence: This is the draft being edited; propose the whole
document. On the reading route the saved document plays the same part. So what
comes back is a whole document, which is what the diff needs, and the tab says
whether it is an update to the draft it was given or a new document — from
what this desk sent, never from a kind the model wrote.
A run captures its baseline, and the proposal belongs to it. What the run records where it starts is the whole of what the proposal is about: which file, which incarnation of the buffer, and the exact bytes it sent. The diff is computed against those bytes rather than against the live buffer, so what is on screen is what Accept would apply; and Accept is offered only while the page still holds them. An author who kept typing while the model was thinking is told so — The draft changed since this proposal was made — run again to propose against it — rather than having their sentences replaced by a document the model never saw. Undo back to those bytes puts the proposal back on offer.
Fix runs the runtime's fix_pack prompt beside Run, with the diagnostics the
check on this page already produced as its diagnostics argument — the bytes
of the diagnostics member, cut out of the runtime's own answer by the same
scanner the editor splices with. Not a message list, not a count, not a severity
filter, and not a re-serialization of a parse: a re-serialization is this desk's
whitespace and this desk's escaping on a refusal it did not write. Same engine,
same gate, same proposal path, and the draft in the first message as above. It is
offered only where the check reports something to fix and the runtime advertises
the prompt, and the tab says which prompt is running.
The assistant opens its own MCP connection, and that costs one more
jpack mcp process while the tab is running. The reason is the ToolGate below:
the desk's one client serves the page's own calls — list_packs, get_pack,
Try it — every one of which is outside the assistant's allow-list, so a gate on
that transport would break the desk. A second socket, gated at the wire, is what
makes "no page code path can bypass it" a structural claim rather than a habit.
The connection lives exactly as long as the session and is closed on stop, on
unmount and on navigation.
The proposal is drawn as what accepting it would do to the draft, member by
member: each top-level member added, removed or changed, with the draft's text
and the proposal's beside it, and each array member compared element by
element — matched by id where the elements carry one, by position where they
do not. Members that did not move collapse to one line with a count.
The comparison is computed here and never quoted. The contract's proposal event carries a document and its unknowns and nothing else; there is no account of its own work for this desk to repeat, and a model's account of what it changed is not evidence about a document.
The proposal is canonicalized once, where the event arrives, in the run hook
— JSON.parse(JSON.stringify(x)) — and frozen all the way down; the diff,
the rendering, the writability check and the writer read that one snapshot and
never round-trip it again. The reasoning is the ToolGate's: an engine may put any
value on document, and a getter or a toJSON can answer one thing while the
diff is looking, another while the pane renders and a third while the writer
serializes — three readings are three documents, and the one a person accepted
would be none of them. The freeze closes the same gap one layer out: the snapshot
travels to the pane on the run's event list, and anything holding that event
could otherwise reach into it between the memoised diff and the accept. A
document that cannot be read as JSON data at all — a cycle, a throwing getter, a
value that is not an object — becomes an error on the stream and no proposal:
there is nothing to show and nothing to write. That there is exactly one
canonicalization site is swept for in assistant/enforcement.test.ts, because a
second round trip added back "for safety" is a second reading.
The caption names the draft it actually compared: on a page whose bytes have moved since the run began it says the draft this proposal was given, because "the draft on this page" is by then a different document.
Each row is identified by its kind and its pointer, not by the pointer alone. A proposal that replaces one rule with a rule of another id produces two rows about position 0 — the rule that left, on the draft's pointer, and the one that arrived, on the proposal's — and one key for both is a warning from React and two rows a reader cannot tell apart.
An id that names two elements of one array matches nothing, and a keyed element
is never paired positionally: a new rule at index 0 must not be reported as an
edit of whichever rule happened to sit there. Where the draft cannot be read at
all — no bytes, bytes that are not JSON, or bytes this desk and JSON.parse
disagree about (a duplicated member) — the diff says so and the whole proposal
is new.
Accept into draft applies it through the same span-preserving writer a form
edit uses (packs/edit/writes.ts), in exactly one write on the editing
session:
- one undo entry, so Undo takes the whole accept back in one step;
- every byte the proposal did not move survives — the author's own indentation, spacing and member order included — because each change is a splice at a pointer and an untouched member is not written at all. ADR-0019 makes a human read the diff of a save, and an accept that re-serialized the file would hand that human every line of it;
- nothing is saved. The on-idle check runs again over the new bytes, the toolbar's dirty count moves, the navigation guard covers an accepted-but- unsaved draft exactly as it covers typing, and Save is still yours to press;
- a draft with nothing to splice into — bytes that do not scan, or a duplicated member — is replaced whole, which is the one case where there are no spans to preserve.
Accept is enabled only on ?edit, with a proposal, once the run has ended, and
only while the page still holds the baseline the run captured. It is disabled
with the reason in its title while a run is in flight, while a save or a reload
is in flight, once accepted, once rejected, and where the draft has moved; on the
reading route it is not drawn at all and one line stands in its place — Open Edit
to accept. "Accepted" is a comparison, not a memory: the pane holds the bytes
the accept produced and says the proposal is in the draft exactly while the draft
is those bytes, so Undo puts it back on offer instead of leaving a control
disabled under a sentence that has stopped being true.
The save gate is asked again at the instant of the click: the route claims a
save synchronously and react-query reports it a render later, so a control that
consulted only the rendered value could write into a buffer whose save is already
in the air. A reload is the other direction — the buffer refuses a read that
lands over an edit made while it was in flight (BufferIdentity.revision), and
the pane declines to start one it knows is about to be argued with.
Reject drops the proposal and keeps the event stream. There is no partial
accept: per-member checkboxes are a later refinement, and this chunk deliberately
does not ship half of one.
assistant.thinking is off, on or ultra. Set to anything but off it
makes whichever engine runs ask the model to think at that depth, stream the
model's reasoning into the tab as it arrives, carry thinking blocks back
verbatim across every tool turn, and run the refutation pass below before a
proposal is shown.
The tier maps to provider parameters in one desk-owned table, per endpoint
family, and the engine receives the normalized result — it never chooses a
parameter of its own. The table is web/src/assistant/thinking.ts and it is the
whole of it:
| family | tier | what goes on the wire |
|---|---|---|
openai-compatible |
on |
reasoning_effort: "high" |
openai-compatible |
ultra |
reasoning_effort: "xhigh" |
anthropic |
on |
thinking: {"type":"adaptive"} with output_config: {"effort":"high"} |
anthropic |
ultra |
the same, with "effort":"xhigh" |
anthropic, after a 400 |
on / ultra |
thinking: {"type":"enabled","budget_tokens":8000} / 16000 |
gemini |
on |
generationConfig.thinkingConfig: {"includeThoughts":true,"thinkingBudget":8192} |
gemini |
ultra |
the same, with "thinkingBudget":24576 |
gemini, after a 400 |
on / ultra |
{"includeThoughts":true,"thinkingLevel":"medium"} / "high" |
gemini |
off |
{"thinkingBudget":0}, or {"thinkingLevel":"minimal"} after a 400 |
| the other two | off |
nothing at all |
off is expressed by omission on two families, and that is not a shortcut:
Anthropic rejects {"type":"disabled"} on the models that always think, and
several OpenAI-compatible endpoints answer 400 to reasoning_effort: "none", so
send nothing is the only spelling of off those two accept.
On the native Gemini wire the opposite is true, and off is a member. A model
that carries a thinkingConfig at all reasons by default there, so sending
nothing asks for thinking by accident — which makes thinkingBudget: 0 the only
honest spelling of off on that family. The asymmetry is stated rather than
smoothed over, because it is the reason the table is per family at all, and
because it gives this model always thinks its first real subject: see the
state table below.
The budget numbers on the Gemini rows are this desk's choice inside a
documented field, not a range quoted from anywhere.
generationConfig.thinkingConfig.thinkingBudget is an integer token allowance
in the API reference; the allowed range is per model and the reference states no
range that holds across the family, so 8192 and 24576 are simply an ordinary
working depth and a deep one. A model whose range excludes one of them answers
400 naming the member, and the desk falls back to the level spelling and then
degrades — the same path a model with no budget field at all takes.
The depth on the Anthropic family lives in a sibling member on current
models and inside the thinking member on 4.5-era ones, and on the Gemini family
it is a token budget on one model generation and a thinkingLevel out of
minimal | low | medium | high on the next. So each of those two families has
two spellings, the desk tries one and, on a 400 that names a member it actually
sent, falls back once to the other. A fallback says nothing in the tab: the
desk asked in the other spelling and the session still thinks.
Five states, three of them selectable. The last two are the desk's to report when it meets them, on every engine, and are deliberately not values anybody can put in a file:
| state | how the desk reaches it |
|---|---|
off |
the file said so, or said nothing |
on / ultra |
the file said so and the endpoint did it |
| this model always thinks — from absence | the tier is off, the desk asked for no thinking, and reasoning came back anyway on two consecutive answering turns |
| this model always thinks — from a refusal | the tier is off and the endpoint answered 400 to the member that turns thinking off, at every spelling the desk knows. Gemini only, because it is the only family where off is a member at all |
| unavailable for this endpoint | a 400 naming a member the desk sent, at every spelling it knows; or two consecutive answering turns carried no reasoning at all; or a thinking signature came back truncated |
The refusal road to always thinks runs the dialect fallback first, and that
is not a formality: a 400 naming thinkingBudget is equally this model cannot
be turned off and this model spells it thinkingLevel, and only trying the
other spelling tells them apart. Reached that way the member comes off — there is
nothing left to ask. Reached from absence nothing is withdrawn: the file said
off, nothing refused it, and every later request goes on saying so.
The degrade happens once, visibly, and the session completes. The refused member is never sent again — the requests carrying a tier parameter are a prefix of the run, at most one per spelling — one line appears in the tab, and the scenario runs to its proposal. Whether a 400 is about the tier is decided against a closed list of the providers' own documented refusals plus the member names this desk actually sent; an endpoint's body is matched against that list and never quoted past it, so a refusal about a document cannot be read as a refusal of the parameter.
The tab's status line names the tier the file asked for and the state the
session reached: builtin · a-model · thinking on · unavailable for this endpoint. Each reasoning passage is one line in the stream, collapsed with its
character count, and opens on a click. Reasoning text never reaches the
runtime — it is for the person reading the tab, and the runtime is asked about
documents.
Thinking blocks come back complete and unmodified, which is both signing
wires' own rule — Anthropic's and Google's — and the reason the built-in engine
echoes the model's turn exactly as it received it rather than rebuilding it: a
redacted_thinking block survives because nothing filters by block type, a
Gemini thoughtSignature survives because the part it sits on is the part that
goes back, and an Anthropic signature split across two signature_delta events
is concatenated rather than the last fragment kept.
On the Gemini wire that means a signed part is never joined to anything, and
that is Google's own rule rather than a precaution: a signed part is not merged
with an unsigned one and two signed parts are not combined, because a signature
certifies the exact bytes it came with. The pieces of an unsigned summary are
still joined — that is what a streamed continuation is — and the joining a
reader wants happens separately, in the passage the tab shows, so the wire
keeps the part count, the order, the text and the signatures exactly as they
arrived. A signature rides on one of two parts here: a thought summary that
still has its text, or the first functionCall part of a turn, which is
where function calling puts it and where later parallel calls do not. The
vercel engine cannot make its SDK reassemble one (vercel/ai#19663, still
present at ai@7.0.93 and measured by this repository's own suite), so the desk
detects the truncation instead: fragments are ledgered as they arrive — under
anthropic.signature on one wire and google.thoughtSignature on the other,
from one table — each outgoing body is compared with them, a block whose
signature came back as a fragment is removed rather than sent, and the session
degrades once with the reason. That test is written to go red if the SDK is ever
fixed in silence.
The scripted endpoint refuses a continuation that dropped, truncated or misplaced a signed part, so every Gemini thinking leg gates on the replay rather than merely reporting it — at all three placements.
tools[].functionDeclarations[].parameters on the native Gemini wire is an
OpenAPI subset, not JSON Schema, and an endpoint answers 400 to keywords an
ordinary schema carries. Every one of the runtime's own five declares
additionalProperties: false, so on this family the choice is between removing
something and not running at all.
The ruling: a closed, documented removal list, on this family only. The
keywords are $schema, $id, additionalProperties, const, examples and
patternProperties — web/src/assistant/geminiSchema.ts, applied at every
depth, and applied to nothing else. A property whose name happens to be one of
them is left alone, because under properties the keys are the author's words
rather than JSON Schema's. Nothing is added, nothing is re-typed, and no value
is changed.
What that costs, and it is stated rather than glossed: on this family the
model is shown the runtime's contract minus exactly those keywords. Without
additionalProperties: false a member nobody declared looks acceptable; without
const a fixed value looks free. It changes nothing about what is enforced —
the ToolGate rewrites and refuses on the wire, and the runtime validates every
call it receives — so the worst case is a model proposing a call the runtime
then refuses, which is a turn spent rather than a guarantee lost.
oneOf is deliberately not on the list. Some models refuse it and some do
not, which makes it exactly the case the list must not grow to cover: a union
removed reads as anything at all, so a contract that said "one of these three"
would be shown as unconstrained. It travels — and a keyword the list does not
name is reported and never stripped: a 400 whose message names a keyword this
desk actually sent becomes an error event naming it, with the closed list
quoted, rather than the desk widening its idea of the runtime's contract on
being refused. Both engines carry that rule and both have a conformance leg for
it.
The two engines do not show the model the same contract, and the desk says
which. @ai-sdk/google does not send the schema it is given: it rebuilds it
through its own converter, which copies an allow-list of keywords and drops the
rest. So on the vercel engine pattern, maximum, uniqueItems, the
conditionals and the annotations below never reach the model either, and a tool
whose schema declares an object with no properties is declared with no
parameters member at all. That is the SDK's behaviour and not this desk's,
and it is below the one seam this adapter has — but it is exactly the
cross-engine contradiction the closed list exists to prevent, so it is declared
rather than discovered:
| engine | what the model is not shown, on gemini |
|---|---|
builtin |
$schema, $id, additionalProperties, const, examples, patternProperties — the desk's list, and nothing else |
vercel |
all of those, plus $comment, $defs, $ref, contains, default, dependentRequired, deprecated, else, exclusiveMaximum, exclusiveMinimum, if, maxLength, maximum, minimum, multipleOf, not, nullable, pattern, prefixItems, propertyNames, readOnly, then, title, uniqueItems, writeOnly — and a tool whose schema declares an object with no properties is declared with no parameters at all |
| either, on the other two families | nothing: those wires take JSON Schema as written |
Three things hold that table honest, and one boundary is stated rather than glossed.
It is derived, not copied — over the recorded runtime's own vocabulary. A
conformance leg sends a fixture whose keyword union is pinned, by a test that
computes both unions, to every keyword the recorded runtime 0.19.0 emits in
its tool and pack schemas — the five inputSchemas it served on tools/list,
and the pack schema its own get_schema answered, which is in the conformance
fixture with its bytes, its sha256 and its provenance beside it. What the leg
reads back is what the installed provider actually did with each of them, and it
is asserted equal to this table. An SDK that starts or stops dropping one of
those keywords is a red test.
The rest of the vercel row is outside that lock, and that is the boundary:
maximum, multipleOf, contains and the other keywords the recorded runtime
does not emit were measured the same way against a wider synthetic schema, and
they are true — but a runtime that never emits them gives this desk no way to
notice if the provider stopped dropping one. The first version of this section
said "every keyword the runtime could emit", which was a claim about a schema
somebody made up rather than about the runtime's own.
The wire is asserted whole: every leg requires the schema that arrived to be deep-equal to what this table says arrives — not that three keywords are present and six absent, which is what the first version checked and is a claim about a handful of words.
And the author is told: a run opens with one line per tool that lost
something beyond the desk's own list, naming the tool and the keywords. Over
the runtime's own five that is nothing at all on builtin — its removals are
the ruling — and exactly one line on vercel, for the tool whose schema that
provider drops whole. A notice about additionalProperties would be the desk
warning about the rule it wrote down.
Two things the SDK does are rewrites rather than removals: it inlines a
$ref (dropping the $defs it resolved, so the constraint survives and the two
keywords do not) and it infers a type for a bare enum. The first is declared
above, because from a keyword's point of view those two names do not reach the
model; the deep-equality assertion therefore runs over the runtime's own five,
which carry no reference, and over the vocabulary fixture only for the engine
that rewrites nothing.
One more thing that provider does, and it is not about schemas. It surfaces
a thought part only when its text is non-empty, so an empty signed thought —
which the wire emits when a summary was not streamed — never reaches the desk:
it cannot be ledgered, cannot be replayed, and cannot be counted as reasoning.
Against an endpoint that emits one and enforces the wire's rule that signed
parts come back, a vercel session is refused and ends with the endpoint's
status; this model always thinks cannot be inferred from one there either. The
built-in engine has neither limit, because it reads the wire itself. Both halves
are conformance legs, written to go red the day the provider starts carrying
them.
The Gemini API reference these rules were written against, read on
2026-09-06:
generating content (the
:generateContent and :streamGenerateContent?alt=sse methods, contents[] of
{role, parts[]}, the text / functionCall / functionResponse part
variants, thought and thoughtSignature on a part, systemInstruction,
generationConfig.thinkingConfig with thinkingBudget, includeThoughts and
thinkingLevel, candidates[].content.parts[], finishReason,
usageMetadata.thoughtsTokenCount, and the {error: {code, message, status}}
envelope); thinking and its
signatures section
(the thinking_level values minimal, low, medium, high, each model
advertising a subset, and the rule that a client "MUST always resend all
thought blocks exactly as they were received from the model");
function calling ("only
a subset of the OpenAPI schema is supported"); and
the models list for the
probe. What that page does not state, and this desk therefore does not
claim, is a thinkingBudget range that holds across the family — see the note
under the tier table.
With the tier on, the assistant does not show you a proposal it has not tried to
break. After the main loop has produced a document and before the proposal
event is emitted, the engine runs a second, adversarial session over it — a
second loop in builtin, a second streamText in vercel — instructed by the
runtime's own test_pack prompt, the document fenced beneath it, and one fixed
sentence from this desk saying to try to refute it with the runtime's tools and
to state no verdict of its own.
It runs inside the same ToolGate, on the same callTool, over the same five
tools, under the same run gate: its experimental_evaluate is rewritten to a
rehearsal before the frame leaves the page exactly as the main loop's is, it can
no more reach write_file, and pressing Stop during the pass ends it where it
would have ended the loop above. The desk hands an engine one capability, so
there is no ungated route for a critic to be given.
Three rules, all of them desk code:
- the verdict is computed from the runtime's own results, never from the
model's prose. A check is a
validateor a rehearsalexperimental_evaluatethe critic actually caused, read from the answers that came back through the gate; the proposal is refuted exactly where one of them did not come back as the runtime's own word for "this went through". A critic whose sentence says REFUTATION: this pack is broken over avalidatethe runtime calledvalidproduces not refuted, and one whose sentence says none found over aninvalidproduces refuted — both directions are conformance legs; - a non-empty list of checks before "not refuted" is rendered. A critic that talked and asked the runtime nothing produces the critic ran no runtime check, and the proposal is shown without a refutation line rather than with a clean bill of health nobody measured;
- the checks are quoted and the prose is labelled. The report shows each
check as the runtime's own
status, and the critic's own words beneath, as the model's words.
The settled status is a table per tool and not the single word valid: a
rehearsal evaluation answers "status": "evaluated", so one word over both
tools would have reported every session ever run as refuted. A status is the
only thing that makes a check: an answer the runtime returned with isError
and no status in it is the runtime declining to answer, not declining the
document, and it is no check at all — and a call the desk's own gate refused
never reached the runtime, so it is a guardrail line and can never become one.
A verdict is a thing the runtime said.
A refuted proposal is still a proposal: the document is shown, the diff is drawn, and Accept is offered. Refutation is information, not failure, and the desk's one clean-run selector treats it as such.
The ruling this carries, which the maintainer has not made. On a degraded
endpoint — one with no thinking at all — the pass still runs. Its value is
the runtime's checks over the proposed document, not the model's thinking, and a
validate costs one call; the tab's line says the endpoint has no thinking, and
the checks appear as they always do. The alternative — skip the pass wherever
the endpoint degraded — is REFUTE_ON_A_DEGRADED_ENDPOINT in
web/src/assistant/thinking.ts set to false, one boolean and nothing else,
with a conformance leg that follows it either way. ADR-0001 lists this among the
questions the bake-off could not close, and it is open until the maintainer
rules.
The conformance session runs both engines over all three wire formats, each
answered as a stream and as one whole object, at tier off, on and ultra,
against an endpoint with thinking, one with none, one that takes only the other
Anthropic spelling, one that takes only the other Gemini spelling, one that
splits its signatures, one that reasons whatever it is asked, one that refuses
to be turned off, and one that refuses a schema keyword. What that measures
is what this desk puts on the wire and what it does with what comes back: the
tier parameter on every request including the critic's, the signatures carried
back byte-equal and well formed, the schema the model was actually shown, the
degrade happening once, the refused member never re-sent, the critic's evaluate
arriving at the runtime rehearsed, and the verdict following the runtime rather
than the prose. The runtime's answers in it are a real jpack mcp's, recorded.
What is modelled is the endpoint. The scripted model reproduces the
documented wire shapes of all three protocols; it is not evidence about how any
vendor's endpoint actually responds, and the reasoning text and signatures in it
are deterministic strings rather than a model's. On the Gemini family that
extends to two things worth naming: which models accept which thinking
spelling, and what a real thinkingBudget range is, are modelled by the
fixture's own refusals rather than measured — the desk's behaviour when it meets
each is what the legs establish. Two smaller things are modelled
too, and are named here rather than left to be discovered: the critic's copy of
the runtime's test_pack prompt is a stand-in string in CI (the recorded
runtime carries tools/list and tools/call and no prompts/get), and that
prompt is read without its pack argument on the page as well, because the
document the critic will be given does not exist when a session's prompts are
read — the document is handed to the critic verbatim instead.
web/src/assistant/toolGate.ts wraps the assistant transport's send, and
every outbound tools/call frame passes through it:
- a name outside the session's allow-list is refused — the frame never
leaves the page, the caller's promise rejects with a
GateViolationnaming the tool, and aguardrailline appears in the tab. The engine turns the rejection into a result the model is told about, because a call dropped in silence is a turn the model spends re-asking for it; experimental_evaluateis rewritten to carryrehearsal: truewhatever the arguments said, with aguardrailline saying what was there — therehearsalmember's previous value, and never the pack text;- everything else passes untouched, including
initialize,tools/listandprompts/get. The gate is about what the assistant may do.
The allow-list is assistant.endpoint.tools intersected with the five, and
the order is allow-list first: an experimental_evaluate the file never granted
is refused rather than politely corrected on its way out.
The frame is canonicalized to bytes before anything is decided, and what
leaves is the canonical frame. That is the shape of the whole check, and it is
the lesson the chassis' relay learned over four review rounds: a classification
made about a mutable object is a classification the object can change out from
under you. A method getter can answer undefined while the gate is looking
and tools/call while JSON.stringify is; a response-shaped object with a
toJSON can serialize as a request; an enumerable toJSON on a call's
arguments is invoked at serialization and drops whatever the gate had just
written. So the object is serialized once, the bytes are parsed back to plain
data — which has no getters, no toJSON, no functions, no symbol keys and no
prototype left — and every rule is applied to that. The transport is handed the
plain data and never the caller's object.
What is checked, in order:
- the frame survives a JSON round trip at all (a cycle does not, and is a refusal rather than an exception thrown at the socket) and is a plain object;
- every frame is checked against the SDK's own message schema
(
JSONRPCMessageSchema) — a request, a notification, a response or an error, each whole. That replaced a hand-written set of shape rules, and the reason is that the hand-written ones were looser than the sentence describing them:{"id": null, "result": 7}, anerrorthat is a string, and a fractional id all satisfied "an id and exactly one ofresultanderror", and not one of them is a JSON-RPC message. It is the same schema the client validates inbound frames against, so what this desk will send is what its own SDK will accept. A JSON-RPC batch is refused here too: an array is not a message, and "anything that is nottools/callis traffic I have no opinion about" let a batch carrying an allowed call beside awrite_fileout whole; - a method that is not
tools/callbut is a spelling of it to some other reader —Tools/Call,tools/call— is refused, on the chassis' own reasoning about its query: a frame two readers disagree about is one this desk will not send; - for
tools/call, the tool is on the session's allow-list — checked against the name the frame serializes into, not the one it claims — andexperimental_evaluategets an ownrehearsal: truewritten last onto the canonical arguments.
One whole conversation through the gate — the handshake, the notification after
it, a listing, a prompt, a tool call, and the client's automatic answer to the
server's own ping — is in the suite, because tightening frame rules is the
kind of change that breaks a connection while every refusal test stays green.
They are structural guards against mistakes, SDK-internal paths and
uncertified adapters, and certification is what the desk trusts. They are not
a sandbox. An engine runs in the page's own realm, and same-realm code that
patches a prototype or replaces a global is outside what any page-side guard can
contain — a module that redefines Response.prototype.body, or captures fetch
before the desk does, is not a threat the ToolGate or the model capability claim
to hold. What they do hold is that an engine which behaves itself cannot reach a
tool nobody granted, cannot send an unrehearsed evaluation, and is never handed
this chassis' credential; and that an engine which does not behave itself fails
the conformance session rather than shipping.
The engine is handed a callTool bound through this gate and nothing else —
no client, no transport, no fetch, and no URL — and the session's member
set is asserted whole in assistant/enforcement.test.ts, because the guarantee
is that there is nothing else there.
ADR-0001's contract sketch writes model: { family, baseUrl, model }, with
baseUrl "the chassis relay". This desk deviates, and the reason is that the
relay authenticates with this chassis' session token in the query: a
baseUrl an engine can read is this desk's own credential in the engine's
hands, and an adapter holding it can open /ws?token=… itself with
globalThis.WebSocket and drive a third MCP connection the ToolGate is not on.
Nothing in the contract would have been violated. The guarantee would simply
have been gone.
So model is { family, model, call }. call(suffix, request) is bound by the
desk: it builds the address itself, admits only a path suffix that passes the
relay's own segment rule, carries only the headers on the chassis' outbound
allow-list, and captures fetch when the session is bound rather than reading
it at call time. The engine chooses a suffix — chat/completions,
v1/messages — and nothing else.
The answer is a facade this desk builds, not the one fetch produced: a
browser Response carries the requested URL on .url, which is the relay
address with the token in it, so returning it handed the engine everything it
needed to derive /ws?token=…. What comes back is a constructed Response —
empty url, the status and reason phrase, a filtered header copy, and a body
stream of this desk's own, piped through an identity transform: a Response
built from a ReadableStream keeps that very object, so a stream somebody
decorated was reachable as facade.body.leak.
The failure path goes the same way: a browser's TypeError for a failed fetch
quotes the URL, so the error is replaced with a fixed sentence. An abort
reaches the engine as a fresh AbortError with a fixed sentence and no cause
— the classification travels because a loop has to tell "stopped" from "failed",
and nothing else does, because a rejection named AbortError can carry the URL
in its own message and again in its cause.
The suffix must be a primitive string before anything else happens. The
TypeScript signature said string and the type is not what runs: a string-like
object can answer an innocuous length and split() while the validator is
looking and a different toString() when the URL is built, which would turn
this capability into an authenticated POST to another same-origin chassis route.
That capture of fetch is what makes the guarantee structural instead of
inspected: the conformance session replaces fetch, WebSocket,
XMLHttpRequest and EventSource with throwing sentinels from before the
engine's chunk is imported until after everything it scheduled has run, on
every leg. The sentinels record as well as throwing, because a reach from inside
a setTimeout throws into nobody's catch.
The barrier at the end tracks handles rather than waiting. It was a fixed
200ms, and a fixed wait is a delay an engine can out-wait — 201ms, an interval,
a timer that schedules another timer. Every scheduling primitive a page has is
wrapped for the sealed window, each with its canceller: setTimeout,
setInterval, queueMicrotask, setImmediate, requestAnimationFrame and
requestIdleCallback, with clearTimeout, clearInterval, clearImmediate,
cancelAnimationFrame and cancelIdleCallback beside them. Each call is
recorded and scheduled for real, so an engine that legitimately needs a timer
still makes progress, and whatever has not fired when the run ends is fired,
repeatedly, until nothing is left. What remains when the bound is reached is a
failure, not a pass. A handle the engine itself cancelled is never fired on
its behalf — that is what the cancellers are wrapped for — because running one
would report a reach the engine had already decided not to make; a schedule that
returns no handle at all, as queueMicrotask does, is filed under none, so a
clearTimeout(undefined) cannot cancel it by accident.
requestIdleCallback is installed where the environment has none, and
removed again afterwards. jsdom does not have it, so an engine that wrote
globalThis.requestIdleCallback?.(() => fetch(…)) did nothing at all during
certification and reached the network in Chrome, after the seal would have
lifted: a primitive the browser has and the harness does not is a hole in a
guard whose whole claim is that everything an engine scheduled has already run.
The drain runs what an engine left behind in the order a browser would have. One entry at a time, soonest first, with its clock advanced to that entry's due time — never before it. Firing every pending handle at once ran them in the order they were scheduled rather than the order they were due, so a sixty-second idle callback ran before the one-second timer that was going to cancel it, and, being run, was told a deadline it had not reached had been reached.
Realistic means the deadline, not only the callback. The shim takes the
IdleRequestOptions it is given, hands the callback a budget that is positive
at its first read and decreasing from the moment it starts — the browser's own
rule — and honours the timeout it was asked for: the callback is not run
before that deadline, and didTimeout is computed when the callback runs, from
whether the deadline was actually reached. Firing every positive timeout after a millisecond and calling that a timeout
credited an engine with work it would have cancelled long first; a callback with
no timeout is offered an idle slot on the next turn, and reports false. Held
under controlled time, deadline by deadline. A shim that answered
zero and false to everything would run the ordinary idle pattern
(if (deadline.timeRemaining() > 0) work()), watch it decline to do anything,
and certify a clean leg while a browser gave it a real budget and let it reach.
The hostile fixture writes both shapes, one guarded on the budget and one on
didTimeout.
A certified engine leaves no live interval when its iterator ends. An
interval is never run by the drain at all: running a few ticks and calling it
drained is a bound an engine can hide a reach behind, and an interval nobody
clears is one no bounded drain can exhaust. It is reported by name and the leg
fails for it. Promise reactions are flushed rather than tracked — the drain
turns the microtask queue over between rounds, which is how a .then chain an
engine left behind is caught while the seal is still up — and what that does not
cover is a reaction chained off something that resolves after the drain: a
fetch to a real host, a socket, a MessageChannel. That bound is real and is
stated here rather than papered over.
The cleanup is nested so that the drain, the timer wrappers, the sentinels and
both connection closes all come off whatever throws: a deferred callback that
threw used to leave a leg's globals installed for every leg after it. The desk's capability still works; an engine that reaches for a
global fails the leg by name (K1a). The string-enumeration guard that used to
forbid a handful of spellings under engines/ is gone: it said of itself that a
novel spelling walks past it, and new globalThis["Web"+"Socket"] is that
spelling. When the vercel adapter lands, this capability is what is passed as
the provider's fetch option, so the shape survives the next chunk.
web/src/assistant/engine.ts is ADR-0001's contract. assistant/engines/ is
the registry: one lazily loaded chunk per certified engine, so a release
carries every certified chunk and a session downloads one. There is one
table: the certified list is derived from the loaders rather than written
beside them, and the two sets are asserted equal at the type level, so an id
cannot become loadable without being put in front of the conformance session and
an id the decoder declares cannot be left without an adapter. Both directions are
compile errors.
A run is a gate, and it is closed before it is aborted. openRun gives each
run a gate the consumer's return(), its throw(), the session's own signal and
the run's natural end all close — once, synchronously — and closing it marks it
closed first, then aborts, then releases. The order is the point: a signal
says "stop soon", and a closed gate says "nothing more from this run reaches
anybody". When the thing an engine was awaiting wins its race with the abort by
a microtask, the loop resumes holding a value; aborting cannot stop it delivering
that, and a flag the delivery path reads can. A session already aborted when
start is called closes the gate before a provider is built or a request is
made: no event, no model work, nothing.
withAbort(() => work(), signal) bounds each await on the world, and takes a
thunk so a closed run starts no work at all — evaluating the argument is the
request. It is used at each model request, each session.callTool, each read of
the AI SDK's stream and of its result promise, and at the relay's and the
providers' request and body reads; sseEvents takes the run's signal so a
stalled stream cannot outlive its run. Two waits are not wrapped, and are
bounded differently: the channel take and the channel's wake wait are released
by abandon(), which the gate calls as it closes — a wrapped race there would be
a second way to end the same wait. Aborting the awaited thing alone was never
enough: a model request honours a signal and a tools/call over a socket does
not, and a cleanup queued behind an await on something it was meant to end waits
for ever.
The runtime is reached through a guard that reads the run's signal before it
dispatches, so no tools/call arrives after the consumer has left. A cancelled
run says nothing at all, on either engine, not even end: the terminal event
belongs to a run that finished, and the page's own terminal accounting is the run
hook's.
An engine's outer shape is a hand-written iterator, not an async generator.
A generator serves next(), return() and throw() from one queue, so a
return() arriving while a next() is pending is not run until that next()
settles — and a run waiting on a model request that ends only when it is aborted
could never be stopped by the consumer that owned it, because the abort was
inside the return() queued behind the very next() it would have released.
Both promises hung for ever, on both engines, measured. So return() cancels
first, synchronously, before it awaits anything: the pending next() settles
{ done: true }, and only then does return(). Each engine holds an abort of
its own, chained to the session's, and gives its model requests that one.
One channel, one consumer, and no terminal event out of a finally. The
adapter's events reach the contract through an ordered channel whose push
resolves only once the consumer has taken the event — which is what puts the
desk's own guardrail line between the tool_call that provoked it and the
tool_result that followed. A second reader is refused by name before it takes
anything, because the in-flight slot is a single slot and two readers would
overwrite each other's. And end travels that channel like every other event
rather than being yielded from a finally: a finally that yields makes a
consumer's first return() resolve { value, done: false } with the generator
still suspended, and for await's own closing discards the value, so the
terminal event is never delivered at all. A consumer that stops listening is owed
no terminal event; what it is owed is a closed iterator.
assistant/engines/contract.ts holds what belongs to the contract rather than to
either loop — the twenty-turn bound, the desk's own sentence to the model, the
one reading of a proposal (exactly one fenced block, never the prose beside it),
the rule that an answer is read by what came back rather than by what was
asked for, and the served schema an engine may show the model. Both engines
import it, because a rule written twice is a rule two readers can disagree about.
A tool the runtime served without an inputSchema is refused, on either
engine: the session ends with one error naming the tool, before a request is
made. K2 says the model is shown the contract the runtime enforces or it is
shown nothing, and a permissive {"type":"object"} written by the desk is this
desk telling the model that anything is acceptable for a tool whose real contract
it does not know. The five a real jpack mcp serves all carry one.
| engine | what runs the loop | added download (gzip) | what it guards | what it does not do yet |
|---|---|---|---|---|
vercel (default) |
Vercel AI SDK v7 — ai 7.0.93, @ai-sdk/openai-compatible 3.0.44, @ai-sdk/anthropic 4.0.49, all pinned exactly |
96.0 KiB for the lazy chunk, plus 2.0 KiB shared with the other engine and 1.2 KiB the main chunk grows by | the rehearsal hook named as a key of the SDK's own options type, so an upstream rename is a compile error rather than a guard that fails open; the desk's gate handed the call as the model made it; a placeholder origin the adapter never resolves, and a query refused at both layers; the SDK's own retries off; the truncated thinking signature it carries back (vercel/ai#19663), detected and degraded rather than sent |
reassemble a split signature: it detects the truncation instead, and the session degrades once with the reason |
builtin |
the bake-off's control loop, by hand — two SSE parsers, both wire formats | 3.2 KiB, and no new dependency at all | the same promises, held one level below it in the ToolGate and the model capability, which is where they are held for every engine; and the assistant turn echoed as received, so thinking blocks, redacted blocks and split signatures survive by construction | nothing the default does — it is the fallback that adds nothing to the supply chain |
builtin is the port of the bake-off's control loop — a hand-written turn loop
over an explicit messages array, both wire formats, no new dependency —
restricted to the contract: it takes the runtime's prompt and the runtime's own
tool definitions, speaks to the chassis relay with no credential of its own,
reads a stream or a whole answer by what came back rather than by what it asked
for, ends on one fenced JSON block, bounds itself at twenty model turns, and
emits end exactly once.
vercel is the same contract on streamText. Its adapter is a translation and
never a second opinion, and four things about the SDK are the adapter's business
rather than the desk's:
-
the rehearsal hook is
experimental_.streamText's options carry a rest parameter, so a misspelledexperimental_refineToolInputis accepted in silence and the rewrite is simply never applied — measured at zero compile errors on a rename. It is named once, as a key of the SDK's own options type, so the dayaidrops the option the adapter stops compiling. -
the desk's gate is handed the call the model made. The hook rewrites what the SDK carries — which is what the model is shown on its next turn — and the ToolGate rewrites what leaves the page. A gate handed a call somebody already fixed reports nothing, and the guardrail line in the tab is the only place a person learns that the rehearsal flag was forced.
-
the SDK reads the answer it asked for.
streamTextpicks its response handler when it picks to stream, so an endpoint that answers whole to a request that asked to stream yields no events at all:AI_InvalidResponseDataErroron one path andAI_NoOutputGeneratedErroron the other, both on the shipped release. The adapter re-frames a whole answer into the events the same protocol defines. That is the only wire knowledge in it, and two of the four conformance legs exercise it. -
the SDK retries a 409 twice, with a backoff — and 409 is what the desk's own relay answers when no key is stored on this machine. Three requests and six seconds for a refusal a person has to go and fix. Retries are off, so both engines make one request per turn.
-
the refusal path leaks a rejection nobody can catch, and one of them is still there.
streamText's result exposes its output as promise-valued members, and reading one mints a promise that rejects when the call fails; read and left unclaimed, it reaches the page as an unhandledAI_NoOutputGeneratedError. Those are claimed at the cause — every promise-valued member, the moment the result exists, enumerated from the object rather than from a list the next release would date.One more is not reachable from this side, and this desk says so rather than hiding it. In a real browser, an endpoint that answers 400 leaves exactly one
AI_NoOutputGeneratedErroron the page, constructed inside the SDK's own transformflushand never handled late. Three things were tried and each was measured on the live drive: claiming the result's promises again after the stream is consumed (still leaks); claiming the result's whole object graph recursively, own properties and prototype getters, to depth four (still leaks — so the rejecting promise is reachable from the result at no depth); and reproducing it under Node with the same loop shape, whereprocess.on('unhandledRejection')sees nothing at all, which is why neither jsdom nor the conformance session can observe it. It reproduces at tieroffagainst an endpoint that refuses every request, so it is the SDK's refusal path rather than anything the tier added. The closest upstream report isvercel/ai#8084— "Unable to catch NoOutputGeneratedError" — closed against 5.0.x; this is the same class on 7.0.93 and no open issue matches it.What the desk owns is that the console is not where a person finds out: the run puts the status and the endpoint's own sentence on its own stream, which the engine's suite asserts, so the rejection is noise beside a failure the tab has already reported. The fix is not a page listener: one of those is keyed on an error name and would suppress every rejection on the page carrying it, an unrelated operation's included, for as long as a run was open.
It reports what the model said about its own reasoning as the contract's
reasoning events, whatever the tier is: the tier is what this desk asks
for, and a model that always thinks reasons anyway. The tab renders one line per
passage rather than one per delta.
Either engine reaches a model only through session.model.call, naming a path
suffix; the address, the token and the header allow-list are the desk's. The
SDK's providers are built against https://relay.invalid, a placeholder origin
nothing ever resolves, and the fetch they are given reduces the absolute URL
the SDK composed to that suffix — refusing any other address, any query and any
fragment, and stripping every header outside the protocol's own, including the
placeholder key createAnthropic throws without. Both wire formats put stream
in the body, so no engine ever needs a query — which is as well, because the
relay refuses one.
web/src/assistant/conformance/ is the bake-off's scenario carried into the
repository and run in CI — keyless, deterministic, no network, no runtime
binary. It runs over the registry, not over one engine: every id in
CERTIFIED_ENGINES is put through all six legs and every check below, so
certifying an adapter is adding its id to one list and a further engine is one
PR — the adapter, its conformance run, and its row in the table above. Where the
two engines' wire shapes differ, the scripted model is held to what each wire
format defines rather than to either engine's spelling; a leg that needed an
engine-specific branch in the fixture would be a finding rather than a fix.
-
scenario.jsonis the experiment's own fixture, whose DRAFT_V1, DRAFT_V2 and FACTS were proved against the runtime before it was written. -
runtime.jsonis a recording of a realjpack mcp:tools/listfiltered to the five, exactly as served with the runtime's own schemas, and onetools/callanswer per step T1–T6, taken from judgment-pack-runtime v0.19.0 in a project copy that declared an audit trail. No audit record was written by any of them. It is produced by a checked-in recorder rather than by hand:# regenerate, or byte-compare what is committed against a runtime JPACK_COMMIT=<full commit> npm --prefix web run conformance:record -- ./bin/jpack /path/to/project JPACK_COMMIT=<full commit> npm --prefix web run conformance:verify -- ./bin/jpack /path/to/project
The fixture carries the binary's SHA-256 and the runtime's full commit, and
verifyfails on a byte of drift — which is what makes "recorded" a claim somebody can check rather than a word in a comment. The project directory must declare an audit trail, so a recording pass that wrote one would leave the evidence behind.sourceCommitis stated by whoever runs the recorder and is the one member there that is a claim rather than a measurement; the fixture says so itself. -
scriptedModel.tsis the fixture's step logic in TypeScript, installed as afetchstub that records every request; it decides the next step from the results present in the request's own messages, so a run that mishandled a message array gets a different script. -
scriptedServer.tsreplays the recording on an in-memory transport pair, and matches a call by its arguments: anexperimental_evaluatearriving withoutrehearsal: trueis a failure, and so is awrite_filearriving at all. -
certification/holds four engines the desk would never certify: one touches the network as its module loads; one schedules four reaches for after its run ends cleanly — soon, five minutes out, chained behind another timer, and on a promise chain with no timer at all — and leaves an interval ticking; one clears the interval it starts and cancels a timer it schedules, so it passes the interval rule, is never credited with the reach it cancelled, and still fails on the timeout it meant and on a reach from a microtask it wrote aclearTimeout(undefined)beside; and one throws from a callback nobody is awaiting, so the cleanup can be shown to run anyway. Each must fail its leg, and does. A conformance session that only ever runs conformant engines proves nothing about the session — and a rule that fails everything proves as little as one that fails nothing, which is what the third is for.
Three bounds this session does not hold, stated because a guard whose limits
are not written down gets read as a proof. A reaction chained off something that
resolves after the drain — a fetch to a real host, a socket, a MessageChannel
— is outside it. So is an engine that stops reading a stream: a reader left
attached schedules nothing, and no drain can see it. And so is an unhandled
rejection: these legs run in jsdom under Node, where a rejection nobody claims
goes to Node's own handler and never becomes a window event. So the adapter's
own suite measures that where it is visible —
process.on('unhandledRejection'), over the desk's own 409 refusal — rather than
by dispatching the event a browser would have sent, which would only have
measured its own dispatch.
Each leg also records what the engine actually sent — the step, how many results its own messages carried back, the route, the body's own top-level members, the tools offered and the header names — as a test annotation, so a reviewer can read the wire rather than only the assertions about it.
Six legs — OpenAI-compatible, Anthropic and Gemini, each answered as a stream
and as one whole object, because an endpoint may ignore what the request asked
for —
and the checks are the experiment's own, plus one this desk added: K1a the
engine touches no network global at all — at load, during its run, or from a
timer it left behind — held by sealing fetch, WebSocket, XMLHttpRequest
and EventSource from before its chunk is imported until after deferred work
has been drained; K1 no
credential in any request and nothing called but the relay; K2 the five
tools out of tools/list, with no schema literal in any engine source; K3a
the rewrite, measured at the scripted server rather than at the page; K3b
write_file never arriving; K3c the proposal equal to DRAFT_V2; T8's
unknowns; and the event stream's exact order with one end.
Beside the shared matrix, the thinking half runs the tier at on and ultra,
the degrade, the dialect fallback on each family that has one, the refutation
pass in both verdicts, and — on the Gemini family — more: the schema the model
was shown, asserted deep-equal to what the table above says it is shown, over
the runtime's own five and over a probe carrying every keyword; that table's
vercel row derived from the probe's own wire rather than copied from
anywhere; the one line per tool the run opens with where something was lost; a
keyword the removal list does not name, refused by the endpoint and reported
by the desk with nothing stripped; the budget-to-level dialect fallback; the
thought signatures replayed across every tool turn at each of Gemini's three
documented placements — on the summary, on a single call, and on the first of a
parallel pair — gating, because the endpoint refuses a continuation that
dropped one; and this model always thinks by both roads, the two-turn
inference and the immediate refusal.
- Go 1.25 or newer (
go.moddeclares it; CI reads that file) - Node 22 or newer (
web/package.jsondeclaresengines.node >= 22) - A
jpackbinary — the judgment-pack runtime
TypeScript and Vite are pinned to exact versions rather than caret ranges. They
are the two tools whose output this repository ships — a transpile and a bundle
— so a fresh npm ci producing a different build than the last one would be a
change nobody made.
One binary with the SPA embedded:
npm --prefix web ci
npm --prefix web run build # emits web/dist, which go:embed picks up
go build -o bin/jpack-desk .
./bin/jpack-desk --jpack /path/to/jpack /path/to/projectIt prints the URL to open, including the session token:
judgment-pack desk
project: /path/to/project
runtime: /path/to/jpack
open: http://127.0.0.1:8791/?token=1f3c…
The project is chosen in three steps, in this order: the argument, then
project.file in this machine's desk-level file — read through the same
custody-validated store every other read of that file goes through, and
validated against this host before it is honoured — then the current directory,
exactly as it always was. A configured default this host cannot open (not
absolute here, not resolving to a regular jpack-desk.json, or in the
filesystem root) refuses the launch and names the member, rather than
falling through: a person who configured one and silently got some other project
would have no way to see that what they wrote was ignored. A desk-level file
that is refused as a whole names no project, and the launch says which problem.
--jpack defaults to jpack on PATH, and --port defaults to 8791.
Two processes: the chassis for the relay, Vite for hot reload.
# terminal 1 — chassis with a fixed token so the URL is stable across restarts
# (flags come before the project directory: Go stops parsing flags at the
# first positional argument)
go run . --dev-token dev --port 8791 --jpack /path/to/jpack /path/to/project
# terminal 2 — Vite dev server, proxying /ws and /api to the chassis
npm --prefix web run devThen open http://localhost:5173/?token=dev.
Vite proxies /ws to 127.0.0.1:8791 (override with JPACK_DESK_CHASSIS).
Passing --dev-token is what additionally permits the Vite dev server's origin
— without it the chassis refuses the proxied upgrade, because the browser's
Origin is the dev server's and never matches the host it reaches the chassis
under.
To check a running chassis end to end with the desk's own client code:
npm --prefix web run smoke -- 'http://127.0.0.1:8791/?token=dev'The desk drives a runtime that reads your project, and — since the authoring
surface — writes files in it. Two capabilities are gated, and gated the same
way: /ws, the relay, and /api/*, the file API. Static assets are
not; they are the page, and the page can do nothing without one of the two.
- Loopback only. The listener binds
127.0.0.1. Nothing off the machine can reach it. - A session token. A random 192-bit token is generated at startup and
printed in the URL. Both capabilities require it as
?token=, compared in constant time. (Length is still observable, which does not matter: the format is fixed and public, and the value is the secret.) The page copies it intosessionStorageunderjpack-desk-tokenon first load, and it leaves the address bar at the first in-app navigation — nothing rewrites the URL on load, so a sentence claiming it disappears immediately would be false. - An origin check. A request whose
Originis not the origin the page was served from is refused — scheme and host both, and anOrigincarrying a path, query, fragment or userinfo is refused outright rather than matched on its host. This is what stops a page on another site from driving your runtime, or writing to your project, through your own browser: a token in a URL you have visited is not by itself protection against that. A request with noOriginat all is not from a browser — it is a script or a test holding the token — and the token is its authorization.
A cross-origin write is refused twice, and neither layer is load-bearing
alone. A page on another site cannot send the file API's PUT from a browser
at all: the JSON content type makes it a non-simple request, so the browser must
preflight, and the chassis grants no CORS permission whatsoever — that layer is
the browser's. The second is ours: a PUT that arrives with a foreign Origin
anyway is refused by the same guard, which is what a non-browser client meets.
Both are asserted by tests, so neither can be dropped on the assumption that the
other suffices.
What these layers are for, and what they are not. The containment machinery defends against confused requests — a page on another site, a bad path, an encoded traversal — and against this desk's own bugs; the write protocol defends honest editors working at the same time. None of it defends against a hostile local process that already owns the filesystem: such a process can race any component addressed by name, and every part of this arrangement has one — the watcher watches by pathname, the runtime is started with a pathname working directory, and the runtime reads the project by pathname too. Holding a descriptor makes the file API's own resolution unraceable and stops the two halves of the desk drifting onto different trees; it does not make the machine someone else's problem. That boundary is stated here rather than implied by the absence of a caveat.
It writes to the project only through the file API, only inside the project root, and only where a request carried the token and an acceptable origin. The runtime subprocess inherits the project directory as its working directory and is killed when the socket that started it closes. Identity is display only; the change that would falsify that is wiring an identity provider — discovery, JWKS, a redirect — and the PR that does it must amend this paragraph in the same commit.
The paragraph above used to open "the chassis holds no credential and opens no outbound connection". It now holds exactly one credential and makes exactly one kind of outbound request, and this section is what that sentence was replaced with rather than quietly edited around.
The key is on this machine, in one file, owner-only — and it is stored together with the destination it was entered for.
~/.config/jpack-desk/secrets/assistant mode 0600, in a directory of mode 0700
{ "assistantKeyVersion": 1, "origin": "https://gw.example", "kind": "gemini", "key": "…" }The binding is the point of that record. A key that travelled wherever the
configuration happened to point would be a key page code could redirect by
writing one member of a file — and this desk has a route that writes that
member. So the probe and the relay present the key only where the configured
endpoint's scheme, host and kind still equal the ones stored beside it, and
refuse with assistant-key-unbound otherwise, sending nothing. Changing the
path or the query keeps the binding; changing the host, the scheme or the wire
protocol breaks it, and the repair is to enter the key again. Storing one
therefore requires an endpoint to bind it to, and a key file without
assistantKeyVersion — the format this replaces, a bare key — is refused
rather than read: a credential with no binding is the state the record exists
to end, and the sentence names the one action that repairs it.
Admin's key row reads that binding rather than waiting to meet it. It says which host and which protocol the stored key was entered for, offers no entry field at all where no endpoint is configured — there would be nothing to bind to — and, where the two disagree, names both destinations so a reader can see which of the pair moved. Neither half is a secret: both are in the file the page already reads.
The verdict is this desk's, and the page computes none of its own.
GET /api/assistant/key answers configuredOrigin — the origin of the
endpoint the file names now, as this desk computes it — and bound, which is
the relay's own predicate rather than a second reading of it. The page tried to
work it out and got it wrong in a way nothing on the page could have caught:
the browser's URL drops an explicit :443 where Go's url.Parse keeps it, so
a key stored for a host and a configuration naming the same host with its
default port written out read as stored and bound while the relay answered
assistant-key-unbound and sent nothing. Two implementations of one rule is
one too many, and the one that decides has to be the one that presents the
credential.
XDG_CONFIG_HOME is honoured where it is set to an absolute path; a relative
one is ignored, as the specification says. The write is staged in the same
directory and renamed over the target, so a reader during a replace sees the
old key or the new one and never half of one.
That directory is validated once, at startup, and then held as a
descriptor — the same treatment the project gets, and for a sharper reason.
A pathname is not custody: os.ReadFile, MkdirAll, Chmod and Rename all
follow symbolic links and none of them looks at who owns what it lands in, so
in a configuration tree another local user can write to, a preplanted
secrets/assistant symlink is enough to make this desk read a file of the
attacker's choosing as the key — and present it to an endpoint the same
attacker named in a preplanted desk.json. So:
- Every component of the path is checked before anything is opened: a real
directory rather than a symlink, and not writable by group or others. A
sticky world-writable ancestor is admitted —
/tmpis the case, and the sticky bit is exactly the rule that only an entry's owner may replace it. - The desk's own two directories must be owned by the user running the
desk, and must not be writable by anyone else. Their ancestors may belong
to root as well, because
/and/homedo on every ordinary system. A0755directory of ours — what a umask of 022 produces — is narrowed to exactly0700, because nobody else could have written into it. Exactly: the setuid, setgid and sticky bits are taken off with the rest, so02700becomes0700rather than being left as it was. None of the three grants anyone else access, and none is a reason to refuse a desk — but a sentence that names one mode while the code accepts another is a small untruth, and the samechmodwas being issued anyway. One that is writable by group or others is refused, not narrowed: tightening it closes the future and can do nothing about what was already planted while it stood open, and what may have been planted is adesk.jsonnaming an endpoint this desk would then present its key to. desk.jsonis held to the key's rules but one. It must be owned by this user, a regular file, not writable by anyone else, and the descriptor opened is compared to the entry inspected — because writing that file is choosing where a credential goes. It may be world-readable: it holds no secret, and refusing0644would refuse what an editor or a checkout leaves.- The validated directory is then pinned, and every read, write, rename and mode change goes through that descriptor with no-follow semantics. The key file itself must be a regular file no one else can read; a symlink at its name is refused rather than followed.
A desk that cannot establish that keeps no key, and says so. The rest of it
runs: packs, rehearsals and graphs are unaffected. Admin › Assistant renders
the refusal, which names the directory that has to be repaired, and every
assistant endpoint answers 409 assistant-unusable-store with the same
sentence.
The residual is the file API's residual. None of this defends against a hostile local process running as this user: it already owns these files and can replace them whenever it likes. What validation closes is the window in which another user's writable directory redirects an open. That boundary is stated here rather than implied by the absence of a caveat.
It is in no project. Not in jpack-desk.json, not in the desk-level
desk.json, not in any file the file API can reach — and the configuration
schema has no member it could be written to, at any depth, with a member named
like a credential refusing the whole file by name.
It never comes back. No endpoint returns it. GET /api/assistant/key
answers {present, fingerprint}, where the fingerprint is four characters from
each end and is empty for a key shorter than twelve — a key of eight
characters would be disclosed in full by a four-and-four fingerprint, and a
redaction that redacts nothing is worse than none. The page therefore never
holds the key it does not type, and the masked field is never populated from
anything: there is no value to populate it with.
It is never logged, and neither is a URL that might carry one. The desk records that a key was stored and that a probe was made; it records neither the key nor any fragment of it, and a test asserts the log contains the event and not the value, so an empty log cannot pass for a clean one. The probe line carries the endpoint's scheme and host only. A configured URL may carry a query string — some gateways route on one — and a query string is a place people put credentials, deliberately or by pasting a presigned link, so it is never written down. Userinfo and a fragment are refused outright when the file is read: a key is never written into configuration, and that includes into a URL.
The probe is made by the desk, not the page, because the key must not reach the page in order to be presented to an endpoint:
GET /api/desk-config |
the desk-level file's bytes, or that there is none and where it would be |
GET /api/assistant/key |
whether a key is stored, and its fingerprint |
PUT /api/assistant/key |
store one — non-empty, at most 4 KiB, no control character |
DELETE /api/assistant/key |
remove it |
POST /api/assistant/probe |
reach the configured endpoint once and report what came back |
ANY /api/assistant/relay/v1/… |
carry one model request to the configured endpoint, with the key attached here — and, for the one shape it renders, read the answer before forwarding it |
The probe's four answer 409 with assistant-unusable-store where this machine
has no directory safe to keep a credential in, assistant-unconfigured where
the desk-level file names no endpoint or was refused, and assistant-no-key
where nothing is stored. Each of those is a different repair, which is why they
are three codes and not one.
All six are under the same two checks as /ws and the file API, through the
same shared guard, and a request refused by the guard stores nothing — asserted
by its own test, because a handler that stored and then refused would pass
every status assertion.
The probe request carries no destination. If it took a URL from its body,
anything holding the session token could point the desk — and the key it holds
— at a host of its choosing; the destination comes from the file on this
machine instead, so a request body cannot move it. It sends the smallest
request the configured protocol defines — a model listing for
openai-compatible, a messages call bounded to one output token for
anthropic — waits at most ten seconds, and follows no redirect: Go strips
Authorization across hosts and knows nothing about x-api-key, so a followed
redirect could walk that credential to a host nobody configured.
A control character in a key is refused wherever it sits, including at either edge. The key is presented in a request header and a newline in one is header injection; only ordinary whitespace is trimmed, and only after the refusal has been decided, so a leading newline is no longer quietly repaired into an acceptable key.
A configuration the desk refuses authorises nothing. The chassis decodes
the whole desk-level file under the same contract the browser decodes it under
— every unknown key refused by name at every level, a credential-shaped member
refused wherever it appears, all four endpoint members required, one URL rule —
and any problem anywhere in that file refuses the probe outright, with no
outbound request at all. Before this, the chassis read only
assistant.endpoint: a file carrying a stray apiKey, a missing tools or a
whitespace model showed "configuration refused" on Admin while the probe
happily sent the stored key to the endpoint it named. The two decoders are held
together by fixtures both read — web/src/config/fixtures/desk-config/, with
one expected.json naming each file's verdict and the keys it must refuse — so
a rule changed on one side and not the other fails on both. There is an accept
and a refuse fixture for every member either decoder validates, and each
refused one is probed with a key stored while a counting transport asserts that
nothing at all left the process.
The answer is {reachable, status, latencyMs, diagnostic}, where reachable
means the endpoint answered successfully — a 401 is a host that is there and
a credential it will not take, and calling that reachable would report a desk
that cannot make one call as ready. diagnostic is one word from a closed
list — unauthorized, forbidden, not-found, timeout, tls, refused,
dns, unexpected-status — and never text the endpoint wrote. The
endpoint's own sentence used to be quoted with the key substituted out of it,
which is a categorical promise held by one string replacement: a body under the
endpoint's control can carry a derived representation of the credential —
base64, percent-encoded, JSON-escaped, hex, or half of it — that no
substitution reliably detects. So the body is read to the end and discarded —
to the end, bounded by the same ten-second deadline rather than by a byte
count, because a drain that stops early leaves the connection unusable. The cost
is real and accepted: debugging a misconfigured gateway now means reading that
gateway's own logs. A probe that reaches nothing still answers 200; the
refusals are the states in which the question cannot be asked at all — no
usable place to keep a key, no endpoint configured (a refused file included),
and no key stored — and each says which.
The page runs the assistant's loop and the page never holds the key. Those two sentences are only compatible if something between them carries the credential, and it has to be the desk: the key is on this machine and is returned by no endpoint. So a page-side engine points its provider client at
baseURL = <this desk's origin>/api/assistant/relay/v1
and sends its model traffic with no credential at all. The relay strips whatever the page did send, attaches the configured key on the configured wire protocol, and forwards everything else verbatim. It is also what makes the arrangement possible in a browser: an ordinary bring-your-own endpoint answers no CORS, so a page calling one directly could not read the answer.
-
The page can choose a destination; the key travels only to the destination it was entered for. This sentence used to be "the destination cannot come from the page", and
PUT /api/desk-configmade it false: code holding the session token could write an endpoint of its own — same-origin, so the origin guard never applied — and then probe or relay and receive the machine-held key there.The answer is not to withdraw the write. It is that the key is bound. Storing one records the scheme, host and
kindof the endpoint configured at that instant, beside the key and in the same file; the probe and the relay present it only where both still match, and refuse withassistant-key-unbound(409) otherwise, with nothing sent. A path or a query may change — that is the endpoint's own routing, and an author edits one without changing who is at the other end — but a host, a scheme or a wire protocol may not. A configuration write that moves any of those leaves the stored key in place and unusable and says so,keyRebindRequired: true, so the repair is a person entering the key again — which page code cannot do, because no endpoint returns the key, nothing in the chassis sends it to the browser, and the store endpoint takes a value the page must already hold. Storing a key therefore requires an endpoint to bind it to, andGET /api/assistant/keyreports the binding beside the fingerprint so a form can say which host the key is for.The rest is unchanged: the destination is still
configuredEndpoint, the same whole-file decode the probe uses, so adesk.jsonthe browser refuses authorises no relayed request either. The page chooses a path suffix and one query pair on one protocol, and nothing else: not the host, not the path around it, and not the rest of the query. -
The suffix is held to a closed class: one or more segments of
[A-Za-z0-9._-], no dot segment, no empty segment, at most 256 bytes, and no percent sign — so the escaped and unescaped readings of an accepted suffix are the same string and%2e%2e%2fis not a dot segment in a costume. Anything else is refused withassistant-relay-pathand nothing leaves this process. The suffix is appended to the configured URL's escaped path, so%2Fin a configured base stays one segment. -
One exception to that class, and it is a shape rather than a character. The final segment may be
<name>:<method>where the method is one ofgenerateContent,streamGenerateContentorcountTokens— the way the native Gemini wire addresses a method, as inv1beta/models/gemini-2.5-pro:streamGenerateContent. A colon anywhere else — including in a segment that is not the last, which the first version of this rule accepted and forwarded — a second colon, an empty name, an escaped colon, or a method outside those three is refused exactly as before. A method is a verb applied to the resource the path names, so there is nothing after it. The list is closed because the part after the colon is a verb: an open one would let whoever holds the session token ask the configured endpoint to do something nobody wrote down, with the stored credential attached, and adding a method is a reviewed change to that list. The rule is about the path and is not gated onkind— the kind decides the credential, and a relay that read one to decide the other would be two rules where there is one — though in practice only the gemini wire writes such a path. -
The
v1in that address belongs to this route, not to any endpoint, which is how one mount point serves both protocols: an OpenAI-compatible client appends/chat/completionsand an Anthropic one appends/v1/messages, and each lands unchanged after the configured base — exactly where the probe sends its own request. -
It forwards the protocol headers, and only those. The request travels with an allow-list:
Accept,Accept-Encoding,Accept-Language,Content-Type,Content-Length,User-Agent,Anthropic-Version,Anthropic-Beta,OpenAI-Beta,OpenAI-Organization,OpenAI-Project, and theX-Stainless-*family both vendors' generated SDKs attach. Everything else is dropped. This was a denylist of credential names and the denylist was the defect: "every inbound credential is stripped" cannot be held by a list somebody thought of —X-Auth-Token,X-Amz-Security-Token,Ocp-Apim-Subscription-Keyand whatever a gateway invents next all walked through it. With an allow-list the claim is structural, andCookie,OriginandRefererfall out without being named. A page that needs a header this list does not carry is a reviewed change to the list. -
Nothing of the page's query is forwarded, ever. A relayed request may carry this chassis'
?token=and no other parameter: every raw pair's decoded name must be exactlytoken, the spelling the guard reads, and anything else — any name, any case, any encoding, an empty name included — is refused withassistant-relay-pathand nothing sent. A literal;is refused with it.This was a filter first, and the filter leaked this desk's session token three times, three different ways, to three reviewers:
?%74oken=…(the guard reads names withurl.Query, which percent-decodes, and a raw compare did not);?x=1;token=…&token=…(Go rejects a pair containing;, so the guard sees one parameter where a server that still splits on;sees two); and?Token=…&token=…(this desk compared case-sensitively, and ASP.NET Core's query parser folds case). Each fix was a better comparison, and each time the next parser disagreed somewhere else. The class existed because the query was forwarded at all — no comparison this desk can write is the comparison every parser downstream makes — so it is not filtered, it is refused, and refusing is the one rule every parser agrees on because there is nothing left for them to disagree about. -
One pair is the exception, on one kind, byte for byte. The native Gemini wire asks for a server-sent-event stream with a query parameter and has nowhere else to put it — it is not a header, and the configured URL cannot carry it because the same endpoint serves the unary call too. So for a configured
geminiendpoint the page may send exactlyalt=ssebeside the token: the literal nine bytes, at most once.alt=json,ALT=sse,%61lt=sse,alt=sse&alt=sse,alt=sse&x=1andalt=sse;x=1are each refused withassistant-relay-pathand nothing sent, and the pair is refused entirely on the other two kinds, which carry streaming in the request body and need none. This is a closed exception and not a loosening: byte equality against one fixed literal is the one comparison that has no second reading, which is precisely what the refusal above exists to guarantee. What the request said is settled before anything is read off this machine; whether the configured kind admits it is settled as soon as the kind is known, before the key is opened, and nothing outbound happens either way. -
One query reaches the endpoint: the configured one, then that pair — the endpoint's own routing, out of the file on this machine, carried across byte for byte and first, with
alt=sseafter it where the page sent one. So the page chooses a path suffix, and one pair on one protocol, and nothing else. -
Method and body verbatim, bounded at 8 MiB — a whole schema, several examples and a draft ride in one request — refused with
too-large, never truncated. The whole body is read before a byte of it is dispatched, so an over-size body of undeclared length reaches the endpoint not at all rather than eight mebibytes at a time; the cost is bounded twice, at 8 MiB a request and four requests in flight, so at most 32 MiB of request bodies are held at once. -
Streamed, not buffered. The answer is flushed as it arrives; a test proves the page has the first SSE event in hand before the endpoint has written the second, which a relay that buffered would fail while still delivering both.
-
Bounded in time rather than in bytes, because a model answer has no length worth guessing: ten minutes for one whole relayed request, two minutes before the first byte of the answer, and two minutes between two writes after it. The first of those two was missing and is the reason this sentence now names three numbers: the idle bound was installed once the transport had a response, so an endpoint that accepted a request and then sent nothing at all — not a header, not a byte — was held by the ten-minute bound instead, and four of them exhausted every relay slot for ten minutes. A stream that stalls, and a connection that never answers, are both cut rather than left holding the page. Writes to the page are bounded by the same pair — a page that authenticates and then stops reading would otherwise hold its slot for ever, since neither deadline ends a write to a client that is not listening and this desk's server has no
WriteTimeouton purpose (/wsis a socket it holds open for a session). Every write is capped at the overall deadline; past it exactly one write is allowed, bounded at five seconds, so the refusal still lands rather than the page being dropped mid-connection. The whole bound is therefore the overall deadline plus five seconds, and no idle bound can extend a request past it. -
At most four relayed requests in flight, and past that the answer is an immediate
assistant-relay-busy— a bound, deliberately not a queue, because a queue reports a wait as latency. -
No redirect is followed, for the reason the probe does not follow one: Go strips
Authorizationacross hosts and knows nothing aboutx-api-key. -
The answer is forwarded whole — status, headers and body — minus the hop-by-hop headers, minus the credential headers below, and minus
Set-Cookie: the page and this chassis share an origin, so a cookie from the endpoint would be stored against the desk and sent back to the desk's own endpoints. No informational response and no trailer is forwarded at all, because both reach the page down paths no filter on this route sees — a 1xx through the proxy's own client trace before the answer is inspected, and a trailer copied after the body — and nothing either protocol needs arrives in either. -
The model listing goes over it too, and is first-page-only. Each protocol's listing is an ordinary relayed
GET—<relay>/modelsfor anopenai-compatiblebase ending in/v1,<relay>/v1/modelsforanthropic,<relay>/v1beta/modelsforgemini— carrying that protocol's credential header and none of the page's. A page cannot ask for a second page: Gemini's listing pages withpageToken, and nothing of the page's query is forwarded, sopageToken=…is refused withassistant-relay-pathand nothing sent. Later pages are not supported at all, and that is the whole of it:pageTokenis refused from the configured URL as well — a configured page token is a fixed cursor nobody re-reads, which is not pagination — so an endpoint with more models than one page holds shows the first page and no more. Supporting the rest would need a mechanism that passes a cursor safely, and this release does not have one. Nothing on the chassis is added for the listing; it is the relay.The page's half is Admin's List models, and it goes over the same capability an engine gets rather than round it: it names the suffix, and
bindModelCallbuilds the address, attaches this chassis' token and holds the suffix to the rule above.ModelRequestcarries amethodfor it — a closed'GET' | 'POST'pair, because the relay forwards the method verbatim and an open member would let whoever holds a capability ask the endpoint to do something nobody wrote down with the machine-held credential attached. The three families' answers differ — Gemini'smodels[].nameismodels/<id>with adisplayNameand the methods each model supports, OpenAI-compatible's isdata[].id, Anthropic's isdata[].idwith adisplay_name— and each is read apart rather than guessed at. Gemini's rows are filtered to models whose own declaration includesgenerateContent, because that listing carries embedding models an assistant cannot run on. -
Nothing else. No retry (a retried model request is a second charge on somebody's account for an answer nobody saw), no caching, no request rewriting, no model-name inspection. A refusal carries
assistant-relay-*and, for an endpoint that never answered, one word from the probe's closed diagnostic vocabulary — never anything the endpoint wrote. -
The log line is the origin and the status: no path, no suffix, no header, no body, no key.
An endpoint can hand the key back, and this is the bound on that. The
credential the desk sends is the endpoint's own, so an endpoint that echoes what
it was sent — a debug gateway, a misconfigured proxy, a hostile one — would
otherwise put the machine-held key straight into the page, which is the single
thing this route exists to prevent. So the answer's headers are filtered too:
Authorization, Proxy-Authorization, WWW-Authenticate, Proxy-Authenticate,
X-Api-Key, Api-Key, X-Goog-Api-Key and Set-Cookie by name, and any header
at all — under a name nobody listed — carrying the configured key in its value.
That last rule has a length in it: a key of twelve bytes or more is looked
for anywhere in a value, so X-Echo: Bearer <key> goes too; below twelve it is
exact equality only, because a short key is a substring of ordinary text and a
filter that deletes the answer to protect a credential is a worse answer than
the credential. Twelve is the same length below which this desk will not show a
key's fingerprint either.
One relayed answer is read, and it is the model listing. The rule above —
the body is carried and never inspected — is a rule about model traffic,
which an engine consumes in code. A listing is different in kind: it is a set
of strings this desk renders, into a picker on Admin, into the page's own
state, and into a field a person can copy from. So an endpoint that reflects
its own credential as a model id or a display_name would hand the
machine-held key to the browser through the one route whose entire purpose is
that it never gets there — and no filter the page could write would help,
because the page has never held the key and could not recognise one.
So for a listing-shaped relayed request — a GET at the suffix the
configured kind's listing is at, decided from the file on this machine and
never from anything the page said — the relay reads the answer's body whole,
before a byte of it is forwarded, bounded at 1 MiB, and:
- if any JSON string in it equals the configured key, or contains it
where the key is twelve bytes or longer, the answer is
502 assistant-listing-refused— "the endpoint put the credential in its model listing; this desk will not list it" — and nothing of the body travels, not the id, not the endpoint's own words around it; - a body past the bound answers the same code: a listing this desk cannot read to the end is one it cannot say anything about, and forwarding the part it did read would be the truncation every other bound here refuses. The read is bounded in time by the same idle deadline every other answer is — it buffers rather than streams, and the wrapper that bounds the rest was once applied only after it, so an endpoint that sent one byte here and stalled held a relay slot until the overall deadline;
- otherwise the bytes are forwarded verbatim, with the length re-declared from what was actually read.
A listing is forwarded only if it is exactly one JSON value, decoded end to end, with nothing behind it. Anything else — plain text, an empty answer, a truncated document, a second value behind the first, a redirect, malformed JSON whose escaped credential sits in the half a decoder never reaches — is refused with the same code and none of it travels. This is the repair for a rule that read the other way round: a decode error used to fall back to comparing the raw bytes, so a body the scan could not read was forwarded whenever the literal key bytes happened to be absent, and a scan that cannot read a body cannot clear it. It costs nothing real — the three protocols' listings are JSON documents, and an endpoint answering something else at its own listing path has not answered a listing.
"Exactly one JSON value" is the decoder's own reading and not a shape list: a
bare string, a number, null and true are each one value and are carried,
and numbers are read as the digits they were written as, so a valid document
carrying 1e1000 is not refused for what Go can hold a float in.
The scan is exact-and-contains, on the same twelve-byte floor the answer headers use, and it compares the decoded strings — member names and values, at any depth, and a number's own digits, because a key of digits is a key — since a key written into JSON with escapes is one string to a decoder and different bytes on the wire. A derived representation — base64, percent-encoded, hex, half of it — is not detectable by any comparison, which is the ruling chunk 1 already took for the probe, and it is stated here rather than implied away. A listing is asked for uncompressed so that what is scanned is what was sent. Every status is scanned, not only a success: a 401's body can carry the credential it rejected as easily as a 200's can carry it as a model id, and a rule with a status in it is a rule with a hole in it. The suffix table is mirrored on the page and held equal to the chassis' by a test that reads the Go declaration.
The page does not read a refused listing's body at all — not even its
code. The relay forwards a 4xx verbatim, so the body on a failed listing is
sometimes the desk's envelope and sometimes the endpoint's, and no header tells
them apart. What Admin says is the status and one sentence from a closed list;
on 502 that sentence names both readings without claiming either, and the
desk's own log is where the distinction lives.
Every other body is not filtered, and that is a decision rather than an oversight.
The relay parses none of the traffic it carries; a streamed answer cannot be
scrubbed as it passes; and the probe's own ruling already applies — a derived
representation of a credential (base64, percent-encoded, hex, half of it) is not
detectable by any substitution, so a filter over bodies would be a categorical
promise held by a strings.Replace. An endpoint that writes the key into a completion
therefore hands it to the page. The residual is stated rather than papered
over: the key is the endpoint's own credential, it is presented only to the
endpoint the desk-level file names, and it is good only at the endpoint that
already holds it. What this route guarantees is that the desk never volunteers
it — not that an endpoint cannot give away a secret it was given. The listing is
the one exception above, and it is an exception because the desk renders it.
The assistant calls it, and nothing else does (ADR-0001). The desk builds each relayed address, with this chassis' session token — the one parameter the rule above admits — and hands the engine a capability rather than a URL, so no engine ever holds the token or chooses a query. See Why the engine gets a capability and not a base URL.
The page mirrors this rule, and the mirror is held equal to it by a test that
reads the Go source. The desk's capability refuses a suffix the relay would
refuse before the request leaves the page, because a refusal that only
happened on the far side is a refusal after the fact. It carries the same closed
method list, the same one query pair on the same one kind, and the same segment
class — and three enforcement tests read relayPathMethods, relayStreamPair
and relayExtraQueryPair out of internal/desk/modelrelay.go and require the
two to agree in both directions. A method on the page's list and not the
chassis' is a call the engine believes it made and the relay refused; one on the
chassis' and not the page's is a capability the desk grants and the page cannot
reach. The wire protocol the pair is admitted on comes from the configured
endpoint where the session is bound, never from an engine: an engine names a
suffix and nothing else.
The runtime has no write tools, and that is a decision rather than a gap: ADR-0006 makes it a stateless oracle, so the authoring lifecycle belongs to the client. The desk is the client, so the desk owns writes — through the chassis, never through the relay, which stays a verbatim pipe.
What phase 1 is, exactly: a chassis file API, and an editor shell at /author
that lists the project's files, edits one as text, and saves it. That is all.
There are no schema forms and no validation wiring — those are phase 2, and
matrix and rows editing is phase 3. See
issue #14 for
the whole shape.
Nothing in the desk judges a document. The file API moves bytes; it does not
read jpack.json, does not care whether a path is a pack, and forms no opinion
about what any file means. Every verdict stays the runtime's, asked for through
the tools every other view already uses, and rendered as the runtime states it.
That includes refusals, with one qualification worth stating precisely rather
than promising: whether a reviewed-set lock (ADR-0019) refuses depends on what
the desk asked for. The only evaluation surface here is the what-if view, and
against a runtime that accepts the rehearsal argument it declares one — a
rehearsal consults no reviewed set by design (ADR-0028), so a locked pack does
not produce a refusal there. Against an older runtime with no rehearsal
argument, the same view makes an ordinary evaluation and a lock refusal appears
verbatim. Either way the desk never offers to update a lock and never routes
around one. See "The lock, and what phase 1 does not do" below.
Three endpoints, proxied alongside /ws by the dev server, under the same two
checks as /ws — the session token first,
then the Origin — through one shared guard, because a new endpoint is a new
place to forget one:
GET /api/files |
the project's regular files, with sizes and digests |
GET /api/file?path=… |
one file's bytes |
PUT /api/file |
replace one file's bytes, optionally creating its parents |
Containment is a held directory descriptor, not a path check. The chassis
opens the project directory once with os.Root when it starts and closes it
with the server; every list, read, stat, staging write and rename goes through
that handle. (On the desktop platforms this desk targets that handle is a real
directory descriptor. Go documents os.Root as falling back to pathname
resolution where the syscalls do not exist — Plan 9 and js/wasm — and the
guarantee is correspondingly weaker there; the desk is not built for either.) This matters for two reasons a string check cannot address:
- A pathname that is validated and then opened is checked against one
filesystem and opened against another. Replace an approved ancestor directory
with a symlink in between and the open follows it — no amount of resolving
beforehand prevents that.
os.Rootresolves each component against the held descriptor, so the thing checked is the thing opened. There are tests that perform exactly this swap, onGET, onPUT, and on the listing. - Pinning it once is the other half. Re-resolving the project path per request would let the authority itself be retargeted — rename the directory, or repoint the symlink it was reached through, and later requests would adopt a different tree without racing anything. A test repoints a symlinked root after startup and asserts the desk keeps serving the tree it was given.
One version-dependent trap, named because it bit: the fs.DirEntry that
Root.FS() yields resolves Info() by pathname on Go 1.25 and by
descriptor on 1.26, and on a filesystem that does not report entry types in the
directory block it lstats by pathname to classify at all. So the listing does
not use Root.FS() — depending on which toolchain is underneath to hold a
containment property is not a property.
The listing walks directory descriptor by directory descriptor: it opens each
directory through the pinned root, reads names in bounded batches, and
classifies each child with Root.Lstat — Lstat, so a symlink is seen as one
rather than followed.
It is also bounded, because a tree can be adversarial without anyone being
hostile: a bind mount or a directory hard link makes a tree contain itself with
no symlink in it, and a depth cap alone does not save you — two aliases per level
and the work doubles. Each opened directory's identity is compared with the
directories open above it (os.SameFile), and there is a total entry budget.
The watcher's traversal carries the same bounds.
Anything the walk could not read — an unreadable subtree, a repeated ancestor, a
budget reached — is reported in a partial member and named in the note. A
thinned answer that still returned a bare 200 would be indistinguishable from
a smaller project, so the editor renders partial prominently and, when it is
present, never says the project is empty.
A lexical check runs first and is tested on its own. It refuses escaping
.. (an interior a/../b normalises and is fine), absolute paths, drive and
UNC forms, NUL, and backslash anywhere — on Windows that is
a separator, so ..\secret is a traversal slash-only cleaning does not see, and
refusing it everywhere removes the platform difference from the argument rather
than reasoning about it. The two layers are independent rather than nested — os.Root refuses escapes
this one never sees, and this one refuses spellings (a colon, a backslash) that
Unix os.Root would happily treat as a filename. Each is kept because a change
to the other should not silently become the only thing standing.
Windows caveat, stated because it cannot be tested here: reserved device names and trailing-dot or trailing-space aliases are refused by the filesystem layer rather than by the lexical one, and this project's CI runs Linux only.
What is readable and writable. Any path inside the root except: the
directories the watcher also ignores — .git, node_modules, dist, .venv,
vendor — and this desk's own .jpack-desk-* staging files. Those are refused
by the endpoints, not merely omitted from the listing, and a path with such a
component is refused on GET and PUT alike. Symlinks are not documents
here: a path any component of which is a symlink is refused by both verbs,
because a read follows a link while a save renames over it — one name, two
objects, and an editor showing you one while the save replaced the other. Those exclusions
are reported in the listing's excluded member rather than left to be inferred.
Non-regular files are excluded too: a symlink is not listed and not readable, a
FIFO or device is refused on open (with O_NONBLOCK, so a FIFO cannot hang the
handler before the check runs). A file too large to read is listed with an
empty digest rather than hidden — it is really there — and refused by the read
endpoint. That empty digest means exactly one thing; a file the listing could not
read for any other reason is named in partial instead, so "too large" is
never said about a permission error. Reads and writes are refused alike for every one of these: an API that
reads and writes a path by different rules is one nobody can reason about.
Otherwise it is the user's own files on the user's own machine, and this API is
their hand, not a policy layer. It does not consult jpack.json and forms no
opinion about what any file is.
Writing requires an existing directory, unless the write asks otherwise. A
PUT whose parent directory is not there answers 404 naming it, rather than
reporting a containment failure — a missing directory is not an escape. The
conflict check runs first, so a write that also carries a stale baseSha256
gets its 409 before that 404: the 404 is what a believed-new or
overriding write receives.
createParents is the opt-in. A request carrying "createParents": true
has the missing directories of its path made before the file is written; with
the member absent or false nothing is created and the 404 above is what comes
back. It exists because the desk decides a new pack's location from
configuration, so the Create-pack dialog can name packs/… in a project that
has no packs/ yet.
Containment is not extended by it — a fourth verb is added to the handle that
already carries the other three. Root.MkdirAll resolves each component against
the same pinned *os.Root, so the thing checked is the thing created. It runs
after the lexical check (so a directory this API would refuse to write a
file into is one it will not create), after the symlink refusal — whose walk
stops at the first component that does not exist, which is exactly where
MkdirAll begins, so a symlinked parent is refused before one directory exists
— and after the stale-digest check inside the write lock, so a write refused
as stale creates nothing. A parent that is a regular file is refused with its
own code and its own sentence: opening through it is ENOTDIR, which is a
naming problem a rename fixes and not a containment failure. It used to be
answered as "path is not inside the project", which sent whoever read it
hunting a security problem that is not there.
What the symlink walk promises, and what it does not. Containment does not
rest on it: every operation past it goes through the pinned os.Root, so a
component swapped for a link pointing out of the project — at any moment,
including after the walk has passed — is refused by the root itself. There is a
test that performs exactly that swap after the walk and asserts nothing left the
project.
What the walk adds is the desk's own stricter rule, that it edits no path passing through a link even one that stays inside the project. That rule is checked in the walk and enforced nowhere else, so it is time-of-check to time-of-use against a local writer racing it: an inward symlink introduced between the walk and the write is followed, and the bytes land at the link's target — still inside the project, still nameable by the listing, and not where the caller asked. That residual is asserted by a test rather than papered over, and it is scoped rather than closed: the threat model is a writer who already has write access to the project directory and does not need to race anything to move a file the desk just wrote.
Bounded by what the listing can report, whether or not anything is created.
A write past the walk's depth limit is refused with a 400, and the check runs
before the "the parent is already there" fast path — it used to sit inside
the create-the-parent branch, so a write into 65 directories that already
existed succeeded. GET /api/files gives up at that depth and reports the tree
as partial, so a write allowed past it lands a file this API's own listing can
never name, and there is no delete verb to take it back. An existing parent does
not make that file findable. storage.packs.dir is refused at decode for the
same bound, so the configuration names the problem rather than the dialog
failing on the write.
There is no unwind. If the directories are created and the write then fails, an empty directory is left behind. Removing it would need a delete verb this API does not have, applied to a directory another process may have populated in the interval; an empty directory is inert and the next attempt uses it.
Modes come from the umask, and this API has no opinion of its own. A created
directory is 0o777 masked by the umask and a created file is 0o666 masked
by the same umask — the ordinary convention in the user's own project, which is
committed to their repository and read by their other tools. A file the desk
brought into existence used to keep the staging file's 0o600, so one request
made a world-readable directory holding an owner-only document; the staging file
is now created 0o666 and the kernel applies the umask. A file that already
exists still keeps its own mode across a save, so a document somebody narrowed
on purpose is not opened up by editing it.
Atomic replace, scoped honestly. The bytes are staged in the target's own directory through the same pinned root — rename is atomic only within a filesystem — the mode is set, the data is flushed, the file is closed, the rename happens, and then a directory sync is attempted — best effort, and a failure there is not reported, because it cannot undo a write that has already landed. The staging file is written through the descriptor it was created with and never reopened by name.
- On Unix,
rename(2)replaces the directory entry atomically, so a concurrent reader sees the old file or the new one and never a truncated one. Only the POSIX rwx bits are carried across; owner, group, ACLs, extended attributes and the inode identity are not, because a replace is a new file by construction. - On other platforms, Go promises no such atomicity and neither does this. What
they get is
os.Root's rename semantics and nothing stronger claimed. - This is not crash durability. Data before the rename and the directory after it is the usual recipe, and power loss, a lying disk cache, or a filesystem with its own ordering can still lose the write.
A crash between staging and rename leaves a staging file. It is excluded from the listing and from the watcher, cannot be read or written through the API, and the server removes stale ones at startup — unconditionally, and only files bearing this desk's reserved prefix and suffix. That startup sweep skips the excluded directories, which is consistent because nothing may be written into them through the API either.
The write answers with a read-back taken off the disk after the rename rather than echoing the request, so the client can verify what actually landed. The editor compares it to the bytes it submitted — captured with the request, not read from the live buffer — and says "saved, and verified" only when they match.
Concurrency: a conditional commit, and one honest residual. A write carries
baseSha256, the digest of the bytes the editor loaded. The current-bytes read,
the comparison, the rename and the read-back all happen under one server-wide
write mutex, so the check and the commit are one decision: two writes from the same base
produce exactly one 200 and one 409, never two 200s where the second
silently discards the first. The 409 carries both digests and exists, so the
client can say what it had, what is there, and whether the file was changed or
deleted. override writes anyway — the user's deliberate choice, never a
default.
That serialization covers writers through this API. It cannot cover an
editor, a git checkout, or any other process; nothing in a single chassis can.
For those the file watcher is the mitigation and not a guarantee: it notices the
change and the page says the file moved underneath the edit, but a write that
lands between this API's read and its rename is not serialized with. The 409
is what makes the common case honest; the watcher is what makes the uncommon one
visible.
The editor never rebases an open edit. The watcher makes the desk invalidate
every query on a file change, so the bytes an edit is measured against are held
by the editor and replaced only when the user acts — an initial load, an explicit
reload, a successful save. A base taken from the live query would silently move
to bytes the user never saw, and Save would overwrite them with no 409 at
all. A file deleted underneath an open edit keeps the editor and the buffer, and
says so.
A project can carry a reviewed-set lock (runtime ADR-0019). Phase 1 neither interprets nor regenerates one: the desk has no lock parser, writes no lock, and offers nothing that would update one.
Two consequences worth stating plainly rather than implying otherwise:
- Whether a lock refusal surfaces depends on the connected runtime. The only evaluation surface in the desk is the what-if view. Against a runtime that advertises the rehearsal argument the desk declares one, and a rehearsal consults no reviewed set by design (ADR-0028) — so editing a locked pack and rehearsing it will not produce a lock refusal there. Against an older runtime with no such argument the same view makes an ordinary evaluation, and a lock refusal appears verbatim. Phase 3 runs matrices from disk, which is where a lock's answer will appear regardless of that distinction.
- The editor can edit the lock file.
jpack.lock.jsonis a file in the project, and this API has no list of files that are special. Editing it is possible, it is the user's own file, and it is stated here as a fact rather than presented as a feature — the runtime remains the only thing that decides what a lock means.
- One WebSocket connection spawns one
jpack mcpsubprocess withcwdset to the project directory. - Bytes cross verbatim: one JSON-RPC message per WebSocket text frame on the browser side, newline-delimited JSON on the stdio side.
- Closing the socket kills the subprocess.
- One message originates in the chassis rather than the runtime: a file watcher
over the project tree sends a
desk/fileChangedJSON-RPC notification carrying the changed path, and the page invalidates its caches on it. - A dropped socket is reconnected by the page rather than reported and left. The
delay doubles from 500 ms up to a 15 s cap, with jitter, and each attempt
builds a fresh MCP client — one that has closed already negotiated with a
server that is gone. A successful reconnect invalidates every query, because
whatever the project did while the socket was down arrived as
desk/fileChangednotifications nobody heard.
main.go flags, embedded assets, HTTP server
internal/desk/
server.go routing, SPA fallback, token and origin checks
files.go the file API: containment, atomic save, stale-write refusal
assistant.go the desk-level file, the key this machine keeps, the probe
custody.go the credential directory: validated once, then pinned
deskfile.go the desk-level file decoded under the browser's contract
relay.go WebSocket ↔ `jpack mcp` subprocess
modelrelay.go the model relay: the page's traffic, this machine's key
watch.go project-tree file watching
scripts/acceptance.sh the two-run acceptance proof
web/ Vite + React + TypeScript SPA
src/mcp/ the MCP client: transport, connection, queries, the
advertised-capability reader, the canonical-string,
probe-name and graph-document readers, and the ledger of
divergent digest pairs already asked about
src/files/ the chassis file API: the client, its query hooks, and
the save discipline both editors hold — the base that
moves only where the viewer acts, the reload that is a
direct read, and the read-back compared to what was sent
src/routes/ project home, the packs layout and its two children
(the "select a pack" page and the pack document),
evaluation, matrix, graphs, the authoring shell, the
Admin page and Help & About — Admin being two groups of
cards in one shape, one group per configuration file, of
which two write the desk-level one: the project group's
default project and the Assistant form's endpoint, model
and tier
src/components/ evaluation, coverage, row and graph-walk views, plus the
trace and handoff-target renderers both the pack and graph
surfaces share
src/shell/ the six regions, the pane state and its per-project
record, this viewer's own appearance and the ladder that
resolves it, the published shortcut list, the dirty guards
both editors use — a `beforeunload` listener and a router
blocker whose predicate is the pathname alone — the icon
set, the console's
ring buffer, the fragment-scrolling hook the section menus
need, and the Create-pack dialog — which asks for a name,
a description and a template, offers Describe it beside
them, and decides the file's location from configuration
src/ui/ the styled primitives: Button, Field, Input, TextArea,
Select, Tabs, Dialog and Alert, plus the editor's own —
SegmentedControl, Toolbar, CodeArea, SuggestInput and
AlertPanel — one CSS module each (see Styling)
src/packs/ the pack surface: the RFC 6901 pointer module, the
span-preserving document writer, the validate reader, the
cross-reference reader, the packs pane and its windowing
hook, the scroll-spy — and what a new pack is called and
where it goes (the slug rule, the one shaping a template
and a proposal both take, and the jpack.json amendment)
__fixtures__/ the documents every case here is asserted against, each
one the runtime accepts, held to the spec's own enums by
a test
document/ the reading document: the member order the schema
declares, the block wrapper that carries every pointer,
the roving tab stop and its key handling, the
omitted-member line, the unparaphrased condition tree and
one component per member kind
inspector/ the three panels: Member, References, Checks
edit/ edit mode: the `?edit` helpers, the editing context, the
shapes mirrored from the schema, the one place a form edit
becomes text, the buffer with its undo and its discard,
the toolbar, the JSON view, the pointer-addressed field
wrapper and the field kinds, the condition builder and its
text operations, the rule and exception forms with their
keyboard reordering, the check on idle, the what-if pane,
the stale-write alert and the lock line
src/admin/ the card every Admin section renders through, the member
slicer that quotes a file rather than re-serialising it,
the narration sweep both this page and the assistant form
are held to, and the one control that nominates this
project as the default
src/config/ the schema both configuration files share, its strict
decoder, the two queries that read them, the precedence
between them, and the theme attribute — written from
`src/shell`, where the viewer's own preference is resolved
against the file's default
fixtures/ the desk-configuration fixtures and their one verdict
file, read by this decoder and by the chassis' — two
implementations of one contract, held together
src/identity/ the identity slot: one nullable field, and the header
control that renders it
src/assistant/ the assistant slot: one nullable field and two settings
about how it runs, the four chassis calls, the Admin
section — a form over the endpoint, the tool grants, the
model, the engine and the tier, with the key row beside it
reading the desk's binding and the endpoint's own model
listing read through the relay — the tab, the event list
and proposal report both surfaces render, the ToolGate,
and engines/ — one lazily loaded chunk per certified
engine behind one contract
scripts/smoke.ts the desk's own client, driven outside a browser
Four rules and one test that holds all four
(web/src/ui/convention.test.ts, which reads the source because vitest runs
with css: false and a component whose stylesheet was deleted renders exactly
like one whose stylesheet is intact). A second test —
web/src/ui/palette.test.ts — reads the two global sheets on the same terms,
and holds the palettes and the density scale that live in them. A third —
web/src/ui/containingBlock.test.ts — reads every .css under web/src, by
extension and not by name, and holds one pair: a rule that authors a scrolling
overflow also positions itself, so a scroll container is a containing block.
The frame is in the swept set for its deliberate clip; a rule that merely clips
is not. It is a sweep and not a list of names, so the scroller nobody has
written yet is held by it too. It holds the declaring rules only; whether a
later rule takes a position back is measured by
scripts/containment-check.sh. It reads source and says so, and the two
halves are named in its docstring.
Three of them run over every *.module.css under web/src, and one — the
component/module pairing — stays scoped to src/ui. The split is the point. The
pairing is a claim about how the primitives are built. Colour, radius and
bare-selector are not: a hex in packs/PacksPane.module.css is exactly the
second palette a hex in ui/Button.module.css would be, and a bare li { … }
there is just as global. Until this widening those three simply did not reach a
module written anywhere else.
-
The tokens in
styles.cssare the only source of colour and radius. A module spells no colour of its own — no hex, norgb(), nohsl(), and no named colour either — and no radius of its own:--radiusand--radius-sm, never4px. A second palette is one the theme attribute does not reach; a literal radius is a second answer to a question the tokens already answer. The modal scrim is--overlayfor the same reason.And the rule reaches the two global sheets as well as the modules. It did not, and
shell.csswas spelling three colours of its own —#fffon the Create button, the drawer scrim, and the menu's shadow — whilestyles.cssspelled#fbfbf9in two rules outside its own token blocks. Every one of them was a colour the theme attribute could not reach, which is a defect a light-only desk had no way of displaying.web/src/ui/palette.test.tsasks the stricter question a global sheet needs: not "did this declaration take its colour from a token" but "does this sheet spell a colour anywhere" — in any property, including in a custom property of its own — with the token blocks ofstyles.csscut out first, because inside them a literal is the palette.border: 1px solid transparentis left alone, which is why it is a different rule and not the modules' one widened.The colour half is checked by parsing each sheet's declarations rather than matching property names, and the difference is not academic: the rule this replaced matched
border/outlinedeclarations and then skipped them, because it asked whether the property name contained "background" or "color".border: 1px solid red,outline: 2px solid redand a named colour in abox-shadowall passed the rule that exists to catch them. Every shorthand that admits a<color>is checked now, every-colorlonghand with it, and the named colours are checked as names — and the parser is itself proven against deliberately broken fixtures, because a rule that only ever sees clean sheets proves nothing about the rule. -
A component under
src/ui/owns its ownX.module.css, and no other sheet styles it. One component, one module, no orphan of either. -
shell.cssowns the five regions' layout and nothing a component renders. No module touches--rail-current,--inspector-current,--console-currentorgrid-template-areas. A route that needs a region's measurement reads a token:--main-roomis the height between the header and the strip less whatever the console is taking, which is what the packs pane needs to be sticky rather than a second scroll region. It is defined beside the geometry it is made of, with itsdvhvalue behind@supportsfor the reason the sheet already states — a custom property is not validated at parse time, so a second declaration would win and fail later at substitution. -
No component under
src/ui/carries an inline style. An inline style beats every sheet without!importantand cannot be themed, which makes it the one way a component can quietly opt out of the tokens. Four files outsidesrc/ui/do carry one, and each writes a value only the running page knows:shell/AppShell.tsxandshell/RightPane.tsxset custom properties the sheets then read (--rail-current,--drawer-w),components/GraphWalkDiagram.tsxsets a max-width measured from the container, andpacks/PacksPane.tsxsets the heights of the two spacers a windowed list reserves for the rows it is not rendering. Naming them is what keeps the rule above true rather than approximately true.
Only class selectors appear at a module's top level. A bare element selector
inside a CSS module is not hashed — it is global — so one button { … }
there would restyle every button in the desk.
Modules need no cascade layer of their own. They are unlayered author rules, so
they beat every @layer shell rule by construction, and their class names are
hashed at build time so they cannot collide with the ~60 names styles.css
already owns. That is why there is no import-order rule in main.tsx to
remember and no layer to keep in sync.
The Create-pack dialog and the pack surface are built from these primitives
today — Button, Field, Input, TextArea, Select, Tabs, Dialog and
Alert. The other views keep the sheets they have; migrating them is its own
piece of work.
go test ./...The relay's end-to-end tests need a runtime and a project, and skip without them:
go build -C /path/to/judgment-pack-runtime -o "$PWD/bin/jpack" ./cmd/jpack
JPACK_PROJECT=/path/to/project go test ./...JPACK_BIN overrides the runtime binary; otherwise ./bin/jpack is used when
present.
The component tests run under vitest against fixture payloads shaped like the wire's own:
npm --prefix web testThey are where a rendering rule fails a test rather than surviving as a habit — that a verdict the runtime did not state is never painted, that a served graph document is read and never repaired, and that each fallback says exactly as much as it should about why it is one.
The editor's own suites are named after the claim each holds.
packs/documentText.test.ts and packs/edit/writes.test.ts hold the splices:
every byte outside a touched span identical, a blanked nonEmptyString removed
rather than emptied, and a member that is not there yet written at the position
the schema gives it — behind a neighbour's own leading run, reused verbatim.
That run is copied and never invented, and it is never used to reformat a member
that was already there: an insertion changes the bytes it inserts and nothing
else, which is why a document indented with tabs stays indented with tabs and a
document nobody indented stays on one line.
packs/edit/shape.test.ts holds the mirrored schema against the fixtures'
own values. packs/edit/useDocumentBuffer.test.ts holds dirty as a byte
comparison, one undo entry per action, and a discard that clears the last
attempt's verdict. packs/edit/conditionOps.test.ts and
ConditionBuilder.test.tsx hold the builder: the operand control switching on
the operator, an ordered comparison emitting a string, an empty in and an
unquoted number both writable, and an unrecognised kind printed rather than
edited. packs/edit/EditView.test.tsx holds the one buffer — a JSON keystroke
moving the reading document, a form edit moving the bytes, form mode withheld
over a document the two readings disagree about, a diagnostic reaching its
field by aria-describedby, and a rule reorder that marks the check stale
rather than re-anchoring it. packs/edit/navigate.test.tsx holds the buffer
following the address on the route that does not remount — the second pack's
members drawn, the second pack's bytes, digest and path sent, and nothing of a
discarded edit left on the pack that follows it. packs/edit/forms.test.tsx
holds what a form does about a member that is not there and about text it has
not written. packs/edit/resilience.test.tsx holds the states this editor can
itself produce: a list pasted as an object, a pack the runtime will not serve,
Mod+S from document.body, the move chord fired twice from one card, the
what-if placement measured off the frame, and the Inspector's provenance while
the buffer is dirty. packs/edit/save.test.tsx holds the save: not
gated on the check, 409 with both digests whole and a non-primary Overwrite,
Mod+S inside the field the shell suppresses in, and a ?edit toggle that
never prompts. packs/edit/TryItPane.test.tsx holds pack XOR pack_id, the
rehearsal declaration exactly where advertised, the explicit click where it is
not, a confirmation that does not outlive the bytes it confirmed, and a refusal
rendered with no disposition. packs/edit/lockLine.test.tsx
holds the one sentence, and that no lock, conformance, health or pass/fail word
appears anywhere on the page. files/useFileEditing.test.ts and
shell/useDirtyGuard.test.tsx hold the discipline lifted out of the authoring
view — routes/AuthorView.test.tsx staying green unedited is the proof the
extraction preserved its behaviour.
The assistant's suites are named the same way. assistant/toolGate.test.ts
holds the gate at a recording transport: what left the page, never what the
gate believes it did. assistant/conformance/conformance.test.ts is the desk's
conformance session, described above, and is the one that certifies an engine.
assistant/engines/builtin/engine.test.ts holds the handful of properties that
session only exercises incidentally — the request's headers and URL, the turn
bound, end exactly once, and a proposal taken from the fenced block rather
than from the prose beside it. assistant/engines/vercel/engine.test.ts holds
the ones that are about that SDK rather than about the contract: the address
discipline on the fetch its providers are given, the placeholder credential
that never leaves it, the two layers that put rehearsal: true on an evaluate
and which of them the desk's gate must see, the whole-answer re-framing, the
retry count, and — for the one rejection this desk cannot claim — that the
author is told anyway, on the run's own stream, with the status and the
endpoint's own sentence in it. There is no unhandledrejection listener, by
ruling: one is keyed on an error name and would suppress every rejection
carrying it. assistant/AssistantPane.test.tsx drives the
page's real transport against the recorded runtime through a stand-in
WebSocket, so the socket, the gate and the SDK client above it are the
production ones.
scripts/needle-check.sh . says whether every mutation needle still matches its
file, exactly once. A row whose needle has drifted reports MUTATION DID NOT APPLY and is silently dead, and a full pass takes long enough that nobody finds
out until a review does — which is how a re-indentation in one PR left a row
from an earlier one broken. It applies nothing and runs no suite, and it is not
a substitute for running the rows.
scripts/containment-check.sh measures, in a real browser, the half of "every
scroll container is a containing block" that no reader of source can hold.
web/src/ui/containingBlock.test.ts holds the declaring rules and says in its
own docstring that it stops there: an override that reaches a pane by any other
selector — an ancestor in front of it, an id, an attribute, a nested &, a
:global, an inline style — is a computed cascade, not a sentence in a sheet.
This loads a built chassis in Chrome and reads what the cascade produced, over
every route App.tsx declares — the list is asserted against that file at run
time, so a route added later fails the gate until it is sampled — at every
width the sheets author a breakpoint for: {1400, 640}, plus N − 1 for each
max-width: N and N for each min-width: N read out of an @media prelude
under web/src, each at height 800. Four pane configurations above 1099px and
three at or below it, because below that the Inspector is a modal drawer whose
overlay owns the pointer, so the console cannot be toggled while it is open:
242 rows a build. The intended row count is computed before any sampling and
checked against the rows afterwards, and each configuration is observed on the
page it claims to configure.
A row is contained only if document.scrollingElement.scrollHeight equals
innerHeight, .desk is exactly innerHeight tall, scrollY is 0 after
window.scrollTo(0, 5000), the computed position of .desk, .desk-rail,
.desk-main, .desk-inspector and .desk-console is relative wherever the
route renders them, no absolutely positioned element resolves its
offsetParent to BODY, and no page or console error was raised while it was
sampled. It prints a table and exits non-zero on any row that fails.
npm --prefix web ci && npm --prefix web run build
go build -o /tmp/jpack-desk .
JPACK_BIN=/path/to/jpack scripts/containment-check.sh /tmp/jpack-desk /path/to/project 8765The project must list at least one pack and one graph: four of the eleven routes
are a pack's and one is a graph's, and the gate exits 2 rather than sample fewer.
judgment-pack-demo/projects/enterprise-demo is one it runs on; a project with
packs and no graphs is not. node scripts/containment-check.mjs --plan prints what
a run would sample — the preludes read and the preludes refused, the widths, the
routes, the intended row count — without a browser.
It copies the project rather than driving the one it was handed, uses a
throwaway XDG_CONFIG_HOME, and kills what it starts by PID. A project that
lists no pack, or no graph, exits 2 saying so: four of the routes are a pack
and one is a graph, and neither id can be spelt without the project. PLAYWRIGHT_CHROME
names a Chrome executable; without it, playwright-core — a devDependency of
web/, which downloads no browser — is asked for the installed one. CI supplies
no runtime binary and no project, so there is nothing for the chassis to serve;
the gate is run by hand. Run before every merge that touches a stylesheet. This
is a convention; nothing automated enforces it.
CI runs gofmt, go vet and go test on one job and npm ci, tsc, the
component tests and vite build on another. It supplies neither a runtime binary
nor a project, so the end-to-end tests skip themselves there and what runs is the
coverage that needs nothing external.
Two real evaluations through the relay, against the same pack: one with the project's full facts, one with a load-bearing fact removed. The first should resolve to an outcome; the second should escalate. Then the rows the project declares about itself — every pack matrix, and every configured graph matrix.
go build -C /path/to/judgment-pack-runtime -o "$PWD/bin/jpack" ./cmd/jpack
JPACK_PROJECT=/path/to/judgment-pack-quickstart scripts/acceptance.shIt builds the chassis, copies the project to a temporary directory — a completed
evaluation appends a record in a project that declares an audit directory, and
an acceptance run must not write into the tree it was pointed at — reads the
tokened URL off the open: line of the chassis' startup output, and drives the
desk's client three times. MUTATE is the jq expression that removes the fact,
and defaults to the quickstart pack's /request/completeness. PACK selects
the decision id where the project's first is not the one FACTS suits.
The matrix runs need none of that care — a row is a rehearsal and writes
nothing — but they run against the same copy anyway, so one run means one
project. EXPECT_MATRIX_STATUS defaults to passed; EXPECT_GRAPH_STATUS is
checked only when set, because a project that configures no graph correctly
reports skipped.
Setting GRAPH_DOCUMENT to a configured graph id adds the graph-serving pair to
that third run, and GRAPH_FILE names that graph's document relative to the
project so the served text is compared against the file byte for byte. Both are
unset by default: a runtime that predates ADR-0029 advertises neither tool, and
the step refuses rather than passing quietly. GRAPH_FILE on its own is refused
before anything runs, because it names the half of a check that only happens
when GRAPH_DOCUMENT says which graph to fetch — and an acceptance run must not
report green for a check that never ran.
The same client runs on its own against a chassis you already have open:
npm --prefix web run smoke -- 'http://127.0.0.1:8791/?token=…' \
--facts /path/to/full-facts.json --evidence /path/to/evidence.json
# the two calls the matrix and graph views make
npm --prefix web run smoke -- 'http://127.0.0.1:8791/?token=…' --matrix --graphs
# the graph-serving pair the walk diagram draws its edges from (ADR-0029):
# the inventory, then one document by its configured id, checked byte for byte
# against its own metadata and against the file on disk
npm --prefix web run smoke -- 'http://127.0.0.1:8791/?token=…' \
--graph-document vendor-onboarding-flow \
--graph-file /path/to/project/graphs/vendor-onboarding.graph.json--graph-document fails rather than skipping where the connected runtime does
not advertise those tools: asking for the step is asking for the check. Against
a runtime that has neither, the fallback is what the other flags already
exercise.
The graph leg runs without being asked for, and there the same absence is a
skip rather than a failure — naming no graph asks what this runtime can tell you
about its graphs, and "nothing" is an answer a runtime is entitled to give. Where
both tools are advertised it lists the configured graphs and, where the
project configures one, fetches its document, checks the served text against
its own bytes and sha256, and then checks the binding (ADR-0030): the
graphSha256 the matrix run decoded against the digest served beside the
document. Equal proves the two calls describe one revision of one file, which is
what lets the graphs page join a served document to a matrix run at all; unequal
proves the file was edited between them and fails the drive. Where the entry
carries no digest, the leg reports that absence and the entry's own detail if it
has one, and does not fail — it says what it observed, not why.
Which graph it binds is printed with it. A graph that declares rows and whose inventory row decoded is preferred, because only such a graph yields an entry with a digest to compare; a project whose first graph carries a decode detail would otherwise end the leg unbound while a later one could have bound. Failing that it takes any graph declaring rows, then the first configured graph.
Both tools are required, and the skip line names whichever is missing rather than assuming both are:
capabilities rehearsal=true list_graphs=false get_graph=false include_traces=false
graph binding skipped this runtime advertises no experimental_list_graphs and no
experimental_get_graph; both are needed to choose a graph and fetch its document
without being told which one (ADR-0029, which jpack 0.18.0 predates)
That drive still ends OK. No version is read anywhere: what a runtime can do
is what it advertises, and jpack 0.18.0 is named as a known example rather than
as the diagnosis.
The whole graph surface is one matrix run per drive — --graphs prints the
suite and the binding reads its entry out of that same run, and the suite's own
status is checked only after the binding has been stated. Two runs would be
two reads of an editable file, which is the condition the digest exists to
detect; and exiting on a mismatch first would leave a red run with no statement
of which revision it read.
The binding decision reads no matrix status, no row and no coverage probe. Digest equality is byte arithmetic over bytes the runtime handed over; what a run concluded is the runtime's to say, and the leg says only which bytes it concluded it about.
The desk consumes the runtime's public wire and nothing else. Where the wire cannot express something the desk wants, the gap is recorded here rather than worked around in the chassis — a chassis that parsed or supplemented the traffic would stop being one.
-
Resolved: rehearsal mode on
experimental_evaluate(was: every completed call appended one audit record in a project declaring an audit directory, so a five-variant what-if session left five records saying the project decided five times). Filed as runtime issue #124 and closed by ADR-0028 in jpack 0.18.0: a call declaring"rehearsal": trueruns identically, appends no record, consults no reviewed set, and carries the label in its payload. The desk declares it on every what-if run when the connected runtime's own tool schema advertises the argument, and says in the page which of the two worlds the runtime is; against an older runtime the original consequence note returns, and the acceptance script still evaluates a copy. -
Resolved: the graph document and the graph inventory (was: no member of any payload carried the graph document — not the pack each node names, not the edges' endpoints, not the fact pointer or evidence id an edge carries, not which node is the declared
result— so the desk could draw the nodes on the coverage report's order axis and no edge between them; and nothing listed the graphs a project configures short of running every one of their matrices). Filed as runtime issue #126 and closed by ADR-0029:experimental_get_graphserves one configured graph document by its configured id, byte for byte, beside its identity, digest and size, andexperimental_list_graphsresolves the whole configured inventory for one call that evaluates nothing. The desk feature-detects both by name intools/listand never reads a version string. With the fetch it draws the real edges from the served document; with the listing it says what the project configures before the matrix has run. Against a runtime with neither, the coverage-derived walk and its "no arrow is drawn" note return exactly as they were, and the home page finds the graphs by running their matrices. A document the runtime serves but could not decode falls back the same way with one line saying why — serving is not validating, so that document arrives as a successful call whose text is not a graph. -
Resolved: binding a served graph document to the matrix run drawn beside it (was: the walk joined two answers from two calls — the document
experimental_get_graphserved, and the coverage and rowsexperimental_test_graphsreported — by node name and by edge index, with no member of either payload saying the two described the same bytes;experimental_get_graphreported the document'ssha256and a graph matrix entry reported none, so the desk bounded the window with connection-epoch keying and in-flight gating and invented no binding of its own, which bounds staleness without ever proving sameness). Filed as runtime issue #132 and closed by ADR-0030 in jpack 0.19.0: a graph matrix entry carriesgraphSha256, bare hex, the digest of the exact bytes that run decoded, present exactly when the document loaded. The desk compares it to thesha256served beside the document, and the comparison decides the join and nothing else — it never derives, revises or overrides a verdict the runtime reached about either revision. Equal, the walk is drawn as before and the page states the provenance in one line. Unequal, the graph file was edited between the two calls, so the joined walk is withdrawn, a line names the divergence, and both queries are invalidated so the next pair of answers can re-bind — one refetch cycle per normalized pair, each cycle being the two requests, and every pair a connection has already asked about is remembered for the life of that connection. So a file still mid-edit reads as a standing withdrawal rather than spinning the page, and a file edited back and forth between two revisions settles after asking about each of them once instead of ping-ponging. Absent — jpack 0.18.0 and older, or an entry whose document did not load, since a rows failure after a successful load keeps the digest — nothing is compared and nothing is claimed either way, and the epoch-bounded behaviour stands exactly as it was. The connection-epoch key stays in all three cases: it is what keys cache identity to one connection, and the digest upgrades the join from a bounded window to proven sameness rather than replacing it. -
Resolved: node traces on the graph matrix (was: ADR-0027 pinned the trace contract and bound it to each node evaluation inside a graph run, and the runtime's
GraphNodeEvaluationcarried that node'strace— but the wire dropped it, reporting per node onlynode,status,expectedandactual, so the desk could show what a node concluded and not how it got there, though the runtime had computed it). Filed as runtime issue #127 and closed by ADR-0031 in jpack 0.19.0:experimental_test_graphsaccepts an optional booleaninclude_traces, and asked, each reported comparison whose node the walk evaluated carries that evaluation's owntraceunder ADR-0027's contract. The desk detects the argument in that tool's own advertised schema — the same way it detectsrehearsalonexperimental_evaluate, and never by the tool's name, which predates the argument — and offers an opt-in control, off by default. Off omits the key entirely, so the untraced call is the one this desk has always made, byte for byte; the traced and untraced answers are separate cache entries, because a payload with no traces is an answer to a different question and must never stand in for one that was asked. Traces are charged against the runtime's report budget, so a suite that fits without them can be over it with them — one reason a traced request can fail, and not one the desk claims. A tool error is one unstructured shape whatever caused it, and a non-refusal error covers a response the runtime did produce and the desk could not read, so the page shows the runtime's own message as the reason and adds only what it knows: that this request asked for traces, that it did not produce a usable answer, and whether clearing the ask returns to an untraced answer still in hand or retries a request never answered. Never "these nodes have no traces", which would be a claim about an answer nobody received. The control stays on screen through the failure, including on a runtime with no inventory to render beside it, so the ask that failed is always reversible. Each node's trace is drawn by the same renderer the evaluation view uses, because it is the same artifact under the same contract; a mismatching comparison shows its trace too, which is the one most worth reading.[]is a trace with no entries and is said to be empty; an absent member is not asked, or not evaluated, and shows nothing. Two orders are kept apart: the comparisons are listed lexicographically by node name, the report's order, while each trace inside one is the evaluator's walk order — the page says so where they meet, and neither is read off the other. -
Resolved: handoff-target assertions on graph rows (was: ADR-0025 added
expectedHandoffTargetto pack matrix rows and deferred the graph surface explicitly, so a graph row compared composite and per-node dispositions only and a change to where a composed decision is handed off left every graph row green). Filed as runtime issue #128 and closed by ADR-0032 in jpack 0.19.0: a rows document declaringgraphMatrixVersion"2"may assertexpectedHandoffTargetfor the composite andexpectedNodeHandoffTargetsfor the nodes it names, and the run reports each as anexpectedHandoffTarget/actualHandoffTargetpair — on the row for the composite, on the named node's comparison for the rest. The two members are one pair: they appear together, exactly when a well-formed assertion rode a run this walk performed, and a row whose assertion was itself defective — undecodable, or naming a node the graph does not declare — reports that defect in its detail and carries no pair. The desk reads them through one accessor that applies the rule, so half a pair renders nothing. Both carriers exist because a headline-only assertion stays blind upstream, where an escalation target on a node three hops back is as editable as the composite's and changes nothing any headline can see. The desk renders both pairs with the pack surface's own component, on the same vocabulary: a capped rendering, the literalnullfor "no target at all", andunavailablewhere a refused run leaves no target to state. Those last two stay distinct, because one is an answer and the other is the absence of one — andunavailableis reachable on the row alone, never on a node comparison, which exists only because the walk evaluated that node. As on the pack side the renderings are display values and are never compared here — a capped rendering can differ from its own pair past the cap — so no mark on a pair is this client's: the row's status is the runtime's verdict and the only one shown.
Apache-2.0. See LICENSE.