Surface System
Status: ✅ Decided (grill sessions, 2026-07-03) — Phases 1–2 + the v0.1 query grammar (D14–D16) are implemented in
@vinxi/surface-runtime; Phase 3 (real widgets) is next. Upstream canon: Surface Platform (SUR-1..14) and the kernel LLD §17.1/§21/§22 — both in this repo since the docs reorg. The shells/slots/planes design is in UI Framework & Lexicon. This doc records how this repo realizes that vision, and the deltas we consciously take.
The thesis
Applications on Vinxi are Surface documents, not code: declarative trees whose data slots
are query references, whose buttons are Action references, and whose form fields are trait
references. One headless TypeScript interpreter — @vinxi/surface-runtime — resolves a
document into a render tree (data subscriptions, capability visibility, dial state), and thin
per-form-factor Shells render that tree through a widget registry. Shells ship once; realities
— and agents — ship Surfaces.
The NetworkAccess NOC demo is the forcing function: today it is journey #1 hand-coded
(apps/network-access/src/components/noc/*, focus in useState). It becomes the first
Surface document, the first golden fixture, and the proof that “adding a screen” means
authoring a document.
What this repo already provides
| Vision concept | In hadar today | Disposition |
|---|---|---|
| Slot engine | @vinxi/tunnel (Portkey) | Keep — the motor |
| Stage plane (persistent twin) | @vinxi/maps-ui — one deck.gl instance, declarative <MapLayer>, camera survives navigation | Keep — map projections render through it |
| Workspace archetype seed | @vinxi/app-ui Items framework | Generalize into projection widgets |
| Composition seed | app-ui dashboard-page | Later (v0.1 node) |
| Shells | app-ui workspace-layout + mobile-layout (parallel files, divergent slot names) | Rework into one slot contract + two thin Shells (D3) |
| Package/install machinery | @vinxi/client ExtensionsRegistry | Surfaces install through it (D2) |
| Reactive spine | @vinxi/client (atoms, molecules, reactor — pure TS) | Runtime state lives here (D2) |
| Theming | @vinxi/design tokens via CSS vars | Keep — provider pattern |
| Leaf widgets | @vinxi/ui (shadcn + terminal set) | Widget raw material |
| The seam | crates: six verbs declared; Act(RegisterResourceVersion) only; no IR-2, no capability vocabulary, no sdkgen SDK | The long pole — v0 runs on fixture adapters (D5, D6) |
Decision record
Format: decision → rationale → consequence. All decided 2026-07-03 unless noted.
D1 · Web-first here, headless-enforced — amends SUR-2 for this repo
The runtime’s first consumer is the web app; mobile remains the reference target. SUR-2’s intent (the interpreter must not absorb platform assumptions) is enforced mechanically instead of by build order: the runtime package forbids DOM/browser imports (lint + dependency rules), golden fixtures (SG-3) are the platform-free contract, and the storage/platform interfaces ship in the contract from day one even though web doesn’t implement offline. Consequence: web-first risks DOM leakage; the guardrails above are non-optional, and the future Expo shell is the audit.
D2 · The runtime builds on @vinxi/client
@vinxi/surface-runtime registers as VinxiClient services: dials and resolved trees are
atoms (reactor-observable), Surfaces install through the ExtensionsRegistry, query caching
uses the client’s QueryClient. @vinxi/client is platform-free (jotai, mitt, TanStack — all
RN-safe), so the mobile shell carries it too. Dependency is strictly one-directional:
surface-runtime → client, never the reverse.
D3 · Two thin web Shells over one canonical slot contract
Canonical slots (stage · primary · focus · aside · drawer · hud · command · nav · status)
are defined once; DeskShell (floating/resizable panels, ⌘K, sidebar) and PhoneShell-web
(full-bleed stage, snap-point drawer, push pages, bottom bar) are thin compositions selected
by viewport class. This replaces workspace-layout/mobile-layout, realizes “interpret the
UI differently for mobile and web” as shells per form factor, not CSS squishing, and is the
one-runtime-many-shells proof before React Native exists. The future Expo PhoneShell reuses
the same composition contract.
D4 · Surface IR v0 node set
Page · Workspace (collection + projections map/list/table + focus contract) · DetailCard · Form, plus reference types (QueryRef, ActionRef, TraitRef) and the TaskInbox
Workspace preset. Form is in v0 deliberately even though the NOC barely needs it: the
kind→widget offer table is one of the three consequences that make IR-3 real, and it must not
be retrofitted. Deferred to v0.1: Composition, Panel, and the cards/board/timeline/chart
projections. Schema source of truth is zod (repo-native), with a JSON Schema projected
from it for editor tooling and agent authoring. Documents carry
irVersion: "surface-ir/v0"; the runtime refuses unknown majors — loudly at install, never
blankly at render.
D5 · A fixture mini-IR-1 (Realm Schema) ships now
Install-time validation and kind-driven widget offers are meaningless without an ontology,
and the kernel has no P0/IR-1 yet. So v0 ships a small zod-defined Realm Schema format —
entity types, interfaces, traits with parameterized kinds (geo(point),
categorical(scheme), media, temporal, numeric, graph-edge), actions with capability
requirements — and a hand-authored networkaccess realm covering the NOC’s joints, cables,
and work items. The runtime’s install step validates Surface→realm; the offer table keys off
real kinds. The format is shaped to match what IR-1 will later project, so the kernel swap
replaces a file, not machinery.
D6 · QueryRefs are structured objects
A Workspace collection contains { iface | entityType, filters, params } as a zod-validated
object, with binding expressions ($session.actor, $dials.focus, $dials.altitude) as
typed string literals — the closed set of scalar bindings, validated at parse. Object-valued
dials ($dials.viewport, $dials.time, $dials.layers) are rejected in filter positions:
filters are equality predicates and an object has no meaning under equality — spatial/temporal
filtering by viewport awaits IR-2 operators (amended after adversarial QA, 2026-07-03). Not the
illustrative ir2:// URI grammar — field-level validation gives agents precise authoring
errors, and we don’t ratify a URI syntax the kernel team hasn’t designed.
This is the schema’s most seam-exposed region: when kernel IR-2 and the sdkgen SDK land,
the ref shape is renegotiated against what the SDK actually emits.
(Partially superseded 2026-07-03 by D14–D16: the trafficure stress-test grill replaced
equality-only traitPayload with a where condition list over a closed operator set, and
opened the binding set to document-declared dials. The seam-renegotiation clause stands.)
D7 · Capability visibility with a stub session
The realm schema’s actions carry capability requirements; a stub session carries an
acquirable-capability set; the runtime computes per-action state. Documents may set
onDenied: hide | disable per action reference; the default is disabled-with-reason
(matching the worked example in the surface platform doc). No role conditionals exist
anywhere in shell or widget code — this invariant starts now, not when the kernel enforces it.
D8 · Dials: full object now; viewport is in the URL
The runtime owns the full SUR-8 object {focus, altitude, time, layers, arrangement} and its
canonical string grammar; the NOC exercises focus and layers immediately. The Stage
viewport is URL-encoded, Google-Maps style (@lat,lng,zoom[,pitch,bearing]), written via
debounced replaceState so panning never spams history; discrete dial changes (focus,
layers, altitude) push history — back is dial history. Defaults elide from the URL. This
resolves the Lexicon’s open fork #1 residual for web: a shared NOC link reproduces the exact
operating picture, and agents direct spatial attention through inspectable state.
D9 · The widget registry is typed React component maps
Node kind → widget, projection kind → widget, and the trait-kind offer table
(geo(point) → map pin/picker, categorical(scheme) → select/tree, media → gallery +
capture, temporal → date/range). Registry entries are React components typed against
resolved-node props — legitimate because both target shells (DOM and RN) are React; platform
freedom lives in the runtime, not the registry. Widgets are dumb by contract: resolved data,
capability state, and tokens in; focus(entity) · dispatch(actionRef, payload) · setDial(…)
intents out. The package-widget (sandboxed) registration point is typed but not shipped
(SUR-10 unchanged).
D10 · NOC re-expression: full parity, including the incident
The fixture data adapter plays kernel: it serves the generated fiber network, advances
WorkItem/entity state along the scripted incident timeline, and handles dispatched Acts
(approve-reroute / dispatch-crew) by advancing the script. This exercises the entire loop —
subscribe, focus, capability-gated actions, dispatch, state change — not just the read path.
The agent chat panel is agent-plane (hud slot) shell content, not Surface document
content. At parity the hand-coded /home is deleted (strangler); no permanent hand-coded
twin.
D11 · Surfaces are JSON documents
apps/network-access/surfaces/noc.json (+ generated $schema for editor validation),
validated at install and in CI. Not TS fixture objects: “documents, not code” is the point —
diffable, package-shippable, emittable by an agent with no TS toolchain. Install path in v0:
the app registers its Surface documents through the ExtensionsRegistry at boot.
D12 · Golden fixtures are the merge gate (SG-3, realized)
Runtime tests snapshot resolved render trees as stable JSON — node kind, selected widget, data-slot resolution state, capability state — across scenario variations (dial positions, capability sets; offline-flag scenarios join the suite with R1, alongside the first offline implementation). The NOC Surface is fixture #1. A green suite gates every runtime change and is the compat proof for every IR version bump.
D13 · Package layout
Two new packages in web-modules/:
web-modules/surface-runtime @vinxi/surface-runtime src/ir/ zod schema for Surface IR v0 + JSON Schema projection src/realm/ Realm Schema (mini-IR-1) format + install-time validation src/runtime/ interpreter: resolve() → render tree; subscribe() facade; action dispatch; capability computation src/dials/ typed dial object + canonical grammar + URL codec src/registry/ widget registry contract types (subpath export) src/adapters/ DataAdapter interface + fixture adapter utilities
web-modules/surface-widgets-web @vinxi/surface-widgets-web DOM implementations adapting maps-ui (map projection → Stage), ui + app-ui Items (table/list), ui primitives (DetailCard, Form inputs)Shells are not a third package: DeskShell + PhoneShell-web land inside @vinxi/app-ui as
its canonical-slot rework — app-ui is the designated chrome home, and the shells reuse its
Items/Dashboard/command-bar code.
D14 · Strict mirror: query grammar = seam spec (grill, 2026-07-03)
Resolved kernel-side (2026-07-03): the renegotiation this decision anticipated has happened — the kernel ratified QuerySpec v3 as the wire surface (condition tree, four doorways, streaming delivery, shared conformance suite) in ADR-0028; the grammar reference now lives at Query Surface. v2 specs parse forever (flat
where={all: […]}sugar); the strict-mirror pledge below is enforced by the shared golden-pair suite both CIs run.
The document query grammar never exceeds what the query seam executes. Every grammar
addition is written as a QuerySpec v2 extension on @vinxi/client/kernel:
FixtureKernelClient implements it in-memory now, kernel IR-2 inherits the exact shape
later. The runtime and widgets NEVER filter/sort/aggregate fetched rows client-side —
one query semantics everywhere is where cross-system predictability is won (Grafana’s
format-specifier mini-language exists only because variable values splice into query
strings; structured bindings avoid the class by construction).
Filters are a flat where: [{trait, field?, op, value}] list (AND-composed; traitPayload
left the doc grammar). Ops are the closed set of nine — eq neq in gt gte lt lte within contains — each validated against the trait’s parameterized kind at install. Sort is a
multi-key array [{trait, field?, dir}]; absent sort means the seam’s deterministic
default order (stable ascending entityId — never random). limit is authorable;
cursor is runtime-owned; traverse stays out until a screen demands it.
Aggregates are seam-side by the same doctrine (aggregate(spec): over/bucket/
groupBy/measures — over is a full QuerySpec, so dials and guards flow into every
aggregate for free); chart/stats projection grammar waits for R3 rendering evidence.
D15 · Declared typed dials (grill, 2026-07-03)
Documents declare their own dial vocabulary: dials: {id: {kind: categorical|text|sort, options?, default?, multi?, presents?}}. The binding set opens from a fixed list to
built-ins + declared ids ($dials.<id>, flat namespace, built-in names reserved) —
binding SYNTAX is checked at parse, binding SEMANTICS (id exists, dial kind permits the
operator) at install. Dial VALUES stay runtime-owned and URL-encoded (d.<id>=; absent
= declared default, explicit empty = cleared). D8’s doctrine holds and extends: values
are shell state; vocabulary beyond the universal built-ins is document-declared.
Unset ⇒ vanish: a condition whose bound dial is unset/cleared is omitted from the
executed query (“no filter chosen = unfiltered”) and recorded in the resolved tree’s
dropped — the X-ray shows exactly what the query does not contain.
D16 · Scoped guards: one mechanism for segments, tabs, and column variance (grill, 2026-07-03)
A container (Workspace, DetailCard) declares at most one scope dial (single-select
categorical). when: [values] — set membership against the container’s scope, never
expression strings — may guard where-conditions, list/table fields, and DetailCard
sections. Query segments (live/historical), detail tabs, and per-segment columns are
the SAME grammar; the resolver emits one concrete tree per dial state. Rendering is
offer-driven (tabs/select/chips/tree/hidden) plus a presents hint — idiom, never
semantics — and presents is the uniform hint keyword everywhere (dials AND actions:
auto|modal|inline). Scope dials are dials: every tab state is URL-shareable and
agent-drivable — no widget-local tab state, no agent backdoor.
Future guard complexity becomes a declared derived boolean dial (Workshop’s fork),
never VS Code-style inline expression strings over undeclared keys (silent-false is the
documented failure mode).
D17 · Realms are vocabulary namespaces, not binding boundaries (2026-07-03)
Cross-realm UI, entities, and traits are the norm: realms are DDD bounded contexts
in the ontology (P0), never runtime isolation. Documents validate and resolve against
the union of installed realms — safe by construction, since every reference is
realm-qualified (trafficure.Alert, networkaccess.Joint) and keys cannot collide.
A document’s realm dependencies are derived from its refs; each referenced realm
must be installed, reported as ONE deduped issue per missing realm (“realm X is not
installed — referenced via Y”) instead of per-ref unknown-noise — the original
“wrong universe” signal without the single-realm restriction it was entangled with.
doc.realm is the home namespace: provenance, never a gate — requiring the home
realm would conflate realm with package, the OS-level install unit (vocabulary +
Surfaces + policies shipping together; frontend modules/apps are userland composition).
The G7 manifest round owns the package question and may rename the field.
D18 · Subpages: within-surface drill-ins on the page dial (grill, 2026-07-03)
Amended 2026-07-04 by the App System (A2): the subpage chain moves from the
pagedial into path segments (/<app>/<surface>/<sub>/<sub>…); the reservedpagedial and barep=retire, andp.<region>=is reserved for parallel subpage stacks. Chain semantics below (shared dials, nesting, truncation, breadcrumb, presents) are unchanged — only the address moves.
A Subpage (G5) is a within-surface drill-in: a child Page node — full page powers
(workspaces + forms with scope dials + guards) — that shares the document’s dials,
so binding $dials.focus threads the parent’s focused entity with zero new grammar
(citypulse advanced-insights, a road deep-dive). A drill-in that stands alone with its
own dial vocabulary is a separate Surface reached via nav (D19), never a subpage —
the split is by ownership. Subpages nest to any depth; the active chain lives in the
new reserved built-in dial page (URL p=a/b/c). Being a discrete dial, changing it
pushes history — the back button closes drill-ins (dial history = nav history, D8),
and deep links + agent-driving come free; this also resolves the Lexicon’s route-grammar
residual for web (one URL grammar, all dials). presents: auto|modal|page per level
(deepest wins; phone push-stacks, desk may flatten) — idiom, never semantics.
Hostile tails truncate to the deepest valid prefix at resolve — never a throw, never
a blank (D4); the resolver emits the active chain as a breadcrumb and resolves only the
deepest active subpage’s children. Structural id-uniqueness (per level) is a parse
concern; children reuse every existing compat rule.
D19 · Packages install; nav is a derived, capability-gated registry (grill, 2026-07-03)
Amended 2026-07-04 by the App System (A4/A6): the module (cross-package nav bucket) retires — packages now
provideapps, and an app’s roster carries the nav metadata that lived onSurfaceEntry.nav. Nav stays derived and capability-gated exactly as below, computed per app roster (plus the launcher across apps); computed cross-surface navigation (S6/openIn) now yields(app, surface)pairs. Install-as-Act, no-requires, and the loud install gate stand.
A package (G7) is the OS-level install unit. Its manifest declares only what it
provides — {realms[], surfaces[] (+nav metadata), widgets[] (T1), modules{}} —
with no requires list: a Surface’s realm dependencies are derived from its refs
(D17) and resolved against the union of everything installed, so declaration/reality
drift is impossible rather than linted. The install gate validates every surface against
that union and fails loudly naming any missing realm. Installing/upgrading/removing a
package is a kernel Act (T9) — audit and rollback are native.
The app shell’s nav is derived, never authored: f(installed surfaces × nav metadata × session capabilities), rendered by the nav slot. Surfaces declare {module, label, icon?, order?, capability?}; packages declare module metadata; the same module id across
packages merges (union, like realms). A surface whose capability the session cannot
acquire is hidden from nav (unlike actions’ disabled-with-reason — personas shouldn’t
see modules they can never enter). Cross-surface entity navigation is computed (S6):
the registry knows each surface’s collections, so focusing an entity yields “Open in X”
affordances for every surface whose collection can hold it, carrying the focus dial —
installing a package retroactively enriches every existing detail card that shows a
compatible entity. Authored link intents wait for a screen that demands them.
Phases
Phase 1 — schema + headless runtime (no UI). Surface IR v0 zod schema + JSON Schema
projection; Realm Schema format + the networkaccess fixture realm; interpreter core
(parse → validate → resolve); dial object + URL codec; fixture DataAdapter carrying the
incident script; golden-fixture suite with the NOC document as fixture #1. Done when: the
NOC Surface resolves to a snapshot-stable render tree under scenario variations, with zero
DOM imports anywhere in the package.
Phase 2 — registry, widgets, shells; NOC parity. Registry contract; web widget
implementations over maps-ui/ui/Items; app-ui slot-contract rework into DeskShell +
PhoneShell-web; mount the runtime in apps/network-access; re-express the NOC; delete the
hand-coded /home. Done when: the demo renders from noc.json with full loop parity on
both web form factors, and the layers panel / focus / camera round-trip through the URL.
Phase 3 — the seam grows underneath. Kernel-side: IR-2 query endpoint over the Query
verb, capability vocabulary, sdkgen SDK. Swap the fixture adapter for the SDK adapter behind
subscribe() — invisible to every widget. Renegotiate QueryRef (D6) and replace the fixture
realm with the IR-1 projection (D5). This phase is kernel work; D5/D6 mark the exact
re-entry points.
Playground feedback outcomes (2026-07-03)
First user tour of the playground produced these steering decisions:
- X-ray must stay derived, never forked. The wireframe is acceptable as a permanent
debug lens only because it renders the actual resolved tree from the real pipeline.
When Phase 3 real widgets land, X-ray becomes a toggle over the same
ResolvedPagethe widgets consume — a second projection of one tree, never a parallel render path. - IR stress-testing by reproduction. Next validation round: screenshots of existing
production apps get re-expressed as Surface documents. Expected pressure points, to be
confirmed by the exercise: explicit layout IR (today composition is shell-implied
via slots) and subpage/navigation IR (multi-page Surfaces, drill-in routes). These
feed R3 (
Composition/Panel) and likely a newRoute/Subpagenode kind. - Controls (dials/session levers) validated — no grammar changes requested; the
levers correctly drive
resolveSurfaceinputs rather than cosmetic view state. - Playground chrome: resizable panel layout (react-resizable-panels, persisted) and devtools-style device presets replaced the fixed grid + desk/phone toggle.
- (Resolved 2026-07-03, same day:) the stress test ran — alerts re-expressed, gap ledger written, and a full grill produced D14–D16 (QuerySpec v2 strict mirror, declared dials, scoped guards), implemented with the trafficure alerts document as golden fixture #2. Remaining gaps: Subpage/modal-as-route + app-level nav (next grill), layer styling (R2), chart/stats projections (R3, seam spec already landed). (Resolved 2026-07-04: that next grill ran — the app layer, routing/URL grammar, and app-level nav are decided in the App System decision record, which amends D18/D19 above.)
Residuals (parked, not decided)
| # | Item | Re-entry point |
|---|---|---|
| R1 | Offline replica + device-branch queue on web | Contract present in runtime interfaces; first implementation is the mobile workstream’s (SG-1) |
| R2 | P24 layer descriptors | v0 map layers use maps-ui’s LayerDescriptor shape; revisit when P24 exists kernel-side |
| R3 | Composition, Panel, cards/board/timeline/chart projections | v0.1, with the first dashboard Surface |
| R4 | Agent-plane attention model (hud slot) | Slot exists in the contract, stays empty until agents ship |
| R5 | Package-widget sandbox substrate | SUR-10 / SQ-6 unchanged — post-pilot |
| R6 | Ephemeral in-session Surfaces (generative UI at runtime) | SUR-12 unchanged — authoring-time validation only in v0 |
Naming note
Per the naming canon (2026-07-02): Vinxi is the kernel/platform (“Surface system on
Vinxi”), Lepton is the company, NetworkAccess is the product whose NOC demo drives
v0. The upstream docs’ “Sanjaya” is a retired term — this doc and the implementation say
agent plane / hud slot. Package names stay @vinxi/*.