Status: complete — every slot either carries a
WEB-rule or is explicitly deferred to the spine. Read the spine first.
This appendix instantiates the Coral app spine for web applications
(server-rendered or SPA + API). Where it serves an API, defer to backend.md for the API
layer and use this appendix for the UI-facing slices.
Defining tension: two things make web the hardest app type. First, the trust boundary
([TRUST-*]) is first-class — untrusted input arrives over the network from end users, and the browser
is hostile. Second, rich UI is inherently fan-in — a dashboard or control panel composes many domains
onto one screen, and unlike data fan-in, UI fan-in must also cohere visually. Both are addressed by
keeping each piece a slice; what does not follow is that each piece needs its own deployment
([WEB-6], [WEB-2]).
[WEB-1] [review] {app:web} A slice is one route / page-action / endpoint, and its UI and
its handler live in the same slice: a frontend slice owns its view, its local state, and the one
capability call it makes. Do not split a feature's UI from its logic into global components/ and
services/ layers — that is the [BUCKET-1] failure wearing frontend clothes.
[WEB-2] [guide] {app:web} Microfrontends are an escalation pattern, not the default. Adopt
them when a concrete requirement forces runtime independence: independent deployment, independent team
ownership, runtime isolation, different frameworks or runtimes, or independently versioned product
surfaces. A feature-rich frontend is not one of those reasons.
This rule used to read the other way — "prefer building rich UI as microfrontends" — and that was wrong in
a way worth naming, because the reasoning that produced it was sound and the conclusion still did not
follow. Rich UI is read fan-in ([COMPOSE-4]), and a dashboard should be many slices rather than one
god-component. Both of those are about structure, and microfrontends are about deployment. Coral
gets the structural property from capability slicing alone ([WEB-1], [WEB-6]); paying an independent
deployment unit per panel to obtain it buys nothing and costs a bundle boundary, a runtime protocol, and a
version skew per panel.
Where the requirement is real, everything the earlier wording described applies: each panel is a slice
owning its UI, its local state, and its capability call, the screen is a small system, and the panel
channel is contract-tested ([WEB-4], [SYS-TEST-1]).
[WEB-3] [review] {app:web} The composition shell is the frontend's orchestration layer
([ORCH-1] in the browser): it owns layout and routing — where panels sit — and contains no business
logic. It mounts slices; it does not reach inside them. Adding or moving a panel is a shell change, not
an edit to another panel.
[WEB-4] [auto] {app:web} A frontend slice never reaches into another slice's internals
([COMPOSE-1]) — it depends only on a published surface. Where runtime isolation is claimed
([WEB-2]), that surface must additionally be a channel ([CHAN-*] in SYSTEM.md) —
an event stream, a typed contract surface, or a thin client-side bus — and statically there is no import
edge between panel directories at all. In an integrated frontend ([WEB-6]), a typed import of another
slice's published surface is permitted, and is preferred to a bus.
The distinction the earlier version of this rule lost: it banned import edges unconditionally, including
inside the integrated frontend [WEB-6] sanctions. So the honest fallback was still forced to communicate
through a runtime channel across a boundary that does not exist at runtime — one bundle, one process, one
deployment. That trade is a bad one in both directions. A typed import gives the compiler a chance to
catch a break, gives the reader a definition to jump to, and shows up in a dependency graph; an event bus
in a single bundle gives none of those and adds an untyped indirection whose consumers cannot be
enumerated. Locality and discoverability are the properties this architecture optimizes for
([AGENT-1]), and here the channel costs both.
What does not relax is [COMPOSE-1]. "Published surface" means an explicit, deliberate export — a
slice's index or barrel naming what it offers — never a deep path into another slice's components, hooks,
or store. Deleting a slice must break only its published surface's consumers, and if a deep import made
that untrue, the boundary is gone whichever mechanism carried it.
[WEB-5] [review] {app:web} Shared design tokens, primitives, and interaction patterns are a
crosscut: defined once as a design-system package, injected into every slice, never re-implemented or
forked per panel.
A control panel must look like one product, and visual cohesion is the cross-cutting concern that data
fan-in doesn't have — it is most of what makes UI fan-in genuinely harder than the read fan-in of
[COMPOSE-4]. It is also a textbook [XCUT-1]: cross-cutting, and carrying an invariant (one visual
language) that is a defect when it diverges.
[WEB-6] [guide] {app:web} The default web architecture is a single integrated frontend
organized internally by capability slice, consuming the design-system crosscut. One deployment unit, one
bundle; the slice boundaries are structural.
This is the default because it is the reversible choice and because it keeps every property Coral
actually asks for. Each slice still owns its view, its local state, and its one capability call
([WEB-1]); the shell still holds only layout and routing ([WEB-3]); the design system is still an
injected crosscut ([WEB-5]). What it does not buy is independent deployment — and independent deployment
is a requirement to be shown, not a property to be assumed ([WEB-2]).
Going the other way is the expensive direction. Splitting an integrated frontend into microfrontends later is a known refactor with a known cost; collapsing five deployed panels back into one bundle means unwinding a runtime protocol, five build pipelines, and whatever version skew they have accumulated. Start where the reversal is cheap, and escalate when a named requirement appears.
[WEB-7] [review] {app:web} Treat the client as hostile: never trust anything that arrives
from the browser.
Authentication and authorization run at the server boundary as middleware before the slice, so the
slice receives an already-authenticated principal. Validate every request payload, enforce CSRF and
session protections, and keep secrets server-side ([CONFIG-4]) — anything shipped to the browser is
public by definition.
If resources are user- or tenant-scoped, the owner/tenant id is part of the record and part of every
query's WHERE clause (as in [BE-6]) — decide before writing the slice; it changes schema, slice
signature, and tests. Default to deny.
[WEB-8] [auto] {app:web} HTTP method semantics: GET/HEAD are safe and read-only ([IDEM-2] —
a GET handler must not mutate); POST is non-idempotent; PUT/DELETE are idempotent. The client owns
retry; never auto-retry a POST ([IDEM-4]). Offer an idempotency key where a user may double-submit.
[WEB-9] [auto] {app:web} Slices raise the taxonomy; a root middleware renders — mapping
category → HTTP status (per [BE-5]) and to the right surface: a user-facing error view or page for
navigations, a structured {category, code, message} body for API and fetch calls. Slices never render
their own HTTP response; code strings stay slice-owned ([ERR-2]).
[WEB-10] [review] {app:web} The UI's stable contract is its route/URL structure: never break
a route, and never silently repurpose one.
This is the slot where web differs most from every other app type, and it is worth being precise about why.
A route has no version prefix and no deprecation path. Users bookmark it, other sites link it,
search engines index it, and a customer's runbook cites it — and you cannot ask a bookmark to migrate. So
there is no /v2 move available: the old path either works or breaks somebody you cannot contact.
Repurposing is the worse failure. Changing what a path means while keeping the path is [CHAN-4]'s
never-repurpose rule at the URL layer, and it is nastier here because nothing errors — every old link keeps
resolving and quietly shows the wrong thing.
Moving a route therefore requires a redirect from the old path, kept indefinitely unless you can
demonstrate nothing references it. Query parameters that change behaviour are part of the same contract.
Where the app also exposes an API, that half follows [BE-7].
- Observability →
[OBS-1..3]: structured logs + traces + correlation IDs via the injected logging crosscut; never leaked into the rendered page or API body. - Configuration →
[CONFIG-1..4], with one addition: anything shipped to the browser is public by definition, so no secret may reach client config.
[WEB-11] [review] {app:web} Server state is the source of truth. Client state is a cache of
it, owned by the slice that fetched it, and never the only place a fact exists.
This is [STATE-6] in the browser, and the browser makes it sharper: a hard refresh, a new tab, and a cold
load are the empty-cache case, and users generate them constantly. So every render path must be correct
with client state empty — that is not an edge case to handle later, it is the second-most-common way your
page loads.
Invalidation is owned by the slice that caused the change. A slice that mutates invalidates what it
changed, then notifies — over the channel where panels are runtime-isolated, or by invalidating the shared
query cache by key in an integrated frontend ([WEB-4]); other slices react and decide for themselves. No panel
reaches into another's cache, and the shell stays logic-free ([WEB-3]). That is what keeps you out of a
global store that every panel reads and writes — the [BUCKET-1] failure in frontend clothes, arrived at
one convenience at a time.
Optimistic updates are a display concession, not a state change. They are allowed, and the slice must
reconcile against the server's response and must surface the failure. An optimistic update that silently
diverges from the server is [XCUT-4] drift rendered directly into the user's face, which is worse than a
spinner.
The one genuinely client-owned state is what has never been sent: form drafts, scroll position, expand/collapse, an in-progress selection. That belongs to its slice, needs no server round trip, and should not be pushed through the channel.
[WEB-12] [review] {app:web} A web slice's behavior test drives it through the surface a user or
a caller actually touches — a route with a request, or a mounted panel with an interaction — and asserts
the observable contract: the status or rendered output, the capability call it made, and the side effect.
That rules three things out, and they are the three that web test suites are usually made of:
- Component-internal state. Asserting a hook's value or a store's contents tests the implementation, and it breaks on every refactor while passing on every logic bug.
- Markup snapshots. A snapshot asserts shape, not behavior: it fails on every redesign and succeeds on every wrong-total. It is not a behavior test and should not be counted as one.
- Mocking the capability call the slice exists to make. Stub at the channel contract (
[WEB-4]) or the HTTP boundary, never the function the slice calls — otherwise the test passes when the contract changes.
Two additions are mandatory rather than optional. An authorization test at the boundary, because
[WEB-7] is this app type's heaviest slot and the one that regresses silently — nothing visibly breaks
when an authz check stops firing. And for microfrontends, a contract test on the panel channel
([SYS-TEST-1]), so each panel is verified alone and you never need the whole shell up to know a panel
works.
Browser-driving end-to-end tests are the [SYS-TEST-5] backstop: a handful of critical journeys, kept
deliberately tiny. They are the most expensive tests you own and the first to become flaky, so they must
not be where your coverage lives.
- SPA + API split: which slices live client-side vs. server-side, and how the boundary is mirrored (the
API half is governed by
backend.md). - Server-rendered vs. SPA vs. islands: how the composition shell (
[WEB-3]) is realized per stack.
| Slot | Web instantiation |
|---|---|
| boundary | a route/page-action/endpoint; UI + handler in one slice |
| default shape | one integrated frontend, organized by capability slice; dashboard = UI read fan-in |
| composition root | the shell — owns layout/routing, no business logic |
| slice ↔ slice | typed published surface; a channel where runtime-isolated; never internal reach |
| visual cohesion | design-system crosscut, injected |
| escalation | microfrontends, when a named requirement forces runtime independence; flag it |
| trust / security | client hostile; authz at the edge; tenant in the data model; deny by default |
| idempotency | HTTP method semantics; client owns retry |
| error rendering | root maps category → status + error view/JSON |
| contract versioning | the route/URL structure; redirect, never repurpose ([BE-7] for the API half) |
| state / effects | server is truth; client state is a slice-owned cache; the mutator invalidates |
| testing | drive the real surface; no snapshots; authz test + contract-test the panel channel |
The complete normative checklist for this appendix: every [auto] and [review] rule defined above. It
adds to the app-scale contracts rather than replacing them —
load ARCHITECTURE.md's, and PRODUCTION.md's if this project
adopts the production baseline. Where the app also serves an API, backend.md's contract applies to that half.
[guide] rules are rationale and live only in the prose.
[WEB-1]A slice is one route/page-action/endpoint, with its UI and its handler in the same slice.[WEB-3]Keep the composition shell to layout and routing; it mounts slices and holds no business logic.[WEB-4]Depend only on another slice's published surface: a typed import when integrated, a channel with no import edge when runtime-isolated.[WEB-5]Define design tokens, primitives, and interaction patterns once as an injected crosscut.[WEB-7]Treat the client as hostile: authorize at the server boundary, validate every payload, keep secrets server-side.[WEB-8]Follow HTTP method semantics:GET/HEADsafe and read-only,POSTnon-idempotent,PUT/DELETEidempotent.[WEB-9]Slices raise the taxonomy; a root middleware renders to the right surface — error view or structured body.[WEB-10]Treat the route/URL structure as the stable contract: never break a route, never silently repurpose one.[WEB-11]Server state is the source of truth; client state is a slice-owned cache, and the mutating slice invalidates it.[WEB-12]Drive a web slice's behavior test through the real surface: no internals, no snapshots, no mocking its capability call.