The settings page is a surface over the command registry and configures the shared tiers; personal settings stay where the identity is
The ask. Decide (the maintainer, before the first settings PR opens): adopt this shape for a dashboard page where a customer configures what Switchboard already lets them configure, with a section for external MCP servers first. Written for an engineer who knows the command registry (command-registry.md) and the config layers (routing-and-config.md item 2) and has not read the MCP spec or the web app. The frame is the maintainer's ask of 2026-09-16 ("a settings page for all our app settings that we want to let customers configure, an MCPs section, and what is configurable today with small effort"); the reader and the deadline are assumed.
Success criteria: (1) a signed-in admin adds, connects and removes an MCP server at the org and channel tiers from the browser without a credential ever passing through the page; (2) a signed-in admin sets and clears a channel's agent, models, effort, boundary and instructions from the browser; (3) every write the page makes is a command the registry already exposes, decided by the policy table with the viewer's grants, so chat, CLI and the page cannot disagree; (4) a customer can see in the product which knobs are configurable at run time, which only through config.yaml, and which subsystems are off in their installation; (5) a viewer without the grant sees the page read-only, and a write they force through anyway gets the refusal the command's handler gives on every surface.
TL;DR
Switchboard has 8 runtime-settable keys per config scope and 28 top-level config.yaml keys, every runtime one already a typed command with an HTTP twin at /api/<group>.<verb>, and no page that shows any of it; the MCP spec's roadmap defers the dashboard page. The bet is that the settings page is an adapter over the registry and nothing more: the page renders config show and mcp list, every write is a POST /api/config.* or /api/mcp.* call, and the page carries no rule of its own about who may do what. The one tier the dashboard does not offer is me, because a browser session is access:<sub> and a run is requested as slack:U…, so a personal setting written from the dashboard lands on a scope no run reads; today the API accepts that write, and this record closes it in the handlers, on the Access surface, for the config and MCP commands alike. It costs one new read (which channels carry a scope, filtered by the viewer's right to read each) and one read-only projection of the running config built by allow-list. Decided: the shape above, the three sections (MCPs, Channels, Installation), and that the org tier for agent, model and effort, which the store holds today for MCP servers only, lands in a second PR under this record and adds a Workspace tab; open: whether an Access email should ever be mapped onto a Slack user so me can come to the dashboard.
Today at 95b4face
The delta from what a veteran expects, each with its proof.
- Every registered command already answers over HTTP for a dashboard identity at
/api/<group>.<verb>, with no per-command code: writes are POST-only, same-origin, JSON (src/channels/commandHttp.ts). A settings page therefore needs no new write route. - The browser's caller id is
access:<sub>(commandHttp.ts,callerIdFor), andconfig set mewritesoverrides.users[caller.id](src/core/commands/config.ts, themebranch ofconfig.set). A run's requester isslack:U…, and nothing ever dispatches a run as an Access identity. The two never meet: the store has no link from an Access subject to a Slack user. Two reads bridge the gap by email match: the costs page's "my runs" (src/core/costs.ts,emailOfSlackUser) and the MCP connect ticket, which only the Access session with the requester's email may complete. - A browser session holds every command group's read and never a write unless
grantsname it (src/core/authz/grants.ts,browserReadActions). The policy rows that admit channel and org writes are grant rows (src/core/authz/policy.ts, theconfigandmcpsections), so an admin's grants entry already opens them. Themerow forconfig:writeadmits every actor of kinduser, and an Access browser session is that kind (src/core/authz/actor.ts,kindFor):POST /api/config.setwith scopemewritesoverrides.users["access:<sub>"]today, a scope no run reads, and a test pins it as deliberate. - The MCP client half is complete: servers live on the three config tiers; the
mcpcommands ask the policy table two questions (org and channel) and hand the answers to the service, whosetargetrefuses a tier the caller may not manage (src/core/commands/mcp.ts,actorOf;src/mcp/service.ts,target); credentials never travel through a command, andaddandconnectreturn a link to an Access-gated connect page (mcp-tools.md items 13 to 19). The spec's roadmap defers the dashboard page. - The web app has no channel context and no design-system input: navigation is full page loads over a server-rendered seed, the nav is one capability-gated list (
web/src/lib/navSections.ts), and no Nuxt UI input component is used anywhere underweb/src(one native search form exists). - A channel's scope (
agent,model,models,effort,efforts,boundary,instructions,mcpServers) comes from two places layered: the static scope is thechannels.<id>block inconfig.yaml, read once at start; the runtime scope is the same shape in the overrides document{ channels, users, org }the state Worker holds andconfig setwrites (src/config.ts,Scope,Overrides). The org tier of that document is read formcpServersonly (orgScope,mcpServersFor);resolvenever opens it. No command lists which channels have a scope. - Whether a viewer may read another channel's scope is already a policy question with two answers (
src/core/authz/policy.ts, theconfig:readrows onconfig-scope): by theconfig:writegrant, or by membership asked for a pointing actor, whose one fact is the channel's visibility from the channel directory (the bot's Slack channel lookup;unknownwhen it is absent, slow, or the channel does not exist) and whereunknownreads as private.
The shape
Amended 2026-09-16 (while proposed): settings is chrome, not a fifth section. The way in is a cog beside the docs link in every page's header, lit on /settings, and the site nav keeps its four sections; the maintainer asked for the conventional icon after the first deploy. Nothing else in this record changes.
The settings page is /settings, reached from the header's cog, three tabs, each a full page load over a seed the bot renders from the viewer's own actor: MCPs is mcp list as a table with an add form, a connect link and a remove button; Channels is a picker over the channels whose scope the viewer may read, then config show --channel as a form whose Save is config set channel and whose Clear is config clear channel; Installation is a read-only table of the running config.yaml behaviour knobs, each with its current value and the sentence that says how it changes, plus the capabilities that are on. Every button on the page is one POST /api/<group>.<verb>; the page then reloads and shows what the registry now says. The page carries the viewer's write rights only as a boolean the seed computed with the same authorize call the handler will make, and uses it to disable controls, never to decide.
The closest known shape is the GitHub repository settings page over the REST API the gh CLI also uses: one API, two clients, and the page has no capability the CLI lacks. The one way this differs is the missing tier: GitHub's page can show "your" settings because the browser session is the same identity as the API token; here it is not, so the dashboard shows the shared tiers and points at chat for the personal one.
One trace: an admin adds an org-tier server for the coding agent from the browser
- An admin whose own
grantsentry, keyed by their Access subject, is{ actions: all, channels: all, repos: all }opens/settings/mcps. The dashboard gate verifies the Access JWT; the view resolves the actoraccess:<sub>with the grants table. - The view calls
McpService.list(actor, undefined): the org tier plus the actor's own user tier, which is empty since nothing was ever written underaccess:<sub>. It computescanWrite.org = authorize(actor, "mcp:write", config-scope org).allow, true because the policy row for org servers asks for the repo-management grantrepo:write(the table's spelling of "admin" for a person: an org server reaches the coding and review agents, the same blast radius as onboarding a repository) andallholds it. - The seed carries two rows (
lake, static, org) andcanWrite: { org: true, channel: true }. The page paints the table and an enabled add form with scope fixed toorgorchannel, nome. - The admin submits
name=notion,url=https://mcp.example.com/mcp,scope=org,agents=general,coding. The page postsPOST /api/mcp.addwithcontent-type: application/json, same origin. commandHttprefuses a foreign origin, buffers the body under the cap, maps it onto{ args: { name }, options: { url, scope, agents } }and invokesmcp.addwith the caller.- The handler builds
McpActor { orgAdmin: true, channelAdmin: true }from the same twoauthorizecalls the seed made, and the service'stargetacceptsorg.authwas omitted, so the service detects OAuth from the server and writes the entry intooverrides.org.mcpServers.notionon the state Worker, then returnsconnectUrlfor a ten-minute single-use ticket. - The page shows the row as
awaiting_credentialwith the connect link. The admin opens it: the existing no-script connect page under the same gate signs them in with OAuth and completes the ticket. - The next
agent:codingrun in any channel callstoolsFor("coding", …); the org tier is the only tier allowed to namecoding, the entry is there, andmcp__notion__*tools join the run within the five-minute discovery cache. - A second viewer opens the same page in an installation where neither their own subject nor
access:*(the one entry that grants every browser session at once) appears ingrants: the seed sayscanWrite: { org: false, channel: false }, the form is disabled, and the row list is the same org tier. If they post anyway,commandHttpanswers 403 withaccess:<sub> is not allowed to run mcp.add, the text every surface gives a caller withoutmcp:write. - The same viewer replays the request with
scope: "me": the handler answersunauthorizedwith the sentence that personal settings are set in chat, because the caller's surface isaccessand no run is ever requested as an Access identity. The same sentence answersPOST /api/config.setwithme.
The property this proves: the page never held a credential, never decided an authorization, and wrote nothing chat could not have written with the same grants; and the one write the API accepted that no run would read is refused where the handler decides, not where the page draws.
The difficulty map
- Identity. A
metier from the dashboard writes to a scope no run reads. Handled in The tier the page does not offer. - The channel index. No read lists which channels carry overrides, and the one we add must not leak a private channel's instructions to a member of another channel. Handled in The channel picker. The spec row that describes the overrides document as
{ channels, users }(routing-and-config.md item 12) predates the org tier and is corrected in the same PR. - The installation projection. A read-only view of
AppConfigleaks an env var name or an internal URL if built by filtering. Handled in Installation, by allow-list. - (most work) Forms. The web app has no input component and every new file under
web/srcre-hashes the screenshot manifest. Not a design risk; one sentence in Boundaries.
The tier the page does not offer
The constraint: config set me and mcp add --scope me key on the caller id, and the dashboard's caller id is access:<sub>. ConfigStore.resolve reads userScope(requester), and the requester of a Slack run is slack:U…. So a personal setting written from the dashboard is stored, is reported back by config show on the dashboard, and changes no run. That is worse than a refusal: it is a setting that lies.
The design: the page offers org and channel and says, in the place the me control would be, that personal settings are set in chat with config set me and mcp add, because that is where the identity is. The seed carries no user scope. And the handlers close the hole the page would otherwise only paper over: config set|clear|instructions me and mcp add|connect|remove --scope me (the MCP default) answer unauthorized for a caller whose Caller.kind is access, naming the reason, since an Access identity never requests a run and the scope it would write is read by nothing. Caller.kind is the surface the command arrived on (chat, cli, mcp, access); the access kind covers every identity the dashboard gate produces: an Access browser session, the configured actor of the token dashboard strategy, and the loopback operator of the none strategy. None of the three ever requests a run. A chat person's me and the CLI's cli:local are untouched. The write was admitted until now on the reasoning that it is harmless (a person pointing themselves at a restricted agent still meets the run-time agent gate), which is true and beside the point: harmless is not the same as effective, and a settings page makes the difference visible. This is a data decision inside the handler (command-registry.md item 22), not a policy row: the policy table speaks of actors, and the fact here is about the surface.
Invariants: no request from /settings carries scope: "me"; a me write from an access caller is refused on every surface before the store is touched; config show's user field is not rendered. Failure mode: a customer expects "my model" on the page and does not find it; the page says where it is.
The alternative it beat: map the Access email to the Slack user, as the costs page does for its "my runs" filter and as the connect ticket does to let the requester complete their own sign-in. Both are narrow: the first is a read, the second lets a person finish a write they themselves started in chat, keyed by a ticket that names them. A general mapping would make an Access session able to act as a Slack identity for every write row on the strength of an email match, a new authority path in a system whose third invariant (AGENTS.md, "Authorization is one table") says adapters resolve identity and never authority. It is the open question below, not a default.
The channel picker
The constraint: a chat caller has an origin channel; a browser has none, and config set channel on a machine surface requires --channel <id>. A customer opening the tab wants to see the channels that are configured, not remember an id. The registry has no read that answers "which channels have a scope"; the document is one JSON object the store already holds, and the static scopes are in the loaded config beside it.
The design: one new read command, config overrides, on every surface, under the existing config:read action (no new policy row): the channel ids that carry a static or runtime scope, each with the names of the settings it carries (never the values), filtered to the channels the caller may read by the same per-channel config:read question config show --channel asks. The Channels tab lists them; picking one loads /settings/channels/<id>, whose seed is config show --channel <id> and whose forms post config set|clear|instructions channel --channel <id>. Below the list, an id field admits a channel that has no scope yet, the one case the index cannot list; it goes through config show's own refusal. The seed is built by invoking the same commands the CLI would, so the page cannot see a channel the CLI could not.
Invariants: the index names settings, never values; a channel the caller's config:read denies is absent from the index, not shown greyed; the index for an actor with the config:write grant needs no directory lookup at all. Failure modes: the directory is slow or down, so every membership answer is unknown, reads as private, and the index shrinks to the channels a grant admits, which is fail-closed; an installation with hundreds of configured channels makes one directory lookup per channel for a non-admin viewer, in parallel, each awaited at most the 1.5 seconds the run stamp allows, and the admin case, the common one, makes none.
The alternative it beat: derive channels from run history. It lists channels that ran something, not channels that are configured, and it needs run history, an optional capability, to be on.
Installation, by allow-list
The constraint: 28 top-level keys are read once at start; a customer asking "why does routing pick that" or "how long are runs kept" has nothing to look at but the YAML they may not have. Most of the interesting values sit next to a secret's env var name or a Worker URL in the same block.
The design: a pure function from AppConfig and Capabilities to a list of rows { key, value, isDefault, how, note } where every row is written out by name in the function body, like row("ship.maxRounds", config.ship?.maxRounds, 3, "config", "review rounds a ship unit may take"): the configured value when the key is set, else the default marked as such. A row is added by adding a line; nothing is iterated from the config object (the one map walked is defaults.models, whose keys are preset names and whose values are model refs). how is one of two sentences: "runtime, config set" for a key the scopes carry, or "config.yaml, then deploy config and deploy restart" for the rest. Capabilities render as an on/off list with the sentence that turns each on, from the how-to that already exists. The test asserts that the rendered rows, joined, contain no string ending in Env, no https://, and no key from the manifest of bot secrets.
Invariant: the projection is a function of the two values and nothing else, so a new config.yaml key is invisible until someone names it, and the test names the secret shapes that must never appear. Failure mode: a knob is missing from the table; the page under-reports and the fix is one line.
Why not X
Why not a settings store of its own, edited by the page and read by the bot? The registry commands already write the one durable overrides document on the state Worker with optimistic versioning; a second document would need the same seam and would let the page and chat disagree.
Why not let the page edit config.yaml through deploy config? That command is CLI-only by design because it pushes a document the next restart reads; a page that says "restart to apply" and cannot restart is a promise the product does not keep. The Installation tab shows the value and names the route instead.
Why a page at all, when chat and the CLI already do every write? Because nothing shows the shared state in one place: config show describes one channel from inside it and mcp list one viewer's tiers, and the config.yaml knobs are visible only to whoever holds the file. A customer asking "what can I configure" has today no answer inside the product; the page is that answer, and it adds no write the registry lacks by design.
Boundaries
The page does not manage grants or restrict (no runtime store; parsed once), agent presets (compiled data), notifications (nothing exists), ingress tokens or any *Env knob (secrets), or repositories (the Residents page). It does not add a transport picker: only Streamable HTTP exists. The first PR ships the shared tier that exists today, the channel, and the MCP tiers; the org tier for agent, model, effort, boundary and instructions is the second PR under this record, because resolve reads overrides.org for mcpServers only and config set has no org scope, and when it lands the page gains a Workspace tab. The forms are native controls in the house classes, as the one search form already is, and the screenshot manifest re-renders once; both are cost, not risk.
What would change our mind
If customers set personal settings far more than shared ones, the missing me tier makes the page useless to most viewers; the cheap evidence is the count of config set me versus config set channel and mcp add --scope me versus other scopes in run history over a month, a number this record does not have because the counting is a run-history query nobody has run. If the count says so, the open question below becomes the next record. Everything here is reversible: the page adds a section and one read command and changes no stored shape.
Open questions
| Question | Owner | Resolves it | Needed before |
|---|---|---|---|
Should an Access email ever map onto a Slack user for writes, bringing me to the dashboard? | the maintainer | the usage count above, and a record that adds the identity link to the actor resolver rather than to the page | never for this PR |
| Does the Channels tab need channel names, not ids? | the maintainer | whether the channel directory exposes names to the bot without a new Slack scope | the second settings PR |
Validation criteria
| Criterion | Proof |
|---|---|
/settings, /settings/mcps, /settings/channels, /settings/channels/<id>, /settings/installation render the shell with a settings seed for a gated identity; anything else under /settings is a 404 | [unit] src/channels/settingsView.test.ts::parseSettingsRoute::the page, its three tabs, a channel under Channels, and a channel query on MCPs, src/channels/settingsView.test.ts::parseSettingsRoute::anything else under /settings is not a route: an unknown tab, a channel under the wrong tab, a malformed id |
The seed's canWrite equals authorize(actor, …) for org and channel, for a granted and an ungranted actor | [unit] src/channels/settingsView.test.ts::the settings view::MCPs: the seed is mcp list invoked as the viewer, with canWrite from the org and channel questions, src/channels/settingsView.test.ts::the settings view::MCPs: a viewer without the grants sees the same rows read-only; without a channel the channel right is false |
No request the page composes carries scope: "me" for an unlinked session; the user scope is never rendered for it (amended by record 0042: a session linked to its Slack person composes me and sees its person's tier) | [unit] web/src/pages/settings.test.ts::McpServersPanel::Add posts mcp.add with the org tier, the agents joined, and never a me scope, web/src/pages/settings.test.ts::McpServersPanel for a linked session (record 0042)::an unlinked session sees the person's row read-only and cannot pick me |
A me write from an unlinked access caller is refused with the pointer to chat, on the config and the MCP commands; a chat caller's me write is unchanged | [unit] src/core/commands/config.test.ts::config set::a credential needs config:write for any scope; a chat person always has their own scope; an Access browser session writes neither me(no run is its) norchannel (no grant), src/core/commands/mcp.test.ts::mcp.* commands::a me write from the Access surface is refused with the pointer to chat (record 0041); org and channel writes and every read are unchanged for it |
config overrides lists only channels the caller may read and names settings, never values | [unit] src/core/commands/config.test.ts::config overrides — the index of configured channels::* |
The installation projection contains no *Env name, no URL and no bot secret name for the full example config | [unit] src/core/installationSettings.test.ts::installationSettings::never renders an env var name, a URL, a bearer, or a manifest secret name, whatever the config holds |
The header carries the settings cog for every installation (amended: a cog, not a nav section) and the MCPs tab only with the mcp capability | web/src/components/AppNav.test.ts, web/src/pages/settings.test.ts (bound in settings-page.md item 6) |
| Human-gated: an admin adds, connects and removes an org server from the deployed page; an ungranted viewer sees the page read-only | read half posted on #1375 (release 1.238.0, the tab and its rights as the maintainer); the write half and the ungranted viewer are the maintainer's, owed on the same thread |
Accepted 2026-09-16
Re-evaluation. The bet was that the settings page is an adapter over the command registry and nothing more: it renders config show and mcp list, every write is a POST /api/config.* or /api/mcp.*, and the page carries no rule of its own about who may do what. Six PRs later it held without a page-only rule: #1375 built the three tabs, #1387 moved the way in to the header's cog, and record 0042's three PRs (#1395, #1399, #1409) and its polish (#1428) added the me tier, every tier for an admin and promotion by adding commands and options to the registry, which the page then rendered; the conformance suite grew rows, the page grew no logic. The maintainer accepted the shape on that evidence ("agree with 41, settings is a projection / adapter over command registry"), live on release 1.238.0.
What changed since the proposal, checked against the reasoning above:
- The tier the page did not offer is offered. Record 0042 answered this record's first open question: the Access email links a session to its Slack person in the actor resolver, as identity and never authority, so the page's
meis the person's and the refusal stands only for an unlinked session. The "what would change our mind" count of personal versus shared settings is moot; the validation row aboutscope: "me"is amended above, not removed. - A settings page never has "no data". The maintainer's acceptance condition: every installation has defaults and every viewer has a scope, so a page that says "no channel carries a scope yet" or "settings are unavailable" is showing the reader an internal state, not their settings. The Channels tab is to show the viewer's effective settings and the installation's defaults before any channel is picked; an empty MCP list is a legitimate state (a fresh installation) and reads as product copy; a page served without its seed is an application error and renders as one. This is the next settings PR, not a change to the shape.
- Channel names, not ids, the second open question, stays open with the maintainer; it needs a channel-name lookup the directory does not expose today.
The record's validation rows carry their proofs now (the gap markers were the state before #1375); the human-gated row names what is still owed.
Sources
- mcp-tools.md, items 13 to 19 and the roadmap line deferring the page.
- routing-and-config.md, items 2, 5, 12, 13, 16.
- authorization.md, the browser baseline and the
config-scoperows. - Record 0040, the last change to the MCP tiers.
- dashboard-routes.md, the generated
/apitable and the line that says there is no MCP page.
Amended 2026-09-17 — an unlinked session's me is its own scope
Re-evaluation, as an amendment after acceptance asks. The record was accepted because the settings page is an adapter over the command registry and nothing more: every write is a command the registry already exposes, decided by the policy table with the viewer's grants, and the page carries no rule of its own about who may do what. The refusal of an unlinked session's me was never the page's rule; it was the config and MCP handlers', decided by the data, on one fact ("The tier the page does not offer"): a browser session requests no run, so users["access:<sub>"] is a scope nothing reads, and a setting nobody reads is a setting that lies. Record 0042 kept the refusal for the unlinked session on the same fact.
What changed. Record 0043 made the browser a channel: the dashboard's chat dispatches a run as the session — access:<sub> unlinked, the person with the credential recorded when linked — and the resolver reads that user's scope like any other. The fact the refusal rested on is false for a browser session and still true for a service token, which requests no run. So the refusal is lifted where it is false and kept where it is true: meIdOf answers the linked person, else the session's own id, and only a service actor (access:svc:…) is refused, with a message that names that reason. The MCPs tab offers me to every session — the person's tier linked, the session's own unlinked — and marks the session's own rows as writable.
Checked against the reasoning. The page still carries no rule: it offers the tier and posts scope: "me"; the handler resolves whose scope that is and the policy table decides. An unlinked session passes exactly the rows it passed before — its own scope's config:write (is-self) and the browser baseline's mcp:write — and no new one. The conformance suite's one surface-bound refusal (item 25's 🚫 cell) goes with the rule, and its identical-output contract now holds for the me writes on every surface, which is the stronger form of criterion (3): chat, CLI and the page cannot disagree.
What does not regress. A linked session's me is unchanged, the person's; a chat caller's is unchanged; the audit line still names the session; a service token is refused as before, for the one reason that is still true of it. Nothing shared changes hands: the scope an unlinked session writes is resolved only by runs requested as that session.
The validation rows, as they read now. The two rows above about an unlinked session's me are superseded by these; their old proofs were renamed with the tests.
| Criterion | Proof |
|---|---|
The page composes scope: "me" for every browser session — the linked person's tier, else the session's own; the rows it may write are its own | [unit] web/src/pages/settings.test.ts::McpServersPanel::Add posts mcp.add with the tier picked and the agents joined, web/src/pages/settings.test.ts::McpServersPanel for a linked session (record 0042)::an unlinked session's me is its own (record 0043): offered and defaulted, Add posts scope me, its own row is mine and writable, the person's row read-only |
A me write from a browser session lands in the scope its runs read, on the config and the MCP commands; a service token's is refused by the data; a chat caller's me write is unchanged | [unit] src/core/commands/config.test.ts::config set::a credential needs config:write for any scope; a chat person always has their own scope; a browser session writes its own \me` (the scope its chat runs read) and not `channel` (no grant); a service token has no `me`, src/core/commands/mcp.test.ts::mcp.* commands::a browser session's `me` write lands in its own tier (record 0043); a service token's is refused by the data; org and channel writes and every read are unchanged for both` |
Amended 2026-09-17 — channel names, not ids
Re-evaluation. The record was accepted on the evidence that the page is an adapter over the registry and carries no rule of its own. Its second open question — channel names in place of ids — stayed open because no directory exposed a channel's name. It closed with the names series (#1562, #1566, #1569) in the same shape the acceptance reasoned from: a core name directory (src/core/names.ts) the adapters fill and every seed reads, so the Channels and MCPs tabs show #name with the id in the tooltip, and a channel is picked from a list a new registry command (config channels) answers — the CLI and chat get the same list, the page grew no logic. Nothing the acceptance relied on regresses: the page still renders commands and posts commands.
Amended 2026-09-17 — the tabs navigate in place
Re-evaluation, as an amendment after acceptance asks. The record was accepted for three things: the page is an adapter over the command registry with no second write path, personal settings stay where the identity is, and the installation tab is an allow-list projection. It also stated the web app's shape at the time — "navigation is full page loads over a server-rendered seed" in the delta, and "three tabs, each a full page load over a seed" in the shape — as a fact about the house, not as a ground of the decision. Record 0056 retires that fact: every page route answers its seed alone as JSON to a request that accepts application/json, the router loads it before the route resolves, and the tabs, the channel index rows and the cog are RouterLinks. Checked against the acceptance: the seed each tab paints from is still the registry's answer invoked as the viewer, built by the same view, only encoded twice; every write is still one POST /api/<group>.<verb>; nothing about who may do what moves into the page. What could regress is a stale page after a write — the page still reloads the document after a save, so it is seeded anew as the new state, and the amendment leaves that as it was. Nothing else in this record changes.