Skip to content

feat(extensions): extension platform — host user-authored apps inside Agent Code - #577

Draft
Juliusolsson05 wants to merge 23 commits into
mainfrom
feat/extension-platform
Draft

feat(extensions): extension platform — host user-authored apps inside Agent Code#577
Juliusolsson05 wants to merge 23 commits into
mainfrom
feat/extension-platform

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

Builds the platform for user-authored workflow tools that live inside Agent Code —
installed by the user, invoked from the command palette, opening their own page, backed
by a documented app API.

Reference case is a timer app. The timer is the forcing function, not the goal; the goal
is app-integrated extensions that can see and act on workspace, session, and git state
without anyone editing core app source.

This PR holds the full implementation. The first commit is investigation evidence, not
the plan — the plan file lands once the architecture decision is settled.

Status

  • Read-only investigation of every integration surface
  • Architecture decision (4 consultants running)
  • Spike: verify the chosen loading path in dev and a packaged build
  • Plan file
  • Implementation

What the investigation found

packages/workflow-mcp is already a complete extension host. Discovery, acorn
metadata parsing without execution, SHA-256 integrity, an approval store keyed to
canonicalIdentity + sourceHash, a node:vm sandbox, and a separate utilityProcess with
heartbeats and timeouts — all shipping today. What it has no story for is UI.

So the open question is narrower than it looked: not how to host user code, but how
user-authored UI reaches the screen.

Three candidates, unresolved:

Effort Satisfies "install from Settings"?
A compiled-in src/apps/<id>/ 1–2 days ❌ needs a rebuild
B runtime-loaded bundle 2–4 weeks
C iframe on a custom scheme 1.5–3 weeks

The constraints that decide it are in
docs/superpowers/specs/2026-07-20-extension-platform-investigation.md §3–§8. The two
non-obvious ones:

  • Interaction ownership is a DOM attribute query and paint order is DOM sibling
    index
    — neither survives a frame boundary, which is a direct cost against C.
  • B's real blocker is a stable injected host ABI, not CSP. React cannot be imported
    twice and app chunks are content-hashed, so every host object must be passed in.

Notes

  • window.api is never handed to an extension — 153 flat methods, no namespacing, no
    sender validation. Extensions get a narrow zod-validated bridge instead, following the
    remote protocol's "the union is the allow-list" principle.
  • Extension settings must not go in the zustand-persist Settings store; forgetting the
    version bump shipped a black-screen launch bug twice (Add settings for command picker visibility #249).
  • Related to but distinct from Host user-authored MCP servers as Agent Code extensions #244, which hosts user-authored MCP servers — that gives
    extensions agent capabilities, this gives them app capabilities.

🤖 Generated with Claude Code

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Architecture settled — Stage 1 plan pushed

Four consultants ran under orchestration run extension-platform-consult. Both Claude
consultants were given opposing briefs — one to steelman compiled-in, one to steelman
runtime-loaded — and independently converged on the same staging. That convergence is the
main reason to trust it.

Decision: Stage 1 is compiled-in, built as Stage 2's substrate.

What changed from the investigation's assumptions

Stage 2's loader is one line, not a multi-week problem. out/renderer/assets/editor.main-*.js
already contains a fully runtime-variable import() that Rollup passed through untouched, and
92 Monaco language chunks load via ESM dynamic import from file:// in production. Both halves
of the loading risk are empirically retired — by code already shipping.

Two objections to the iframe option were refuted. Paint order survives (the iframe wrapper
is a normal registry sibling), and interaction ownership survives with a marker on the host shell
(~1–10 LOC, fixes all seven consumers). The perf-baseline objection was also wrong — it only
colors a number yellow. What is genuinely broken is keyboard: every host shortcut dies while
focus is inside a frame, and Cmd+W closes the window rather than the pane. Full iframe bridge
priced at ~350–650 LOC.

The API is transport-independent. Same schemas for compiled-in, runtime-loaded, utility-process
and iframe — only bootstrap, serialization, mounting and teardown differ. So AgentCodeApiV1 gets
built now and used even while apps are compiled in.

The two rules that make Stage 2 a swap

  1. An app imports nothing from @renderer/* and receives one prop, api: AgentCodeApiV1.
  2. Every host call returns a Promise, including ones that could be synchronous — postMessage
    cannot be made sync later, and widening those signatures afterwards touches every call site in
    every app.

The plan ends with the grep that proves rule 1 held. If it fails, Stage 1 built something Stage 2
would tear out — and that is worth finding while there is exactly one app.

Stage 2 is conditional, not scheduled

Trigger: extensions shipping to people who run packaged releases. The owner runs npm start
against a local build and rebuilds routinely, so Stage 1 is sufficient for personal use
indefinitely.

📄 docs/superpowers/plans/2026-07-20-extension-platform-stage1.md

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Stage 1 implemented — all six tasks

Six commits, 28 files, +2668. Host built before its first consumer so the
infrastructure could not be shaped around one app's needs; Timer landed last.

npm run typecheck
npm test ✅ 209 files / 1187 tests
npm run build
portability grep

What's in it

  • src/main/extensions/storage.ts — per-app JSON under STATE_DIR/extensions/<id>/,
    atomic temp+rename. Ids are validated and rejected, not sanitized: an id becomes a
    directory name, so a permissive id is a traversal primitive — and sanitizing silently
    collapses ../timer and timer onto one directory.
  • apps/api/types.tsAgentCodeApiV1. Tier 0 only (storage, close, toast, theme
    tokens). Every method returns a Promise even where it is synchronous internally,
    because postMessage cannot be made sync later.
  • apps/surfaces/AppHostSurface.tsx — one surface entry, appended last in
    modalSurfaces. Apps mount inside DialogContent so they inherit the
    interaction-ownership marker that seven consumers query synchronously.
  • apps/commands/appCommands.ts — commands derived by .map(APPS), so an app
    cannot ship without an opener.
  • Settings → Apps — fourth instance of the existing self-subscribing marker pattern.
  • Timer — SVG progress ring, presets, pause/resume, deadline-based countdown.

The test that matters

$ grep -rn "@renderer/" src/renderer/src/apps/timer/
apps/timer/index.ts:      import type { AppDefinition } from '@renderer/apps/types'
apps/timer/index.ts:      import { TimerApp } from '@renderer/apps/timer/ui/TimerApp'
apps/timer/ui/TimerApp.tsx: import type { AgentCodeApiV1 } from '@renderer/apps/api/types'

Only the ABI types and its own file. The Timer directory should lift into its own repo in
Stage 2 unchanged — that claim is currently under adversarial audit.

Deviations from the plan, both found by reading

  • uiShell actions are declared in app-state/types.ts (UiShellSlice), not
    uiShell/types.ts as the plan assumed. State field went in the latter, actions in the
    former.
  • Timer styling is inline style={{ }} with var(--theme-*) rather than Tailwind
    utilities — the utilities are a build-time binding that will not exist out-of-tree.

Still open

Two reviewers running (extension-platform-review): correctness/integration, and an
adversarial audit of the Stage-2 migration claim. Findings to follow.

No manual smoke run yet — npm start verification pending.

@Juliusolsson05

Copy link
Copy Markdown
Owner Author

Full extension platform — Phase B complete

16 commits, 42 files, +5528. typecheck ✅ · 1187 tests ✅ · build

The compiled-in app approach was removed; this is now a real VS Code-style
extension system — install from a repository, declare contributions, activate lazily.

Phase B

B1 agent-code-ext:// scheme + CSP
B2 Host runtime global
B3 Manifest contributions + activation events
B4 Extension host — import, activate, disposables
B5 View bridge
B6 Declarations → palette / Settings
B7 Four review defects

Verified, not assumed

Scheme spike — real built renderer, real CSP, file:// origin:

document origin       file:            dynamic import        activate() OK
missing extension     404              relative ./sibling.js resolved
../../../.ssh         404 blocked      ..%2f..%2f encoded    404 blocked

Install — the real installer against a real repository:

id timer · ref v0.1.0 · sha256 1fb05a80… · 194 KB on disk · exports activate

Plus error paths: unknown repo, private repo, malformed input.

Manifest — the real timer manifest accepted, and nine rejection cases
including foreign-namespace ids, duplicate ids, dangling keybindings, unknown
activation events, and traversal entries.

Production build__agentCodeHost and agent-code-ext:// both present in
the shipped renderer bundle.

First real extension

Juliusolsson05/agent-code-timer v0.1.0 —
ported from focus-flow-timer, restructured so a headless engine owns the session
and the view is a subscriber. Closing the window does not stop the timer; a
wall-clock deadline means it survives an app restart too.

It is already installed on this machine and will appear on next launch.

Two bugs found by building the real thing

  • The contribution-id regex demanded kebab-case and rejected timer.inheritTheme.
    Caught by running the schema against the real manifest.
  • GlobalToast sat at z-50, under the dialog scrim at z-[1100] — every toast
    raised from inside a modal was invisible. Harmless while all callers were
    non-modal chrome; fatal for an extension whose reminders fire as toasts.

Not verified

Nobody has launched the app and clicked it. Everything above is automated
verification of the pieces. The end-to-end run is the remaining gap.

Juliusolsson05 added a commit that referenced this pull request Jul 28, 2026
…idate ledger rows

Three known #577 defects, cleared before building the contribution wiring on top.

1. Update button was a no-op. onClick did `setRepo(entry.repo); void install()`, but
   setRepo is async and install() closed over the OLD repo, so Update installed the
   empty/last-typed value. install now takes an explicit target
   (`install(target?: string)`), and a new `update(entry)` calls install(entry.repo)
   directly — no closure round-trip.

2. Uninstall (and Update) never deactivated the live extension. AppsSettingsRow held
   no ExtensionHost reference and main has no handle on the renderer-side host, so a
   removed extension's subscriptions/registrations/intervals leaked for the session.
   The row now uses useExtensionHost() and calls deactivate(id) before removing files
   (remove) and before reinstalling over the bundle (update). deactivate works off the
   in-memory module, so it runs correctly before the on-disk bundle changes.

3. Ledger rows were cast unvalidated (`return parsed as InstalledExtension[]`). Each
   row's manifest.id/entry is interpolated into a path join and the import() URL, so a
   hand-edited extensions.json was the one way an unvalidated id/entry could reach
   those sinks. readLedger now validates every row against the manifest schema (which
   already enforces the id regex + entry `..`/absolute/backslash refinements) and drops
   malformed rows INDIVIDUALLY with a warning, never the whole ledger.

First commit of the no-sandbox tranche (plan WS0). Verified: tsc -b tsconfig.node.json
and tsc -p tsconfig.web.json --noEmit both clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@Juliusolsson05 Juliusolsson05 changed the title Extension platform: host user-authored apps inside Agent Code feat(extensions): extension platform — host user-authored apps inside Agent Code Jul 28, 2026
Juliusolsson05 and others added 11 commits July 28, 2026 21:46
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ion registry

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ngs systems

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n & capability enforcement

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 force-pushed the feat/extension-platform branch from b0a7baa to cd4a61a Compare July 28, 2026 19:47
Juliusolsson05 and others added 12 commits July 28, 2026 21:55
Iterating on an unpublished extension meant cutting a GitHub release for every
change (installExtension resolves releases/latest). This adds a local-folder
install so an author rebuilds, clicks Load folder, reloads — no release.

- install.ts: extract the shared finalizeInstall tail (consent → move → ledger →
  grant) so GitHub and local installs converge; add installExtensionFromPath — a
  SNAPSHOT copy (cp excluding node_modules/.git) through the exact same manifest
  validation + entry-containment checks as the tarball path. No tarball to hash,
  so the grant binds to the built ENTRY bytes (a rebuild correctly forces re-consent).
- ipc/extensions.ts: extract the consent dialog (shared by both paths); add
  extensions:install-path with a native openDirectory picker.
- preload + AppsSettingsRow: extensionsInstallPath + a 'Load folder…' button.

Snapshot, not a live mount — a live-reference mode is a larger scheme-handler
change left for later; a copy reuses the tarball path's containment guarantees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frame's activate() ran but its deactivate() never did: closing a view just
navigated the iframe to about:blank, so the extension leaked its intervals /
AudioContext / listeners on every close. The bootstrap now captures the module and,
on pagehide (fired by that about:blank navigation and by app quit), calls
module.deactivate() then disposes context.subscriptions in reverse order — the same
cleanup contract the same-realm host honored. Best-effort/synchronous, since the
document is being torn down (the timer's engine.dispose()/removeStyles() fit this).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sion.id in frame

The SDK's AgentCodeApiV1 was Tier-0 only while the runtime had gained the Tier-1
observe groups — an author could not type api.workspace/sessions/panes.observe.
Bumps the submodule to v0.2.0, which mirrors them. Also fills api.extension.id in
the frame bootstrap (from the frame's own agent-code-ext://<id> origin host): the
type promised it and the same-realm api already provided it; the frame did not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…echanism)

The extension modal was a fixed 560px, so a big game canvas could not get room
without making every small extension look lost in an empty box. The frame now
reports its content width alongside height; the iframe takes a definite width and
the DialogContent is content-width (up to a cap), so a large view grows the modal
to fit while a small one stays snug. Clamped 240-1200px against a hostile child.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An extension with a fixed natural size — a game canvas is the motivating case —
reports e.g. 892x652. That fits a normal window, but on a short one the modal
CLIPPED it: DialogContent has no maxHeight and is overflow-hidden, so the bottom
of the view simply vanished with no scrollbar and no way to reach the controls.

The frame now keeps its natural pixel size and is scaled visually to fit, with
the wrapper occupying the scaled footprint so the auto-sized modal reserves the
right room. Scaling rather than scrolling because the content is a single
fixed-aspect surface: a scrollbar on a game is worse than a slightly smaller
game, and clipping is worse than both.

This has to live host-side. The obvious alternative — have the extension size
itself in vw/vh — is a trap: those units resolve against the IFRAME's viewport,
which the host sets from the content's reported size, so the extension's size
would depend on its own size. That is precisely the resize feedback loop
frameDocument.ts's measurement is built to avoid.
reportSize measured root.scrollWidth, but #root is width:100% and scrollWidth
is by definition never smaller than clientWidth — so the reported width was
always at least the CURRENT iframe width. The modal could grow and never
shrink: switching from a wide view to a narrow one (an 892px game to a 375px
one) left the modal frozen at the old width with the new content marooned in
the corner.

Width now comes from the children, which carry their own intrinsic size, so
the report can go down as well as up. Height still comes from #root, which is
height:auto and therefore already measures content.

The ResizeObserver had the matching blind spot: watching #root alone cannot
see a content change that only alters WIDTH, because #root's own box is pinned
to 100% and never moves. It now observes the children too, and a MutationObserver
re-syncs that list when the extension swaps its tree — a router switching
screens replaces the child outright, which is exactly the case that was stale.
…ack palettes

The frame's only baked CSS is a margin/overflow reset — there is no theme
baseline — and onLoad pushed mount synchronously while the theme followed a
microtask later, because tokens() is async. So a view rendered against
--theme-* variables that did not exist yet.

Given that, a local fallback palette was the only defense against a flash of
unstyled content, which is why extension authors end up re-declaring the entire
token set locally instead of using var(--theme-canvas) directly. The platform
never offered a moment at which the theme was guaranteed present.

postMessage preserves order, so pushing theme first and mount second closes the
window: by the time the child mounts, the tokens are already set on its
documentElement. The catch is deliberate — a theme failure must never prevent
the view from mounting, since an unthemed extension is a bug but an unmounted
one is a broken product.
Brings the branch up to date after 40 commits landed on main (command palette
sort modes, unified command settings, UI primitive theme fidelity, dictation
history, dispatch lane removal, session lifecycle observability).

14 files were touched by both sides; 4 genuinely conflicted, all of the
"re-apply our small change onto their rewrite" kind:

- CommandKeybindingsRow.tsx — main rewrote it (+237) for the unified command
  list and Palette column. Both sides added real behaviour, so both survive:
  extension-contributed keybinding defaults still merge into the table the
  editor conflict-checks against, and the deps array is now the union.
- settingsRegistry.ts — main removed the Commands category (it moved into
  CommandKeybindingsRow), taking listPickerCommandMeta with it. Kept main's
  shape and re-applied only what extensions still need: the `extension` control
  arm and deriveExtensionSettings. getSettingsRegistry drops its now-dead
  extensionCommands parameter, and SettingsPage was updated with it — the
  params were positional, so leaving it would have passed commands where
  manifests were expected and silently rendered nothing.
- registry.ts — took main's removal of PickerCommandMeta; our per-call
  allCommandDefs concat was outside the conflict and survives.
- SettingsList.tsx — both added a marker row; kept both.

Two things git merged cleanly but wrongly, caught by tsc rather than by review:
the extension keybinding imports in registry.ts were removed with main's hunk
while the code using them survived, and deriveExtensionSettings lost its
definition while keeping its call site.

One real integration gap: main's new `grouped` sort mode sections the palette by
CommandCategory, and our `extensions` category had no entry in CATEGORY_ORDER or
CATEGORY_LABELS, so extension commands would have grouped under an unnamed
heading. Extensions sort last — third-party commands should not sit above the
app's own, matching why they are concatenated last in the registry.

Also fixes the SESSION_KINDS parity test, which was already red on this branch
before the merge. It now asserts the derivation rather than a hand-written list,
which is what the guard was always for. Review finding H3 (extension-view being
a SessionKind at all) stays open and is documented at the assertion.

Verified: tsc clean on both projects, 1818/1818 tests passing.
Six boundary defects, each independently confirmed by two or more reviewers.

Scheme-handler traversal (blocker). `url.hostname` was used verbatim as a path
segment, and the containment check below it resolves against a root DERIVED
FROM THAT VALUE — so an escape made the check certify the wrong root and
approve everything beneath it. `agent-code-ext://../extension-grants.json`
parsed to hostname `..`, rooting the handler at the whole state directory:
grants, ledger, workspace.json, and the proxy dumps that carry provider
Authorization headers. Reachable from a Tier-0 extension that triggered no
consent dialog. The id is now validated before anything else touches it.

The pattern lived in four files and was missing from the one that mattered
most, so it now lives in @shared/types/extensionId and everything imports it.
That also fixes removeExtension, which ran a recursive rm on an unvalidated,
IPC-supplied path component.

HTML injection into the frame document. The bootstrap interpolated viewId and
entry into JS source and relied on JSON.stringify, which escapes quotes and
backslashes but not `<` or `/` — so a value containing `</script>` closed the
element from inside a string literal. The entry path passed all four of the
manifest's negative refinements with that payload embedded. Config now travels
in a JSON island with `<` escaped, which removes the sink rather than filtering
it, and the view id is checked against the manifest's declared views. Covered
by a test using the reviewer's exact payload.

Child CSP was per-SCHEME, not per-origin. `'self'` already covers the document's
own origin, so the bare `agent-code-ext:` source granted only the cross-extension
case — one extension could fetch and execute another's bundle. Paired with a
wildcard CORS header on every asset. Both are now scoped to the frame's own
origin. base-uri and form-action are set explicitly because neither falls back
to default-src.

window.open egress. No CSP directive governs window.open, so a Tier-0 extension
could exfiltrate to any URL through the OS browser with no prompt. Fixed with
sandbox="allow-scripts allow-same-origin", which keeps the origin-derived broker
identity intact while killing popups, top-nav, modals, forms, and downloads. The
comment claiming sandbox was unusable was wrong — that is only true without
allow-same-origin. Enforced on the attribute rather than in setWindowOpenHandler
because Electron's HandlerDetails has no frame field and referrer is suppressible.

viewBridge's side channel authenticated on one gate while claiming parity with
frameHost's two. contentWindow is identity-stable across navigations, so source
alone could not tell a self-navigated frame from the original; it now checks
origin too.

__proto__ storage keys silently vanished instead of failing, because the write
hit Object.prototype's setter rather than creating an own property.

Adds 12 tests over a boundary that previously had none.
Undo Close was permanently brickable (the review's only CRITICAL). Both undo
paths called spawn() for every kind, main rejects an extension-view spawn, the
catch turned that into 'retryable-failure', and undoClose PUSHES A FAILED ENTRY
BACK — poisoning the stack head so every later Cmd+Shift+T popped the same
entry, failed, re-pushed, and returned. All older undo history became
unreachable for the rest of the session. Scenario: split a panel view into a
pane, close it, then accidentally close a Claude pane — undo never works again.

The tab path was worse. It spawns leaves in order, so hitting an extension-view
leaf threw partway through and the rollback then killed every sibling it had
just spawned: undoing a tab containing one extension pane started N real
claude/codex processes plus proxies, killed them all, restored nothing, and
poisoned the stack. A process-less leaf is now restored rather than spawned, and
deliberately not added to spawnedIds so rollback does not try to kill it.

Bury/revive and detach/attach stranded a pane forever. isSessionKind accepts
'extension-view', so ensureSessionLive sailed past its unsupported-kind guard,
called recoverSession, and threw on main's rejection. Bury and
Detach-to-Dispatch are both ungated commands, making it a one-way trip with Kill
Buried as the only exit. Fenced at that single choke point, which covers all
three callers (Revive, Attach Detached, Attach All).

A rehydrated pane claimed its extension was missing. installedExtensions starts
empty and is filled by an async IPC whose failure path deliberately leaves the
store untouched, so "still loading" and "not installed" were the same state:
every reload flashed a false message, and one failed extensionsList() made it
permanent — sending the user to reinstall something that was fine. The store now
records whether the list ever loaded.

Extension panes could not be focused by clicking them. The leaf declared
focused/onFocusRequest in Props and used neither, while every sibling leaf wires
onMouseDown — so clicking an extension pane left focus elsewhere and Cmd+W closed
the wrong pane. The cross-origin iframe still swallows mousedown over its own
content, so this catches the surrounding gutter: partial, but strictly better
than unfocusable.

A panel view's action command opened as a modal, contradicting its manifest,
because the cold-activation fallback called openApp() unconditionally while the
targetView branch beside it already honoured the declared mount.

Also validates the persisted extensionViewId against the manifest's declared
views. It is an unconstrained string restored from workspace.json, and trusting
everything after split('.')[0] let any "victim.anything" mount a live broker for
victim.
The host document permitted agent-code-ext: in script-src, connect-src, and
every asset directive. That existed solely to serve ExtensionHost.activate() —
a host-realm import('agent-code-ext://…') that evaluates third-party extension
code in the renderer's own realm, where window.api exposes every IPC handler
including extensions:install and extensions:remove. That path has no callers;
command execution moved into the sandboxed frame. So the concession bought
nothing and left the renderer permitted to run extension code directly, which
is precisely what the iframe design exists to prevent.

The scheme now appears in frame-src and nowhere else. The child serves its own
assets under its own far stricter policy. The old rationale block described
directives that no longer grant it, so it is gone rather than left to mislead;
what replaces it says that frame-src is the directive doing the containment
work, which the previous comment never mentioned.

Install staged into the OS temp dir and committed with rename(staging, final).
rename cannot cross filesystems, so wherever /tmp is its own mount — Linux
tmpfs, the common case — it fails with EXDEV. And since the commit deletes the
live bundle BEFORE renaming, that is not "install failed" but "install
destroyed the version you had". Staging is now a sibling of the destination, so
the rename is same-filesystem by construction.
…switch

The gate was `await requireGrant(...)` lines sprinkled through a switch. Adding
a Tier-2/3 member to frameRequestSchema and forgetting its line was a one-line,
review-invisible privilege escalation — nothing existed to notice the omission.
And `perform` had no default arm, so an unhandled method fell off the end
returning undefined, which the caller reported as ok:true — a silent false
success for a capability that was never performed.

Tiering is now a Record keyed by the method union, enforced once before the
dispatch, plus an exhaustiveness assert. Verified by adding a `network.fetch`
member to the schema: it fails to compile in both places, so an ungated method
can no longer be expressed rather than merely being caught in review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant