<!-- caspian:start -->

# Caspian Agent Guide

## Purpose

This workspace is a Caspian application plus a packaged copy of the Caspian docs.

**This file is the first-party Caspian reference and the required first read for every task — do not skip it and do not start implementing before it.** The "Caspian Core Contracts" section below is the condensed, always-read digest of how Caspian actually works — feature gates, the Jinja/PulsePoint brace dialects, the authoring model, the props-passing contract, the closed PulsePoint template/API surface, and the data flows. It exists because tasks fail when an agent implements from generic framework intuition instead of these shipped contracts; reading it first is what lets a feature land correctly in one pass. The packaged docs under `node_modules/caspian-utils/dist/docs/` remain the deep per-feature layer to open when a task touches that surface.

When you work here, use `caspian.config.json` and the code that actually runs as the source of truth for this project. Use workspace file instructions under `.github/instructions/**/*.instructions.md` as the task-specific instruction layer when they match the work, and use the packaged markdown docs under `node_modules/caspian-utils/dist/docs/` as the AI-facing Caspian feature and task-reference layer.

Do not treat the existence of a packaged doc as proof that the feature is enabled in this project.

## Caspian Core Contracts (Read Before Any Analysis)

Every rule in this section describes shipped behavior of the Caspian runtime this app runs on. Implement against these contracts, not framework intuition. When any claim here disagrees with `caspian.config.json`, the app code, or the installed runtime, the code wins — and this section should then be fixed together with the matching packaged doc.

**This is the digest, not the full documentation.** It exists to stop the highest-frequency implementation failures; it does not replace the packaged docs under `node_modules/caspian-utils/dist/docs/`, which remain the canonical deep layer per feature. Each subsection below ends with a "Deep dive" pointer — open that doc before implementing anything nontrivial on that surface, and use the "Task Routing" section further down to pick the right doc for the task as a whole. Never conclude from this digest alone that a detail, option, or edge case does not exist.

### Feature gates (`caspian.config.json`)

This workspace currently enables: `tailwindcss`, `mcp`, `prisma`, `typescript`, `websocket`; `backendOnly: false`; components are scanned under `src/`. Re-read the file when in doubt — it is the single source of truth for optional features. A packaged doc existing never proves a feature is enabled. If a disabled feature is requested, ask first, then enable the flag and follow the Caspian update workflow.

Deep dive: `node_modules/caspian-utils/dist/docs/index.md` (the docs manifest and retrieval order) and `commands.md` (scaffold and update workflows).

### The three brace dialects — the #1 source of broken implementations

Every template in this app is authored inside `html(r"""...""")` and rendered through Jinja **before** the PulsePoint compiler ever sees it. Three brace forms coexist and must never be confused:

| Syntax                | Layer                | Meaning                                                            |
| --------------------- | -------------------- | ------------------------------------------------------------------ |
| `{{ value }}`         | Server (Jinja)       | Python-to-HTML interpolation at render time. Autoescaped.          |
| `{{ value \| json }}` | Server (Jinja)       | Safe serialization of a server value into a `<script>`.            |
| `{# comment #}`       | Server (Jinja)       | Stripped from output.                                              |
| `{ expression }`      | Browser (PulsePoint) | Left untouched by the server; evaluated reactively in the browser. |

Consequences that are always true:

- **Never author markup as a Python f-string.** It inverts both dialects (`{x}` becomes server interpolation, a PulsePoint binding must become `{{x}}`), skips autoescaping while still marking the output trusted, and skips the `<x-*>` scope stash. The `html-form` gate rule fails new f-strings. The one accepted markup form is `html(r"""...""")` — raw, triple-quoted, nothing else.
- **Autoescaping is ON.** `{{ value }}` is safe for user text. Trusted HTML needs `Markup(...)` or `| safe`. `children` is auto-safe.
- **Braces are escaped by the server too** (`{`/`}` → `&#123;`/`&#125;` on every non-`Markup` value), because PulsePoint compiles the rendered DOM and a stored `{fetch(...)}` would otherwise execute. `Markup` is the trust boundary: `get_attributes(...)`, `merge_classes(...)`, `| safe`, the `json` filter, and layout children keep their braces live. Therefore **you cannot build a PulsePoint expression by interpolating a plain server string** — `class="{{ some_expr }}"` renders inert. Author the expression in the template, or return `Markup` from the helper.
- Server-rendered `{{ }}` values are static after first paint; `{ }` values are reactive. Passing first-render data into reactive scripts goes through `{{ value | json }}` into a `pp.state(...)` initializer, or through props (see the props contract below).

Deep dive: `node_modules/caspian-utils/dist/docs/components.md` "Single-File Components With `html(...)`" (the dialects, autoescaping, and the `Markup` trust boundary in full).

### Authoring model — pages, layouts, components

Authoring is **Python-only and single-file**. There are no `.html` sidecars in this app; markup lives inline in the owning `.py` file, returned from `html(r"""...""", **context)` (import `html` — and `component` — from `casp.component_decorator`).

**Routes (`src/app/**/index.py`):\*\*

- Folders are URL segments (Next.js App Router model): `[id]` dynamic, `[...slug]` catch-all, `(group)` organizes without a URL segment.
- `page()` returns `html(r"""...""")` for UI routes, a `Response` for non-visual routes, or the tuple `(html(...), {"layout_prop": value})` to push a value up into wrapping layouts as `{{ layout.layout_prop }}`.
- Path params arrive as **one positional dict**: `async def page(params: dict)`. Query params inject by name; `request` injects by keyword when declared.
- The same `index.py` owns the route's metadata, `@rpc()` actions, auth checks, caching, redirects, and validation. Extract to `src/lib/**` only what is genuinely shared.
- **Component-first composition is the top authoring rule**: the page template is a short assembly of `x-*` chunk components (topbar, sidebar, sections, cards, forms, footer). Long markup moves into focused components in `src/components/` _before_ the route is written, not as cleanup.

**Layouts (`src/app/**/layout.py`):\*\*

- `layout()` returns `html(r"""...""", **context)` — but **deferred**: `children` (the page below) does not exist yet, so `html(...)` hands back an unrendered `LayoutTemplate` and the engine renders it later with `children` / `layout` / `metadata` merged in. Those three names are engine-owned and always win over author context.
- The layout must place its children — `<slot />` or `{{ children }}` — or it raises `LayoutChildrenError`.
- Also accepted returns: `(html(...), props_dict)` (props become `{{ layout.* }}` for the subtree), a bare props `dict`, `None`, or a plain template string.
- Deferral is keyed on the `layout()` frame only; a component or helper called from a layout still renders eagerly.
- Grouped sections (dashboard/admin/account) = parent folder + `layout.py` + child routes, exactly like the App Router. Put `pp-reset-scroll="true"` on the content pane that should reset on child navigation; leave shell scrollers unmarked.

**Special files (`src/app/**`) — all optional, but each owns its behavior:\*\*

None of these is required; add one only when the app actually wants that behavior. What the table settles is _how_ — when a task does call for one, the answer is this file, never a hand-built equivalent:

| Behavior, when wanted              | File                   | Export                             |
| ---------------------------------- | ---------------------- | ---------------------------------- |
| Shared shell for a subtree         | `layout.py`            | `layout()`                         |
| Loading UI during route navigation | `loading.py`           | `loading()`                        |
| Global 404                         | `src/app/not_found.py` | `page()`                           |
| Global 500                         | `src/app/error.py`     | `page(error_message, error_trace)` |

**`loading.py` is the one most often reinvented — but also the one least often needed.** Most subtrees ship without a loader and navigate with a plain fade, which is correct default behavior; do not add one unless the user asks for a navigation loading state or the section clearly wants one. When a task _does_ call for one, edit or add the closest `loading.py` instead of writing a spinner component, an `isLoading` store, a `pp:navigation:start` listener, or a manual overlay. Its contract:

- `def loading():` — **synchronous, no parameters**, returns `html(r"""...""")`. `async def` (or an awaitable return) raises `TypeError`; a module without the function raises `AttributeError`.
- Scope comes from the folder, and the browser picks the closest ancestor: `src/app/dashboard/loading.py` → `/dashboard` and everything under it; `src/app/loading.py` → the root fallback. `(group)` segments are stripped exactly as they are from the URL, so a loader placed **directly** inside `(marketing)/` collapses to the root scope `/` and becomes the app-wide fallback rather than the group's — put it in a real route folder under the group instead. **A `[id]` folder never matches** — the lookup compares scope strings to the real pathname, so put the loader on the static parent.
- **The markup is static HTML.** The loader is collected once into a hidden registry and injected with `innerHTML`, never mounted: Jinja `{{ }}` and `html(...)` kwargs _do_ interpolate, but `<x-*>` tags stay literal (rendering nothing), `{ }` bindings render as literal text, and `<script>`/`pp-for`/`on*` do nothing. Plain elements and CSS only.
- Rendered once and cached until the file's mtime changes, so the output is shared by every request — no per-user or per-request data. Every loader in the app ships inside every page, so keep them small.
- `pp-loading-content="true"` on a layout element marks the pane the loader replaces (fallback: `document.body`, which flashes the whole shell). `pp-loading-transition='{"fadeIn":…,"fadeOut":…}'` inside the loader markup overrides the 250 ms default each way.
- **Navigation only.** It never appears on first paint or a hard refresh, and it is inert in a static export. An in-page wait — an `@rpc()` call, a submit, a filter, an upload — is ordinary `pp.state` in the owning component and must not be routed through `loading.py`.
- This workspace already has one example of the pattern — `src/app/dashboard/loading.py` fills the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. Copy that shape if a loader is wanted elsewhere; its existence is not a reason to add loaders to other subtrees.

Deep dive: `node_modules/caspian-utils/dist/docs/file-conventions.md` (all four files, with the full `loading.py` contract) and `pulsepoint.md` "SPA, loading, and navigation helpers".

**Components (`src/components/**/\*.py`):\*\*

- One `@component` function per responsibility, markup inline via `html(r"""...""")`, PulsePoint `<script>` inside the root. Split by responsibility exactly as you would split React components — **that analogy covers decomposition and hook API only, never markup syntax** (see the PulsePoint contract below).
- **Composition is Python-import-driven.** An `<x-*>` tag resolves from the `Component` objects imported into the module that authors the tag: `Container` → `<x-container>`, `CommandDialog` → `<x-command-dialog>`. Import every tag you write, including in pages and layouts. Same-file multi-exports are imported from that exact file. Directories that are not valid identifiers (hyphens, `(group)`) bind via `Name = importlib.import_module("src.app.some-dir.Name").Name`. **A package import of a one-component-per-file directory also works** — `from src.lib.ppicons import Search, ArrowLeft` → `<x-search />`, `<x-arrow-left />` — even though Python binds the _submodules_ there rather than the Components (a component directory has no `__init__.py` re-exports). A module binding is unwrapped when the module defines a component under its own file name (`Search.py` → `Search`), and the tag alias stays the _binding_ name, so `import Search as MagnifyIcon` gives `<x-magnify-icon />`. Nothing else is unwrapped: `utils.py`, `import os`, a bare package, or a file whose function is missing `@component` still raise `UnknownComponentError`.
- Resolution precedence inside a component's output: inherited ancestor components, then the module's own imports (imports win). Slot content resolves in the scope where it was **authored**, so the module writing the tag must import it.
- A component may also be called directly as a function and interpolated with `{{ }}`; its nested tags still resolve from its own module's imports.
- **Root shape:** default to one authored top-level element with the `<script>` inside it.
  - A **component** with sibling top-level nodes is a _fragment_ (the `<>…</>` equivalent) — framed by a compiler comment pair, materialized as `<pp-fragment style="display: contents">`, adds no element, and is the only shape that survives inside `<tbody>`/`<tr>`/`<select>`/`<optgroup>`. **A fragment cannot receive props** — any attribute on its `<x-*>` tag (including `pp-ref`) raises `FragmentPropsError`. Never hand-write `<pp-fragment>` or `<!--pp:…-->`.
  - A **page or layout** with sibling top-level nodes gets a layout-neutral `<div pp-component style="display: contents">` boundary host instead — legal and expected.
  - A component whose authored root is another `x-*` tag (composition component) gets the same host, carrying the parent's forwarded props and `pp-ref-forward`. An extra `display: contents` div in rendered DOM is expected output, not a bug.
- Never author `pp-component` or any runtime-managed attribute; the pipeline injects them.
- A template whose root is an `x-*` tag keeps its `<script>` inside that root: it travels as slot content owned by the authoring template (`pp-owner`, alias `app` for pages/layouts) and executes in the **author's** scope.
- Async components (`async def`) are allowed only when the component itself needs awaited I/O.

Deep dive: `node_modules/caspian-utils/dist/docs/routing.md` (routes, dynamic segments, groups, layouts, layout props), `components.md` (component authoring, imports, slots, direct calls, granularity), `file-conventions.md` (`index.py`/`layout.py`/`loading.py`/`not_found.py`/`error.py`), and `project-structure.md` (placement).

### Props passing — the contract that silently fails when skipped

There are **two separate handoffs**, and the Python component is the deliberate bridge between them. Skipping the bridge produces no error anywhere — just `undefined` props in the browser.

1. **Parent tag → Python.** Attributes on the `<x-*>` tag arrive as **raw string kwargs**, kebab-case converted to camelCase (`on-apply` → `onApply`). PulsePoint expressions are **not** evaluated: `open="{permOpen}"` arrives in Python as the literal string `"{permOpen}"`.
2. **Python → root → `pp.props`.** The browser computes `pp.props` from the **rendered root element's attributes**, never from the Python signature. So every prop the template's `{...}` expressions read must be re-emitted on the single native root:

   ```python
   attributes = get_attributes({
       "class": merge_classes("base-classes", props.pop("class", "")),
       "open": open, "value": value, "onApply": onApply,   # every prop the template reads
   }, props)                                               # **props = passthrough for the rest
   return html(r"""
     <section {{ attributes }} hidden="{!open}">
       ...
       <script>const { open, value, onApply } = pp.props;</script>
     </section>
   """, attributes=attributes)
   ```

   `{{ attributes }}` on the root **and** `attributes=attributes` into `html(...)` are both required. A named Python parameter is consumed out of `**props`, so it must be listed explicitly in the defaults dict or it never reaches the root.

Value-type contract (forwarding fixes presence, not type):

| Attribute on the rendered root                 | `pp.props.x`                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------- |
| `volume="{vol}"` — brace expression            | real type, evaluated in the **parent's** scope                      |
| `volume="0"` — literal from a server value     | the **string** `"0"` (`volume === 0` is false)                      |
| valueless attribute                            | boolean `true`                                                      |
| `class`, `for` — JS reserved words             | dropped; `pp.props.class` never exists                              |
| value was `None`/`False`/`""`/empty collection | attribute omitted by `get_attributes` → `undefined` (never `false`) |

Design rules: read booleans defensively (`!!pp.props.playing`); coerce server literals before strict comparison; avoid native-attribute collisions (`title` makes a tooltip — prefer `user-name`); camelCase round-trips through kebab-case (`isFullscreen` ↔ `is-fullscreen`). `get_attributes` aliases: `className`/`class_name` → `class`, `htmlFor`/`html_for` → `for`, `defaultValue` → `defaultvalue`, `defaultChecked` → `defaultchecked`. When Tailwind is enabled, `merge_classes(...)` emits a live `{twMerge(...)}` expression — pass it straight through, never wrap or re-merge it, and pop the incoming `class` from `props` first.

`pp-ref` on an `x-*` tag is parent-owned and binds the component's concrete DOM root (forwarded through composition hosts). A component can opt out by declaring an explicit `ppRef` parameter. For a child-defined imperative API, pass a parent ref as an ordinary prop and publish with `pp.imperativeHandle(pp.props.controlRef, () => ({...}), [])` — never author `pp-ref-forward`.

Deep dive: `node_modules/caspian-utils/dist/docs/components.md` "Receiving Props In A Python Component", "Every Prop A Template Reads Must Be Forwarded To The Root", and "HTML Attribute Helper Contract" (the full `get_attributes`/`merge_classes` behavior and end-to-end examples).

### PulsePoint templates — plain HTML, never JSX

The React comparison covers exactly two things: the `pp.*` hook API inside `<script>` and how components are split by responsibility. **The markup is plain HTML parsed by an HTML parser.** The one-line test before finishing any template: _would it still be valid HTML with every `{}` deleted?_

Fatal JSX constructs and their PulsePoint forms:

| Never write                                       | Write instead                                                                                                                                             |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `{cond && (<div/>)}` / `{cond ? <A/> : <B/>}`     | `<div hidden="{!cond}">…</div>` (element stays, guard inner expressions with `?.`)                                                                        |
| `{list.map(item => (<li/>))}`                     | `<template pp-for="item in list"><li key="{item.id}">…</li></template>`                                                                                   |
| `class={expr}` — unquoted brace attribute         | `class="{expr}"` — **always quote**. Unquoted is invalid HTML: the parser shreds the element and the route serves a **blank page with no console error**. |
| `className`, `htmlFor`, `onClick`, `defaultValue` | `class`, `for`, `onclick`, `defaultvalue` (lowercase HTML)                                                                                                |
| `style={{color:'red'}}`                           | `pp-style="{styleText}"` — a CSS **string**                                                                                                               |
| `<>…</>`                                          | one real root; or plain siblings (fragment rules above)                                                                                                   |
| `dangerouslySetInnerHTML`                         | server-render trusted HTML                                                                                                                                |

**The directive list is closed.** All of it: `{expr}` in text/quoted attributes; native `on*` event attributes; `pp-for` (on `<template>` only, forms `item in items` / `(item, index) in items`, plain `key` on the repeated element); `pp-ref`; `defaultvalue`/`defaultchecked` (lowercase, uncontrolled seed); `pp-style`; `pp-spread="{...obj}"`; `<token.provider value="{v}">` (lowercase context provider); `pp-spa="false"`; `pp-reset-scroll`; `pp-scroll-key`; `pp-loading-content`; `pp-loading-url`; `pp-loading-transition`. There is **no** `pp-if`, `pp-show`, `pp-else`, `pp-model`, `pp-bind`, `pp-class`, `pp-key`, or `pp-context`. If it is not in `public/js/pp-reactive-v2.min.js`, it does not exist.

Runtime-managed, never authored: `pp-component`, `pp-owner`, `pp-event-owner`, `pp-ref-forward`, `<pp-context-provider>`, `data-pp-*`, `pp-keep`, `pp-keep-run`, `pp-keep-content`.

Rendering semantics worth knowing before debugging a "blank binding":

- Interpolations produce **text, never elements**, HTML-escaped, serialized with JSX-child rules: `true`/`false`/`null`/`undefined`/`""` render nothing; `0` and `NaN` print; arrays concatenate with no separator; objects/functions warn (`[PP-WARN] Invalid template child`) and render nothing. So `{items.length && 'x'}` leaks a `0` — write a ternary; bind display expressions (`{admin ? 'yes' : 'no'}`) when a value can be boolean/nullish.
- On non-boolean attributes, booleans serialize as `"true"`/`"false"` (correct for `aria-*`/`data-*`), and **nullish leaves the attribute present-but-empty** — guard URL attributes: `src="{avatar ? avatar : placeholder}"` (an empty `src` refetches the page).
- Form controls are controlled (`value="{state}"` + `oninput`) **or** uncontrolled (`defaultvalue="{expr}"`) for their lifetime. Binding `value` to state that starts `undefined` flips the mode and logs `[PP-WARN] … changed from uncontrolled to controlled` — fix the initial state, never add both attributes.
- `{...}` is safe in **any** attribute or position (SVG `d`/`viewBox`, `src`/`href`, date/number inputs, text in `<table>`/`<select>`) because the server defers each component root inside an inert `<template>`. Never add per-tag workarounds (hidden-gated `<img src>`, `data-*` URL holders, SSR-resolved initial values) to dodge first-paint validation.
- Event handlers get injected identifiers: `event`, `e`, `$event`, `target`, `currentTarget`, `el`. Lowercase `on*` = native DOM events; kebab-case attributes (`on-open-change`) = component props (`pp.props.onOpenChange`) — the `on-` prefix is convention, not magic.
- **A handler in slot content runs in the scope of the template that authored the markup**, not the component it renders inside. `ReferenceError: fn is not defined` from a handler that fired means the function lives in the wrong template's script — move the function to the authoring template (its script can stay inside the `x-*` root as slot content) or move the markup into the child. Wrapping in another component or blaming portals does not fix it.

Deep dive: `node_modules/caspian-utils/dist/docs/pulsepoint.md` "PulsePoint Is Not JSX", "Complete Directive And API Surface", "Conditional rendering", "Value serialization is the JSX child contract", and "A slot-authored `<script>` belongs to the template that authored it".

### Component scripts — hooks and runtime API

The script is a plain, untyped `<script>` inside the root: captured by the runtime before materialization, evaluated in component scope via `new Function(...)`. No `import`/`export`/top-level `await`. Only **top-level** declarations reach the template (functions, `const`s, every destructuring shape). Props are read via `pp.props` — there is no injected `props` variable.

Hooks (closed list): `pp.state`, `pp.effect`, `pp.layoutEffect`, `pp.ref`, `pp.memo`, `pp.callback`, `pp.reducer`, `pp.context`, `pp.portal`, `pp.id`, `pp.errorBoundary`, `pp.syncExternalStore`, `pp.imperativeHandle`, `pp.transition`, `pp.deferredValue`, `pp.optimistic`, plus `pp.props`. Utilities: `pp.createContext`, `pp.mount`, `pp.redirect`, `pp.rpc`, `pp.socket`, `pp.enablePerf`/`disablePerf`/`getPerfStats`/`resetPerfStats`. No `forwardRef`, `Suspense`, `lazy`, `useActionState`, or `pp.provideContext` — do not invent hooks.

Contracts:

- Effects return synchronous cleanups only (promises are ignored with a warning). Always pass a dependency array; deps compare by identity, so memoize object/function deps first.
- `pp.id()` for generated `id`/`for`/`aria-*` — never index- or counter-derived ids.
- `pp.syncExternalStore` needs a `pp.callback(..., [])`-stable subscribe.
- `pp.transition()` gives an accurate `isPending` but does **not** time-slice; PulsePoint renders synchronously.
- `pp.errorBoundary()` catches render/effect/cleanup throws (including its own), latches until `reset()`, gives up after five unreset captures; event-handler errors need `try`/`catch`.
- Context: `pp.createContext(default)` → lowercase `<themecontext.provider value="{theme}">` in markup → `pp.context(token)` in descendants. Share the token via props when scopes differ. Resolution walks component ancestry (portals included), not the DOM.

Performance ownership (the render contract):

- `pp.state` = "render required". Timers, request generations, cursors, and RPC-only query text go in `pp.ref` — a ref mutation never renders. Debouncing a setter limits frequency, not render cost: for server search, keep the query in a ref, debounce the RPC, discard stale responses with a generation check, and put only accepted rows in state.
- Keep high-frequency state in the smallest owning component; `pp.deferredValue` for consumers that may lag one commit.
- **Prop identity decides child re-renders** (shallow, by identity). Inline `rows="{list.filter(...)}"` or `on-select="{(r) => ...}"` re-renders the child every parent render — memoize arrays/objects with `pp.memo`, handlers with `pp.callback`, and pass those names. Primitives are free. Provider `value` objects must be memoized too, or every consumer re-renders each provider render.
- Key every `pp-for` row, keep the row body single-rooted, and the runtime reuses unchanged rows; a mounted child boundary is reconciled by its attributes, not its markup.
- Never "fix" performance with `querySelector`/`addEventListener`/`innerHTML` — diagnose ownership first, then `pp.enablePerf()` if byte-identical output still costs.

Interaction rules: bind first-party events with `on*` in the markup; ordinary forms use `onsubmit="{handler(event)}"` + `Object.fromEntries(new FormData(event.currentTarget).entries())` (input `name`s define the payload; Python validates) — never per-input `pp-ref` collection, never id/`data-*`-driven DOM wiring, never manual `innerHTML` list painting. Imperative DOM access (focus, measurement, media, third-party widgets) stays behind `pp.ref` + `pp.effect` inside the owning component.

Deep dive: `node_modules/caspian-utils/dist/docs/pulsepoint.md` "Hooks and runtime API", "High-performance authoring", "Context", "Error boundaries", and "SPA, loading, and navigation helpers"; `pulsepoint-runtime-map.md` for the fastest feature-to-owner lookup.

### Data — first render, RPC, streaming, uploads

- **First render:** load in `page()` (async when I/O-bound), pass into `html(...)` as context, render with `{{ }}`. Shared subtree data goes in `layout()` props.
- **Everything browser-triggered after that is RPC:** Python `@rpc()` (route-owned in the route's `index.py`; component RPC names are global) called via `pp.rpc(name, data?, options?)`. Never raw `fetch` to hand-made JSON endpoints.
- `@rpc(require_auth=True, allowed_roles=[...], limits="20/minute")` for protection. **Payload keys are filtered against the signature** — a parameter is client-settable only when declared; identity/ownership/privilege must be derived server-side (`auth.get_payload()`), never accepted as an argument. `**kwargs` opts into the whole payload — only deliberately.
- Options: `abortPrevious` (cancelled promise resolves `{ cancelled: true }`), `url`, `csrfUrl`, `credentials`, `onStream`, `onStreamError`, `onStreamComplete`, `onUploadProgress` (`{ loaded, total, percent }` — no `percentage`), `onUploadComplete`.
- **Streaming (the default for AI/LLM/chat tokens):** a generator `@rpc()` that `yield`s chunks (bridge an SDK stream with `async for ... yield`); consume with `pp.rpc(..., { onStream })` appending to state. Never `EventSource`, raw `ReadableStream`, or a WebSocket for one-way streams.
- **Uploads:** a payload containing `File`/`FileList` becomes multipart (non-file fields sent first; objects JSON-stringified; nullish omitted). Upload/delete actions live in the owning route's `index.py`; blobs under `public/uploads/**` (attachment-mode protected); metadata in Prisma; list UI via `pp.state` + `pp-for`.
- Server-push-only? RPC streaming. Genuinely bidirectional? Named sockets (see the workspace clarifications below).

Deep dive: `node_modules/caspian-utils/dist/docs/fetch-data.md` (first-render data, RPC, "Search, Filters, And Request Races", "Streaming Responses", serialization), `file-uploads.md` (the complete file-manager pattern), and `websockets.md` "Named Sockets".

### Server utilities

- **Validation** (`casp.validate`): `Validate.email/url/string/boolean/decimal/date/...` for single-value coercion (`Validate.string` trims + HTML-escapes by default); `Validate.with_rules(value, [Rule...], confirmation_value=None)` for multi-constraint form and RPC payloads. Validate every mutation payload in Python.
- **Metadata** (`casp.layout.Metadata`): static `metadata = Metadata(title=..., description=..., extra={"og:title": ...})` at module scope; dynamic `Metadata(...)` inside `page()` overrides it. Inheritance: root layout → nested layouts → route (route wins per field). Layouts read resolved values as `{{ metadata.* }}`.
- **Cache** (`casp.cache_handler`): `cache_settings = Cache(ttl=3600, enabled=True)` at module scope in a route's `index.py` (explicit assignment preferred). Public shareable HTML only — `CacheHandler` keys on URI alone and `main.py` refuses to cache authenticated renders. Invalidate after writes with `CacheHandler.invalidate_by_uri(...)`.
- **StateManager** (`casp.state_manager`): transient request-scoped server state (`get_state`/`set_state`/`reset_state`/`subscribe`) — flash-style messages, not a session store, not browser state. Do not assume cross-request persistence unless `request.state.session` is bridged.
- **Time** (`casp.app_time`): never bare `datetime.now()` in `src/**`/`main.py` — use `app_time.now()`/`today()`, `to_app_time(...)` for display, `day_bounds_utc(...)` with `gte`/`lt` for calendar-day queries. Session expiry and cache TTLs stay UTC.

Deep dive: `node_modules/caspian-utils/dist/docs/validation.md`, `metadata.md`, `cache.md`, `state.md`, `auth.md`, `database.md`, and `core-runtime-map.md` (which `casp` module owns which behavior, including `casp.app_time`).

The workspace-specific layers — the quality gate (`npm run check`), browser log (`npm run logs`), formatter, named sockets, auth, Prisma workflow, security invariants, and static export — are covered in the "Workspace Clarifications" and "Task Routing" sections below, and remain part of the required contract.

## Document Ownership

- Keep repo-wide always-on rules in `.github/copilot-instructions.md`.
- Keep the "Caspian Core Contracts" section above as the first-party implementation-contract digest: the condensed, always-read version of what the packaged docs explain in depth (brace dialects, authoring model, props passing, PulsePoint surface, data flows). It is version-controlled and survives `node_modules` reinstalls, so when runtime behavior changes, update it together with the matching packaged doc.
- Keep the rest of this file focused on decision order, task routing, workspace-specific clarifications, and packaged-doc maintenance.
- Keep packaged docs under `node_modules/caspian-utils/dist/docs/` framework-oriented and use `core-runtime-map.md` when those docs need to point AI back to `main.py` or the installed `casp` runtime.
- **Only the built runtime under `public/js/**`exists in a generated Caspian app.** Whatever this workspace uses to produce it is a local build detail, not part of the product: never document it, reference it, or route AI to it from the packaged docs. Describe the runtime by its *behavior contract* — what the shipped runtime does — and treat `public/js/pp-reactive-v2.min.js` — the single minified PulsePoint bundle the app serves — as the artifact under discussion. Per-subsystem build output and any authoring source tree are development-only: never cite them as the runtime. The same rule applies to this workspace's own quality tooling and any performance measurement setup: they are development-only and never appear in packaged docs.
- **The packaged docs under `node_modules/caspian-utils/dist/docs/` are git-tracked in this repo** (23 files, not gitignored), so an edit here is recoverable — but `npm install` still overwrites the files on disk, and the change does not reach any other project. Every packaged-doc edit must therefore also be ported into the `caspian-utils` package source; after a reinstall, check `git status` on that folder before assuming the edit survived.

## Decision Order

Use this order depending on the question being answered:

0. First-party Caspian implementation contracts, read before any analysis
   - the "Caspian Core Contracts" section at the top of this file
1. Optional feature enablement and generated surface area
   - `caspian.config.json`
2. App runtime and app-owned code for current project behavior
   - `main.py`
   - `src/app/**`
   - `src/lib/**`
   - `public/js/**`
   - `prisma/**`
3. Matching workspace file instructions for task-specific guidance
   - `.github/instructions/**/*.instructions.md`
4. Installed Caspian framework runtime
   - `.venv/Lib/site-packages/casp/**`
5. Packaged Caspian docs for feature discovery, file-placement guidance, and task routing
   - `node_modules/caspian-utils/dist/docs/**`

If the task is about current repo behavior, prefer the app runtime.

If the task is about framework internals, prefer the installed `casp` package.

If packaged docs differ from the project or installed runtime, the project and runtime win. Keep the packaged docs reusable across Caspian projects and move project-specific clarifications into this file or `.github/copilot-instructions.md`.

Before making feature, tooling, or scaffolding decisions, read `caspian.config.json` almost immediately. Treat it as the workspace feature gate for flags such as `backendOnly`, `tailwindcss`, `mcp`, `prisma`, `typescript`, `websocket`, and `componentScanDirs`.

Treat `caspian.config.json` as the single source of truth for whether an optional Caspian feature is enabled in the current workspace. Use feature-specific docs only after the matching flag is confirmed as enabled. If a feature is disabled and the user wants it, ask whether they want to enable it first, then follow the Caspian update workflow to refresh framework-managed files.

When `.github/instructions/**/*.instructions.md` files exist, treat them as workspace-local instructions for specific third-party libraries, component kits, icon systems, integrations, and implementation rules. Read the matching instruction before deciding how to implement work on that surface, but do not let it override `caspian.config.json`, the project code, or the installed runtime.

## BrowserSync URL source of truth

When AI needs to test or confirm whether a page route, exposed function request, proxy-backed response, or local server workflow is working, check `./settings/bs-config.json` first.

Important rules:

- use `./settings/bs-config.json` as the source of truth for the active BrowserSync URLs in this app
- do **not** assume the proxy remains on the default `http://localhost:5090`; if that port is already in use, Caspian may use a different port
- confirm the current `local`, `external`, `ui`, and `uiExternal` values in `./settings/bs-config.json` before suggesting a browser URL, route test URL, or BrowserSync UI URL
- when frontend console logs, network errors, or terminal output suggest the app is being tested through the wrong URL or proxy port, re-check `./settings/bs-config.json` before changing app code

## Workspace Clarifications

Use `.github/copilot-instructions.md` for the repo-wide implementation rules. This file keeps only the workspace-specific retrieval and maintenance notes that help AI decide where to look next.

- Local Caspian docs live under `node_modules/caspian-utils/dist/docs/`.
- Workspace file instructions live under `.github/instructions/**/*.instructions.md` when the repo needs task- or library-specific AI guidance that should not be always-on.
- Use `node_modules/caspian-utils/dist/docs/core-runtime-map.md` when a behavior is controlled by `main.py`, package-owned runtime helpers such as `.venv/Lib/site-packages/casp/runtime_security.py`, or other `.venv/Lib/site-packages/casp/**` files and the owning file is not obvious yet.
- Treat `public/` as a URL-root mapping, not a directory registry: an existing `public/icons/app.png` is served as `/icons/app.png` without a per-directory route, mount, or prefix list in `main.py`. `PublicFilesMiddleware` handles only `GET`/`HEAD`, resolves paths beneath `public/`, rejects traversal and symlink escape, and falls through when no file exists. Keep it inside `SecurityHeadersMiddleware` and outside rate limiting, body parsing, sessions, CSRF, auth, RPC, and page routing.
- Use `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` when a behavior is controlled by the shipped PulsePoint browser runtime and the task names state, effects, refs, context, portals, directives, `pp.rpc`, uploads, streaming, SPA navigation, or scroll restoration.
- Use `node_modules/caspian-utils/dist/docs/websockets.md` when the task names WebSockets, live bidirectional channels, socket origin checks, socket auth/session behavior, broadcast managers, or native browser `WebSocket` clients.
- Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
- **If navigation loading UI is wanted, it is `loading.py` — never hand-roll it.** The file itself is optional and most subtrees do not have one; this rule governs the implementation, not whether to add the feature. A spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, or a manual overlay built for route-to-route navigation is a reimplementation of the shipped runtime (`casp/loading.py` collects the files, `caspian_config.py` derives their URL scopes, the browser runtime resolves the closest ancestor scope and swaps the `pp-loading-content="true"` pane). The full contract is in the "Special files" block above; the shipped example is `src/app/dashboard/loading.py` plus the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. In-page waits (RPC, submit, filter, upload) are `pp.state` in the owning component and are not this feature.
- When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
- **After any `prisma/schema.prisma` change, exactly two commands are required, in order.** Step 1 — sync the database, pick one: `npx prisma migrate dev` (development default, creates and applies a migration) or `npx prisma db push` (migration-less direct sync). Step 2 — always: `npx ppy generate`, the **only** command that regenerates the Python ORM the app imports (`src/lib/prisma/__init__.py`, `db.py`, `models.py`, `settings/prisma-schema.json`). The two generators are different toolchains from the same schema: `npx prisma generate` builds the Node/TypeScript `@prisma/client` used only by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Never hand-write or patch the generated Python ORM instead of regenerating it; the generated client is ready to import from `src.lib.prisma`. See `node_modules/caspian-utils/dist/docs/database.md` "Two Generators, One Schema".
- Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
- Component-first page composition is the highest-priority authoring rule for this workspace (see `.github/copilot-instructions.md`). Build pages as a short assembly of `x-*` chunk components (top menu, sidebar, header, content sections, cards, forms, footer) and keep each chunk's long markup inside its own focused single-file `html(...)` component, so the page template in `src/app/**/index.py` stays small instead of holding a wall of HTML. Plan the chunk breakdown before writing the route, not as a later cleanup pass.
- **PulsePoint is not React and its templates are not JSX.** This workspace's guidance compares PulsePoint to React in exactly two places — the `pp.*` hook API inside `<script>`, and how components are split by responsibility — and that comparison stops at the markup. Template files are plain HTML. Never generate `{cond && (<div/>)}`, `{cond ? <A/> : <B/>}`, `{list.map(item => (<tr/>))}`, `className`, `htmlFor`, camelCase `onClick`, `style={{...}}`, `dangerouslySetInnerHTML`, or `<>…</>`. Use `hidden="{!cond}"` for conditionals, `<template pp-for="item in list">` with `key="{item.id}"` for lists, and **always quote brace attributes** — `class="{...}"`, never `class={...}`. The unquoted form is invalid HTML: the parser splits the value on spaces into junk attributes, the component root never compiles, and the route serves a blank page with no console error (the body's `opacity: 0` reveal never fires). There is no `pp-if`, `pp-show`, `pp-else`, or `pp-key`. Sanity check before finishing any template: it must still be valid HTML with every `{}` deleted. See `node_modules/caspian-utils/dist/docs/pulsepoint.md` sections "PulsePoint Is Not JSX", "Complete Directive And API Surface", and "Conditional rendering".
- Split single-file Python components by responsibility, using the same mental model as React components — **for decomposition and single-root shape only, never for syntax** (see the rule above). A page with tabs should usually have one component for the tab shell and separate components for each substantial tab panel. A section with its own form, table, toolbar, or list should usually be its own component with data and options passed by props, not an unrelated block inside a giant Python file.
- **Authoring is Python-only, single-file, and `html(...)` is the one markup entrypoint — pages, layouts and components alike.** A route is one `index.py` whose `page()` returns `html(r"""...""", **context)`; a layout is one `layout.py` whose `layout()` returns `html(r"""...""", **context)` (optionally as `(html(...), props_dict)`); a component is one `.py` returning `html(...)`. Inside `html(...)`, `{{ ... }}` is server-side Jinja and `{ ... }` stays for PulsePoint. **Never author markup as a Python f-string**: the brace dialects invert (`{x}` becomes server interpolation and a PulsePoint binding must be written `{{x}}`), the string is not autoescaped yet `Component.acall` still marks it trusted, and the `<x-*>` scope stash is skipped so a directly-called component cannot resolve nested tags. The `templates` gate enforces this — existing f-string components are frozen in `settings/fstring-components.json` and any NEW one fails `npm run check`; converting one means deleting its baseline line. Prefer `r"""..."""` when the markup's `<script>` contains backslashes. See `node_modules/caspian-utils/dist/docs/components.md` and `file-conventions.md`.
- **A layout's `html(...)` is deferred, not rendered at call time**, because `children` is the page beneath it and does not exist yet. `html(...)` called from a `layout()` returns a `LayoutTemplate` — the unrendered source plus the author's context — and the layout engine renders it once with `children`/`layout`/`metadata` merged in; those three engine-owned names always win over author context. Deferral is keyed on the `layout()` frame specifically, so a component the layout calls, or a helper in the same file, still renders eagerly. The other accepted shapes (a bare template string, `(str, props)`, a props `dict`, `None`) take the same engine path, because `LayoutTemplate`'s string value _is_ the raw source. A layout that places its children nowhere (no `<slot />`, no `{{ children }}`) now raises `LayoutChildrenError` instead of serving an empty shell.
- In a prop-receiving single-file Python component, `x-*` attributes arrive as raw string kwargs (including unevaluated strings such as `"{permOpen}"`) and do not become browser `pp.props` automatically. Forward every browser-facing prop onto the single native root with `get_attributes({...}, props)`, render `<root {{ attributes }}>`, and pass `attributes=attributes` into `html(...)`. Props accepted by Python but not re-emitted are silently absent from `pp.props`; no server error or browser warning is raised. Remember that forwarded names are real DOM attributes, so avoid unintended native collisions such as `title` when a component-specific name like `user-name` is appropriate. A named Python parameter is consumed out of `**props`, so it is no longer in the passthrough dict and must be listed explicitly in the `get_attributes` defaults. Forwarding also does not preserve types: a brace expression (`volume="{vol}"`) is evaluated in parent scope and keeps its real type, but a literal server value renders as a string, so `volume="0"` makes `volume === 0` false; a valueless attribute becomes `true`; `None`/`False`/`""` are omitted entirely so the prop reads `undefined` rather than `false`; and JS reserved words such as `class` are dropped from `pp.props`. When an icon toggle, `hidden`, or class binding silently does nothing, verify the prop is on the rendered root before debugging the expression. See `node_modules/caspian-utils/dist/docs/components.md` "Receiving Props In A Python Component" and "Every Prop A Template Reads Must Be Forwarded To The Root."
- Composition is Python-import-driven everywhere: a module's `x-*` tags (page, layout, or component) resolve from the Component objects imported into that module; the Python import is the only import mechanism, enforced by the compiler and the `templates` gate rule `import-comment`. Runtime resolution precedence inside a component's output is inherited ancestor components, then the module's own Python imports. Slot content (children) resolves in the scope where it was authored, so the module that writes an `x-*` tag in markup must import that component. For directories whose names are not valid Python identifiers (hyphens, `(group)`), bind the component with `Name = importlib.import_module("src.app.some-dir.Name").Name`.
- For first-party HTML interactivity in this workspace, PulsePoint is the required default. Use PulsePoint `on*` event attributes, `pp.state`, refs, effects, directives, and `pp.rpc()` instead of inventing id/data-attribute driven JavaScript with `querySelector`, `getElementById`, `addEventListener`, manual `innerHTML`, or parallel client state. For simple forms, bind `onsubmit` in the HTML, convert named fields with `Object.fromEntries(new FormData(event.currentTarget).entries())`, and validate/normalize that payload in Python; do not add `pp-ref` to each input, create a form ref, and attach an effect-managed submit listener just to collect submitted values.
- For PulsePoint performance work, read `node_modules/caspian-utils/dist/docs/pulsepoint.md` "High-performance authoring" and `fetch-data.md` "Search, filters, and request races" before changing the runtime. Diagnose ownership first: `pp.state` is for values whose change must produce a render (markup, bound props/context, or render-dependent effects); `pp.ref` is for timers, request generations, pagination cursors, and transient query text that should persist without rendering. A debounce delays work but does not make a state update cheap. Keep high-frequency input state in the smallest owning component, do not toggle loading state when that toggle changes no visible UI, discard stale RPC responses, and remember that `pp.transition()` reports pending work but does not provide concurrent rendering. Treat a large component that explicitly requests unnecessary renders as an authoring issue; treat byte-identical output that still traverses a large stable subtree as a possible runtime issue. Never "optimize" either case by replacing PulsePoint with manual DOM wiring.
- **Keyed `pp-for` rows are reconciled per row, not per list.** The runtime remembers the markup each keyed row produced; a row that re-renders byte-identically is stood in for by a `<tag pp-keep key="…">` placeholder that the morph pass resolves by repositioning the existing live node, skipping re-parse, attribute sync and event rebinding for that subtree. Three or more _consecutive_ reused rows collapse further into a single `<tag pp-keep-run="k1,k2,…">` marker, so the parse cost of a mostly-unchanged list stops scaling with its length — but the morph still resolves every key in that list individually and in order, so liveness and ordering are verified exactly as they are for a single placeholder. Never "optimise" the run marker by trusting its length instead of its keys; that trade turns a stale cache into silent DOM corruption rather than a caught mismatch. Four invariants hold this together and must not be relaxed casually: a placeholder **reuses the row's own tag name**, because an unknown tag inside `<tbody>`/`<select>` is foster-parented out of its container and silently tears the row out of its table; a run marker also carries a `key` so the morph still takes its keyed path; rows carrying a nested component boundary, owned slot content, or a context provider are excluded, because the morph pass is what refreshes those bindings; and the render cycle compares rows _emitted_ as placeholders against rows _resolved_, permanently disabling reuse for that component and re-rendering it from full markup if the two ever disagree. Keys that cannot be packed unambiguously into the marker (anything outside `[A-Za-z0-9_.:-]`) fall back to one placeholder per row. Recognising an unchanged row is deliberately **positional first**: a row whose markup matches what the same index rendered last time reuses that row's stored entry outright, costing one string comparison and skipping the key regex, the allocation and the map insert entirely; only a row that misses that check is parsed, and then looked up by key so a row that _moved_ rather than changed is still recognised. Entries are immutable once built and shared with the next render, and the key index is rebuilt only when the key sequence itself changed — so keep any new per-row work off the fast path, or a mostly-unchanged list starts paying per row again. Eligibility is decided at compile time — nested loops (whose cache slot would be shared across every iteration of the outer loop) and loop bodies that render more than one root element per row opt out entirely. The author-facing half of this contract is in `pulsepoint.md` "Keyed rows are reconciled per row, not per list".
- **A mounted nested boundary's body is elided from the parent's render, not re-emitted.** The compiler already masks a script-bearing child boundary out of the parent template (`maskComplexComponents`) and restores its markup verbatim on every parent render — that markup is a compile-time _constant_, and the morph pass stops at the boundary and never descends into it, so parsing it was pure waste. `BoundaryContentCache` therefore emits the boundary's opening tag with an empty body (marked `pp-keep-content`) once the child is mounted, keeping attributes — which carry the props — emitted and reconciled exactly as before. Measured on a 100-child shell, this removed the large majority of the parent's render time. The invariants that make it safe must not be relaxed: a stub is emitted **only when the previous committed render emitted that same boundary key** (a first or reappearing boundary renders in full, because the child bootstraps from the markup the parent emitted); boundaries whose content the parent is responsible for reconciling are excluded at compile time (`pp-owner`, `<pp-context-provider`, `pp-ref`/`data-pp-ref` inside the body); the marker is stripped from the source before attribute syncing so it never reaches the DOM or `pp.props`; and, exactly like loop rows, the render cycle compares stubs _emitted_ against stubs _resolved against a live, non-empty boundary_, disabling reuse for that component and re-rendering from full markup (with `shouldForceChildRefresh` so children repaint) if the two disagree. **Owned/slot content is compiled in the owner's scope but rendered into a different component's DOM**, so `createOwnedRenderScope` deletes `__pp_boundary_html` — restoring it would let a component stub a boundary its own morph can never reach. The author-facing half is in `pulsepoint.md` "A mounted child boundary is reconciled by its attributes, not by its markup".
- **The bootstrap pass merges each parent scope once, not once per child.** `NestedBoundaryManager` caches a `ScopeBundle` (merged ancestor scope + compilable keys + key signature + argument values) per boundary parent for the duration of one `bootstrap()` call, and threads it into both `applyAttributeInterpolations` and the child's `computePropsFromAttributes`. Re-deriving it per child re-ran an `Object.assign` merge of the whole ancestor chain and missed the scope-descriptor WeakMap every time, because the merge produced a fresh object. Reusing one object is what keeps that cache warm. The one case that must keep resolving per child is a child whose base id equals its parent's boundary id, where `resolveComponentId` relative to the child lands on a different component.
- **A nested boundary's bound root attributes are evaluated from its captured bindings, and written only when the value changes.** `__ppRawBindings` is the source of truth for a boundary's expressions; the live attribute holds the _evaluated_ value continuously. `applyBoundaryBindings` evaluates from the capture and commits only on difference, and `syncNestedBoundaryAttributes` passes those attribute names to `syncAttributes` as `skipAttrs` whenever the expressions themselves are unchanged. Do not "simplify" this back into reading the expression off the element: that requires writing `{expr}` onto the live element before every evaluation, which is what made each bound attribute cost three DOM writes per render (morph writes source text, bootstrap rewrites it, bootstrap writes the result) — over 24,000 attribute mutations became 240 for 60 updates of a 100-child shell. Objects, functions and unparseable expressions must keep the raw binding text on the element, because `computePropsFromAttributes` re-evaluates them from it. Also note where the `pp-keep-content` marker is filtered: strip it from the throwaway source in `DomMorpher` rather than skipping it inside the `syncAttributes` per-attribute loop, because every list row pays for that loop.
- **A child whose props did not change is not re-walked.** `refreshPropsFromParent` only re-runs `bootstrapNestedComponents()` when the child produced nested runtime structure in its own last render (`hadNestedRuntimeStructures`, the same condition `render()` uses). For a leaf component — every card in a shell — the pass traversed nothing, rebuilt an empty provider set and collected an always-empty descendant list, once per child per parent render.
- **Every per-render capture store mints ids from a sequence that restarts at zero each render** (the `ppref_`, `ppinput_`, `ppselect_`, `ppchecked_`, `ppcontext*_`, `ppdefault*_` and `ppv_` families), so unchanged markup re-renders to an identical string. Do not give any of them a globally increasing counter: the loop capture store used to, which made every row carrying a per-row handler byte-different on every render and defeated both the byte-identical render skip and per-row reuse. These ids are also lifted out of event-handler source so all rows of one loop share a single compiled handler function — if an id format changes, the matching extraction must change with it, or every row compiles and caches its own handler.
- When `caspian.config.json` has `websocket: true`, socket behavior is app-owned: the single named-socket endpoint is wired in `main.py` and the layer lives in `src/lib/websocket/**`. Routes do not pass `websocket_path`/`websocket_url` into templates — `pp.socket(...)` already knows the shared endpoint; a route only names its `@socket()` function.
- **Named sockets are this workspace's preferred live-channel layer.** `src/lib/websocket/sockets.py` is the server half of `pp.socket(...)`: `@socket()` registers an async function by its own name (application-wide, duplicate names refused at registration), every connection lands on the single `SOCKET_PATH` endpoint (`/__pulsepoint/ws` — named for the PulsePoint runtime so every backend serving `pp.socket` uses the same path; wired in `main.py`, gated on `websocket: true`), the arguments arrive as the first frame (one JSON object, filtered against the handler signature like rpc payloads), and failure travels as an `{"error": "..."}` frame followed by a close. The handler declares a `socket` parameter (`Socket`: `recv`/`recv_text`/`send`/`sender`/`close`); `socket.sender()` + `SocketPool` is the broadcast pattern (see `src/app/chat/`). `@socket(require_auth=True, allowed_roles=[...])` delegates to `Auth`; the endpoint keeps the origin check, connection cap, message-size limit, per-connection rate, and idle timeout (outbound traffic counts as liveness). A socket in a route's `index.py` registers when the route first renders; shared sockets live in `src/lib/**`. The shipped browser runtime (`public/js/pp-reactive-v2.min.js`) connects `pp.socket(...)` to that same default path, so treat `SOCKET_PATH` as fixed unless the served runtime's default changes with it. This is the only socket layer: hand-written `@app.websocket(...)` + native `WebSocket` is reserved for wires the JSON-frame contract cannot carry (binary, non-JSON protocols) and must run the same origin check and `Auth` delegation itself. Read `node_modules/caspian-utils/dist/docs/websockets.md` "Named Sockets"; tests in `tests/test_socket.py`.
- Socket auth policy is per socket, not per endpoint: `@socket()` is public, `@socket(require_auth=True)` needs a session, `@socket(allowed_roles=[...])` adds RBAC — all delegating to Caspian's `Auth` (`Auth.set_request(websocket)` plus `is_authenticated`/`get_payload`/`check_role`) inside `sockets.py`. **The old public/private channel layer is gone**: there are no `/ws/live` / `/ws/public` endpoints, no `authorize_websocket(...)` guard, and no `WebSocketConnectionManager` pools — do not reintroduce them or write per-endpoint session parsing. Keep authenticated and guest traffic in separate `SocketPool`s, and treat the socket session as read-only (mutations are not persisted to the cookie over a WebSocket).
- For socket clients, use `pp.socket(name, args, handlers)` inside the owning component script: open it in `pp.effect(..., [])`, keep the handle in `pp.ref(...)`, close it in the effect cleanup. Reach for a native `new WebSocket(...)` only for a wire the named-socket contract cannot carry (binary frames, non-JSON protocols).
- Before changing socket security, verify the running code in `src/lib/websocket/sockets.py` — it owns the whole surface: origin allow-list, `MAX_WEBSOCKET_CONNECTIONS`, auth delegation, idle timeout with outbound-liveness, message-size limit, per-connection message rate, and the error-frame-then-close behavior. HTTP route privacy and `AuthMiddleware` do not by themselves protect WebSocket scopes: the HTTP middleware stack early-returns on `scope["type"] == "websocket"`, so only `SessionMiddleware` runs and the socket endpoint authorizes each connection itself.
- Keep route-specific backend logic in that route's `src/app/**/index.py`, including first-render data loading, route-owned `@rpc()` actions, auth checks, redirects, and validation. Move logic to `src/lib/**` only when it is shared by more than one route, feature, component, or integration.
- The `pp` component-script API mirrors React hooks **inside the `<script>` only** — the surrounding markup is never JSX: `state`, `effect`, `layoutEffect`, `ref`, `memo`, `callback`, `reducer`, `context`, `portal`, `id`, `syncExternalStore`, `imperativeHandle`, `transition`, `deferredValue`, `optimistic`, `errorBoundary`, plus `props`. Use `pp.id()` for generated `id`/`for`/`aria-*` values, `pp.syncExternalStore(...)` for sources the component does not own, and wrap failure-prone subtrees in a parent with `pp.errorBoundary()` instead of letting a render throw reach the console. Verify against `public/js/pp-reactive-v2.min.js` and see `pulsepoint.md` "Hooks and runtime API".
- For grouped-subtree SPA navigation UX, the current browser runtime keeps unmarked shell scrollers stable and uses `pp-reset-scroll="true"` on the content pane that should reset. Check `pulsepoint.md`, `routing.md`, and `public/js/pp-reactive-v2.min.js` before changing that behavior.
- Before updating docs, verify runtime-specific claims such as middleware order, route param injection, `layout()` behavior, `StateManager` persistence, safe public-file serving, response header, or session-secret behavior against the current `main.py` and installed `casp` package, especially `.venv/Lib/site-packages/casp/runtime_security.py`, rather than copying older notes.
- When generating or reviewing page templates, layout templates, or component markup, single-root is the default shape: one authored top-level parent element or one imported `x-*` root, with any owned `<script>` kept inside that same root. **A component may instead have sibling top-level nodes**, which make it a fragment, framed by the comment-pair boundary described below. `TemplateRootError` covers a component template with no root at all, or with an `x-*` tag as its only root. Prefer a single native root anyway — it is the only shape that can receive props. **For a page (`index.py`) or layout (`layout.py`)**: sibling top-level nodes are wrapped in a layout-neutral `<div pp-component="…" style="display: contents">` boundary host, so a page whose sections are genuinely siblings does not need a meaningless wrapper `<div>`. The same host is emitted for a _composition component_ whose authored root is another `x-*` tag, carrying the parent's forwarded props (which is what makes them reach `pp.props`) and `pp-ref-forward` — so an extra `display: contents` div between two component roots in rendered DOM is expected output, not a bug. Keep the owned `<script>` inside the template either way: the host is the boundary, so one script still covers every root.
- **A multi-root component is a fragment — the `<>…</>` shape.** When a component's `html(...)` has sibling top-level nodes, the compiler frames them with the comment pair `<!--pp:id-->…<!--/pp-->`, which `materializeRangeBoundaries` turns into a live `<pp-fragment style="display: contents" pp-component="id">` at mount, before the boundary scan. So a fragment component adds **no element** to the rendered tree, and it is the only shape that survives inside `<tbody>`/`<tr>`/`<select>`/`<optgroup>`, where a `display: contents` wrapper is foster-parented out by the HTML parser (the browser runtime deliberately leaves markers under those parents as comments, so the grouping renders but the fragment owns no identity there — a stateful fragment needs a context an element could also live in). The syntax is **implicit**: siblings in the template, nothing to hand-write. Still never type `<pp-fragment>` or `<!--pp:…-->` yourself — those are compiler/runtime output, and an authored marker is refused by the subtree render cache. **A fragment cannot receive props**: with no root element there is nowhere for `get_attributes(...)` forwarding to land and `pp.props` would be silently empty, so passing any attribute (including `pp-ref`) on the `<x-*>` tag of a fragment component raises `FragmentPropsError` at compile time — give the component a single native root when it needs props. Fragments are excluded from the subtree render cache; a fragment nested inside a cached subtree still has its marker id re-minted per instance.
- Form controls are controlled _or_ uncontrolled for an element's lifetime. `value="{state}"` / `checked="{state}"` is controlled; the lowercase HTML attributes `defaultvalue="{expr}"` / `defaultchecked="{expr}"` are the uncontrolled form and are real PulsePoint syntax (the camelCase React spellings are not). Binding `value` to state that starts `undefined` flips the mode and makes the runtime log `[PP-WARN] <input#x> changed from uncontrolled to controlled` once — fix the initial state, do not add both attributes.
- When generating or reviewing sign-in flows, do not ask the sign-in page to decide redirect targets by re-implementing `next` support or post-login routing. In this stack, redirect behavior is already owned by the Caspian auth runtime plus `src/lib/auth/auth_config.py`; protected-route guest redirects, auth-route redirects, and the default destination are centralized there, with `default_signin_redirect` defaulting to `/dashboard`.
- Component markup is server-deferred in an inert `<template>`. `main.py` finalizes every page through `defer_component_roots(...)`, which wraps each outermost `pp-component` root in `<template pp-component="…">`. The browser never parses/validates/fetches `<template>` contents, so raw `{...}` placeholders never reach live DOM at first paint. During `mount()`, PulsePoint captures and empties each plain component `<script>` before materializing `template[pp-component]` into live DOM, then evaluates that captured source in component scope; the same guard applies to scripts introduced by later morphs. Because of this, `{...}` is safe in ANY attribute or position — SVG geometry (`d`, `viewBox`, `points`, `transform`), URL attributes (`src`, `srcset`, `href`, `poster`), form `value`/date/number/color, and text placed directly inside `<table>`/`<select>`. Do NOT add per-tag workarounds to dodge browser first-paint validation: no static-path `hidden` toggles just to avoid binding `d`, no `data-*` URL holders, no gating `<img src>` behind `hidden`, and no SSR-resolving an initial value only to prevent a validation flash. Two compiler transforms still apply for different reasons and stay: `pp-style` (so `.html` source-file HTML/CSS tooling does not choke on `style="{...}"`) and the `<input>`/`<select>`/`checked`/`defaultvalue`/`<textarea>` value rewrites (attribute-vs-property correctness for controlled form fields), not first-paint validation.
- This workspace has an app-level quality gate for its own Python (`main.py`, `src/**`, `settings/*.py`), added on top of Caspian — the framework itself ships no test runner. One command, `npm run check` (which calls `uv run python settings/check.py`), runs `pyright` (types), `ruff` (lint), and `pytest` (tests) in a single pass and prints each problem as `path:line:col [tool:code] message`, exiting non-zero on failure. Running it is mandatory: after you create, edit, or delete app-owned Python — bug fix, new file, refactor, or feature — run it and get it fully green before treating the change as done, and do not report work as finished on the assumption that it passes. Fix every reported location and re-run until clean. The gate runs four tools: `pyright`, `ruff`, `templates`, and `pytest`.
  - **`templates`** (`settings/check_templates.py`) lints authored markup — `src/**/*.html` plus the triple-quoted markup inside single-file Python components — for JSX and non-existent directives, and **fails the gate** on a hit. It exists because JSX kept reaching routes: `{users.map(user => (<tr/>))}` renders one literal row, and an unquoted `class={...}` is invalid HTML that blanks the entire page with no console error. Rules: `jsx-map`, `jsx-logical`, `jsx-ternary-element`, `unquoted-brace-attr`, `react-attribute`, `camelcase-event`, `jsx-fragment`, `style-object`, `unknown-directive` (`pp-if`/`pp-show`/`pp-else`/`pp-key`/…), `pp-for-placement` (`pp-for` outside `<template>`), `html-form`. `<script>`, `<pre>`/`<code>`, and HTML comments are excluded, so real component JavaScript and docs samples never trip it.
    - **`html-form` enforces the single markup form: every `html(...)` call takes a raw triple-quoted literal, `html(r"""...""")`.** Nothing else is accepted — not a plain `"""..."""`, not a single-line string, not an f-string, not a variable holding markup assembled elsewhere. A non-raw literal silently rewrites backslashes, so a JS regex (`split(/\s+/)`) or a `'\n'` in a component script means one thing in the source and another at render, and the two forms have to be written differently (`\\s` vs `\s`) to produce the same output. An f-string additionally inverts the brace dialects and emits interpolated data unescaped while `Component.acall` still marks it trusted. One form also keeps the markup surface greppable. This drifted once already — 437 calls used the raw form and 133 did not — so the rule exists to hold it. Markup that must be built dynamically stays in the template: a tag name is a server value like any other (`html(r"""<{{ tag }} …>""", tag=tag)`), because Jinja renders before the component compiler sees the markup. Coverage is in `tests/test_check_templates.py`, including a repo-wide clean assertion. Run it alone with `uv run python settings/check_templates.py`.
  - Its boundary: it does not validate Tailwind/`globals.css`, `x-*` tag resolution, single-root violations, or `public/js/**`. Those still surface only at render time — verify front-end changes by loading the affected route in the browser (BrowserSync URL from `./settings/bs-config.json`).
- **Browser console errors reach the `npm run dev` terminal and `.casp/browser-log.jsonl`.** `settings/dev-log-bridge.ts` adds a BrowserSync middleware serving `/__pp-devlog.js` and receiving `POST /__pp-devlog`; `_inject_dev_console_bridge(...)` in `main.py` adds the `<script>` tag, gated on **both** `IS_PRODUCTION` and `CASPIAN_BROWSER_SYNC_PORT`. That variable is normally set only by `settings/python-server.ts` when the dev stack spawns the server, but that is a convention about who sets it, not an enforcement — so the production check is what actually keeps the tag out of production and a static export, mirroring `_dev_cookie_scope`. The variable is deliberately absent from `.env`: `load_dotenv()` defaults to `override=False`, so the process env var always wins, and the dev stack picks a free port by walking upward from 5090 (`settings/bs-config.ts` `getAvailablePort`), making any hand-typed value either ignored or stale. The hook forwards only `[PP-ERROR]` / `[PP-WARN]` console output plus uncaught errors and unhandled rejections — ordinary `console.log` stays in the browser — and prints them with the route, message, and top stack frames, deduplicated within a 1s window. Do not switch this to the BrowserSync socket: mount-time errors fire before that socket connects, and BrowserSync's own `snippetOptions` injection does not fire against this app's proxied responses at all, which is why the tag comes from the render pipeline. `npm run check` only reports — auto-fix with `npm run check:fix` (runs `settings/fix.py`: safe ruff fixes, then the gate). Unused-import (`F401`) removal is guarded because component imports look unused to ruff: Caspian single-file components import children and use them only as `<x-*>` tags (`from .Dialog import DialogContent` → `<x-dialog-content>`), which ruff can't see, and casp resolves the tag from module globals at render time. So `F401` is `unfixable` in `pyproject.toml` (a raw `ruff check --fix` never deletes any import), and `settings/fix.py` removes dead imports only from files with no `<x-*>`-tag import (component files are skipped whole); `settings/check.py` suppresses the matching `F401` reports so the gate fails only on genuinely dead imports. Shared detection lives in `settings/_component_imports.py`. See `tests/README.md`. Tests live in `tests/`; tooling and config live in `pyproject.toml` (`[dependency-groups] dev`, `[tool.pyright]`, `[tool.ruff]`, `[tool.pytest.ini_options]`); install them with `uv sync --group dev`. The type checker is **pyright** (the same engine Pylance uses in the editor), so IDE squiggles and `npm run check` agree instead of disagreeing like the previous `pyrefly` setup. `[tool.pyright]` is `include = ["main.py", "src", "settings/*.py"]` with `exclude = [".venv", "node_modules", "**/__pycache__"]`, so it checks `main.py`, everything under `src` — including the generated `src/lib/prisma/**` ORM, which is analyzed, not excluded — and the `settings/*.py` orchestrator scripts (`check.py`, `fix.py`, `_component_imports.py`). It uses `typeCheckingMode = "basic"` (Pylance's default), and mirrors the old pyrefly suppressions by setting `reportReturnType = "none"` and `reportAssignmentType = "none"`; re-enable those per-rule when tightening. Because this is project-specific, keep it documented here and in `.github/copilot-instructions.md`, not in the packaged docs.

- **The markup formatter proves each block before writing it, and a skip is a result rather than a failure.** `settings/format.py` runs djLint over every `html(r"""...""")` template, then checks the output against `settings/_markup_equivalence.py` — a tokenizer that decides whether the reformatted markup is _guaranteed_ to render identically. Only proven blocks are written back; the rest are reported with a reason and left alone. The gap this closes is specific: djLint is a general HTML formatter, so it inserts a newline between a block tag and an adjacent inline or `<x-*>` tag, and that newline renders as a visible space because a custom element's `display` comes from CSS the formatter cannot see. Four rules make the oracle correct rather than merely cautious — whitespace inside a tag never renders; a whitespace run in text collapses to one space but presence-vs-absence between inline elements is significant; a text node's edge whitespace collapses only when its parent is block-level; and `<pre>`/`<textarea>` render verbatim while `<script>`/`<style>` are indentation-insensitive code. Those last two are additionally **masked out before djLint sees them**, so code is preserved by construction, not by proof — djLint otherwise reads `/>` inside a JS regex as a tag delimiter and rewrites `.replace(/>/g, …)` into `.replace( />/g, …)`. Do not resolve a skip by relaxing the oracle: a false positive there silently changes rendering across hundreds of templates at once, which is exactly the failure a bulk reformat cannot afford. Coverage is in `tests/test_format.py`, which pins both directions. Two tag families are registered deliberately: `<x-*>` component tags are given to djLint via `--custom-html` so a component tree nests instead of sitting flat, while the oracle still treats them as **inline** — so indenting tags already on separate lines is accepted and separating two touching tags is still refused; and SVG elements count as block-level in the oracle because an SVG fragment lays out no text, so indenting the children of an inline `<svg>` in a component template cannot change what is drawn (`<text>`, `<tspan>`, `<textPath>` and `<foreignObject>` are excluded, since they do render their content).
- **A BOM makes a Python file invisible to the `templates` gate.** `check_templates.py` reads files with `path.read_text(encoding="utf-8")` and `ast.parse`, and a UTF-8 BOM makes that parse raise, which the scanner swallows as "skip this file". One file (`src/components/dashboard/ProductsPage.py`) carried a BOM and was silently exempt from every template rule, including `html-form` — it had been using the non-raw `html("""...""")` shape the whole time. `ruff format` strips the BOM, so the violation surfaced the moment formatting ran. If a template rule ever seems not to apply to a file, check for a BOM before assuming the rule is wrong.
- **`npm run logs` is how an agent checks front-end health, because the dev terminal usually belongs to someone else.** The developer typically runs `npm run dev` in their own shell, so its stdout is invisible to an agent session — and starting a second dev stack is the wrong fix: `npm run dev` begins with `projectName`, which **deletes `.casp/` and `caches/`** out from under the running server, and then binds different ports and rewrites `settings/bs-config.json`, orphaning the browser tab the developer is actually looking at. **Never start a second `npm run dev` to get a log.** Instead `settings/dev-log-bridge.ts` appends every event to `.casp/browser-log.jsonl` (JSONL, one event per line, gitignored, truncated per dev session because `.casp/` is recreated at startup), and `settings/browser_log.py` renders it via `npm run logs`. `npm run check` prints the same digest at the end of its run but **never lets it affect the exit code** — whether a route has been exercised depends on someone clicking around, and a gate that flaky gets ignored; `--fail-on-error` opts in for scripts that want it, `--no-browser` skips the section.
  - **The log records successful page loads, not just errors.** This is the property that makes it safe to act on, and it must not be removed as redundant. A clean reload writes nothing on its own, so without `load` events a fixed error would sit in the file forever and an agent would "fix" a bug that no longer exists. A route's status is therefore whatever happened during its **most recent load**: one clean reload retires every earlier error for that route (reported as `N earlier error(s) resolved`). Errors are tied to their load by a client-generated `page` id, never by arrival order, because two `fetch` POSTs can land out of sequence.
  - **`NEEDS RECHECK` is the status that matters most — a reload does not re-test everything.** A reload re-runs mount, so it is real evidence against a mount-phase error. It never clicks a button, so it proves nothing about an error thrown from an event handler. Errors are classified by how long after their page load they arrived (`phase: "mount"` within 2s, `"interaction"` after), and an interaction error is **not** cleared by a later load — it is carried as `NEEDS RECHECK` with its timing shown. Treating that as `CLEAN` is exactly how a live bug gets signed off; this was a real defect in an earlier version of this tool, on a route whose `onclick` threw 17s after load. To clear one, repeat the interaction (click/submit) and re-run `npm run logs`.
  - **The log is compacted on every source change, not appended forever.** A dev session left running for hours would otherwise grow an unbounded file (~331 KB / ~85k tokens in a measured 6-hour session), and `.casp/` is only wiped by a full `npm run dev`, which nobody does mid-session. So when `src/**` changes, `compactBrowserLog(...)` rewrites the file down to the session header, a `{"type":"restart"}` marker, and the errors still open — resolved history is dropped. Survivors are marked `"carried": true` and dropped at the _next_ compaction, so a stale interaction error cannot haunt the log forever. Reading the digest costs ~855 tokens regardless of session length, because it is bounded by route count, not time.
  - **Do not diagnose from the raw JSONL.** It is history, not state: it can hold errors resolved minutes ago, and errors carried across a source change. Always prefer `npm run logs`, which derives current status. If you do read the file, the `session` line's `readme` states the rule — **a later `load` or `resolved` supersedes earlier errors on that route, except `phase: "interaction"` errors, which a reload cannot re-test.**
  - **`UNCONFIRMED` means an error with no matching `load` in this log** — almost always a browser tab left open across a dev restart, reporting against a page that was rendered under the previous session. The error was real when it fired but may already be fixed. Ask for a reload of that route and re-run `npm run logs` before treating it as a live bug; do not start editing code on an `UNCONFIRMED` line alone.
  - **Read the three not-an-error states literally.** `NONE` = no dev session ever wrote — nothing has been observed. `WARN … dev server is NOT running` = the log is left over from an exited session; treat every line as history. `CLEAN` on a route means _that route was opened and rendered without error_. A route absent from the listing was never opened, which is **no signal, not a pass** — say so rather than reporting the front end healthy.
  - **How to act on a failure.** The digest gives route, message, and the top stack frames. Fix the owning `src/app/<route>/index.py` or the component it names, then get the route exercised again and re-run `npm run logs`. What "exercised" means depends on the status: a mount error needs a **reload**; a `NEEDS RECHECK` interaction error needs the **same interaction repeated** — a reload will not clear it and should not be expected to. The flip to `CLEAN` is the confirmation. Do not treat a still-present error as unfixed until it has actually been re-exercised; the log cannot know about a fix nobody has run.
  - Coverage is in `tests/test_browser_log.py`. The event shape is defined by `LogEvent` in `settings/dev-log-bridge.ts` and consumed by `build_report(...)` in `settings/browser_log.py`; change both together. Known limit: the client script runs per full document load, so **SPA navigations do not emit a `load` event** — the log tracks full page loads only.

- Security posture is enforced in three places and must stay in sync: `casp.runtime_security` (environment resolution, security headers, safe public-file serving), `main.py` (middleware stack, MCP gate, cache eligibility, the named-socket endpoint), and the Caspian-owned files under `src/lib/**` — `src/lib/auth/auth_config.py` (route privacy and RBAC policy) and `src/lib/websocket/sockets.py` (socket origin check, connection ceiling, auth delegation, and per-connection limits). Key behaviors an agent must not silently undo:
  - **`APP_ENV` resolves fail-closed** via `is_production_environment()`. Only an explicit development value (`dev`, `development`, `local`, `staging`, `test`, `testing`) enables the relaxations; unset or misspelled counts as production. Never reintroduce `os.getenv("APP_ENV") == "production"` in a new module — import the shared helper so `main.py`, `casp.rpc`, and `src/lib/websocket` cannot disagree.
  - **Server-interpolated values must never carry live PulsePoint syntax.** The Jinja `Environment` in `casp.layout` runs a `finalize` hook that encodes `{`/`}` as `&#123;`/`&#125;` on every non-`Markup` value, because `TemplateCompiler` compiles the rendered DOM and would otherwise execute a stored `{fetch(...)}` as JavaScript. `Markup` is the trust boundary: `| safe`, `get_attributes(...)`, `merge_classes(...)`, the `json`/`dump` filters, and rendered layout children all return `Markup` and legitimately keep their braces. A new helper that emits PulsePoint syntax must return `Markup`, and a helper that formats user data must not.
  - **Never cache an authenticated render.** `CacheHandler` keys on the URI alone, so `is_request_cacheable(request)` gates both the read and the write; a route's `Cache(...)` cannot override it.
  - **RPC payload keys are filtered against the function signature** (`_filter_call_kwargs`), so a parameter is client-settable only when declared. Declaring `**kwargs` opts into the whole payload — do that only deliberately.
  - **Configured public upload directories serve user content in attachment mode** via `PublicFilesMiddleware.inline_safe_subdirectories`; only allow-listed real image types render inline. This workspace configures `uploads` for `public/uploads/**`. If another top-level public directory will receive untrusted runtime uploads, add it to that mapping before storing files there. Do not apply attachment mode to trusted first-party assets, which must stay inline.
  - **`/mcp` requires `MCP_AUTH_TOKEN`.** With no token the endpoint stays open in development and returns 503 in production. It is mounted outside the routing tree, so `AuthMiddleware` does not protect it.
  - Security-relevant environment variables beyond the pre-existing set: `MCP_AUTH_TOKEN`, `RATE_LIMIT_PAGES` (default `200/minute`; existing public-file `GET`/`HEAD` requests are served before the limiter and `/health` is explicitly exempt), `CONTENT_SECURITY_POLICY` (replaces the default policy wholesale), `MAX_WEBSOCKET_CONNECTIONS`, `MAX_WEBSOCKET_MESSAGES_PER_WINDOW`, `WEBSOCKET_RATE_WINDOW_SECONDS`, and `WEBSOCKET_ALLOWED_ORIGINS` (required in production — the same-origin fallback is derived from the client-supplied Host header and is development-only).
  - Coverage lives in `tests/test_layout.py` (brace escaping), `tests/test_runtime_security.py` (environment + CSP + path safety), `tests/test_request_security.py` (cache eligibility, page rate limit, socket message rate, CSRF cookie scope), `tests/test_mcp_security.py`, `tests/test_upload_serving.py`, `tests/test_socket.py` (origin check, socket auth, wire limits), `tests/test_rpc.py`, and `tests/test_auth.py`.

- This workspace supports static HTML export (SSG, like Next.js `output: export`) as an app-owned build convention on top of Caspian, driven by two `settings/` scripts and two `package.json` scripts. `npm run static` builds metadata + Tailwind then exports every static route through `settings/build-static.py` (which boots the app via Starlette `TestClient`, writes `static/<route>/index.html`, and mirrors the complete `public/**` tree into `static/`). Its skip policy is "warn & skip": dynamic routes are only pre-rendered when their `index.py` exports `static_paths` (the `getStaticPaths` equivalent), and auth-gated / non-200 / non-HTML routes are reported and skipped. `npm run static:serve` runs `settings/serve-static.py`, a hardened preview server that serves only `static/`, binds loopback `127.0.0.1` by default (network exposure is opt-in via `HOST=0.0.0.0`), and auto-selects a free port by walking upward from a preferred default (8000, overridable via `PORT`) so an occupied port never aborts the preview. Keep `static/`, `settings/files-list.json`, and `settings/component-map.json` treated as generated outputs. `pp.rpc()`, auth, WebSockets, streaming, and per-request server data are inert in a static export. See `node_modules/caspian-utils/dist/docs/static-export.md`.

## Task Routing

Use this map before making changes.

If the task generates or edits route, layout, or component HTML templates, check `routing.md`, `components.md`, and `pulsepoint.md` before writing markup. Enforce the root-shape contract there: one authored root by default, any owned `<script>` inside that root — relaxed to a comment-pair fragment boundary for a multi-root component (which then cannot take props) and to a `display: contents` boundary host for a multi-root page or layout. For reactive behavior, button clicks, form events, uploads, filters, toggles, and list updates, use PulsePoint in the template first instead of standard DOM-event wiring. For normal form submits, prefer `onsubmit="{submitForm(event)}"` plus `Object.fromEntries(new FormData(event.currentTarget).entries())` over `pp-ref`/`pp.effect` listener boilerplate.

- Project layout and file placement: read `node_modules/caspian-utils/dist/docs/index.md` and `node_modules/caspian-utils/dist/docs/project-structure.md`. Verify against the current workspace tree.
- File conventions and special route files: read `node_modules/caspian-utils/dist/docs/file-conventions.md` and `node_modules/caspian-utils/dist/docs/routing.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/layout.py`, `.venv/Lib/site-packages/casp/loading.py`, and `.venv/Lib/site-packages/casp/caspian_config.py`.
- Navigation loading UI, route-change spinners, skeletons, or "show something while the next page loads" — **when the task actually asks for one**: this is `src/app/**/loading.py`, not new code. No loader at all is a valid, common configuration. Read `node_modules/caspian-utils/dist/docs/file-conventions.md` "`loading.py`" and `pulsepoint.md` "SPA, loading, and navigation helpers". Verify against `.venv/Lib/site-packages/casp/loading.py`, `.venv/Lib/site-packages/casp/caspian_config.py` (`LoadingEntry`, `_to_url_path`), `public/js/pp-reactive-v2.min.js` (`showLoadingTransition`, `findLoadingElement`, `updateContentWithTransition`), and the shipped `src/app/dashboard/loading.py` + `src/app/dashboard/layout.py` pair.
- Feature availability and tooling switches: read `caspian.config.json`. Verify against the current workspace tree, `main.py`, `prisma/**`, and `public/js/**`.
- Framework internals and core-file lookup: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md`. Verify against `main.py`, `.venv/Lib/site-packages/casp/**`, and the matching feature docs.
- PulsePoint browser runtime lookup: read `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `public/js/pp-reactive-v2.min.js`, `main.py`, and `.venv/Lib/site-packages/casp/components_compiler.py`.
- Library-specific and task-specific rules: read the matching `.github/instructions/**/*.instructions.md` file. Verify against `caspian.config.json`, the current workspace tree, and the owning app and lib files.
- MCP server layout and launch flow: read `node_modules/caspian-utils/dist/docs/mcp.md`. Verify against `settings/restart-mcp.ts`, `package.json`, and `src/lib/mcp/**`.
- Routing, layouts, metadata: read `node_modules/caspian-utils/dist/docs/routing.md`. Verify against `main.py` and `.venv/Lib/site-packages/casp/layout.py`.
- SPA navigation and scroll restoration: read `pulsepoint.md`, `routing.md`, and `core-runtime-map.md`. Verify against `public/js/pp-reactive-v2.min.js`, `src/app/**/layout.py`, and `main.py`.
- Auth, sessions, RBAC, providers: read `node_modules/caspian-utils/dist/docs/auth.md`. Verify against `src/lib/auth/auth_config.py`, `main.py`, `.venv/Lib/site-packages/casp/runtime_security.py`, and `.venv/Lib/site-packages/casp/auth.py`.
- Social login (Google or GitHub sign-in): treat it as shipped. `main.py` already registers both providers via `Auth.set_providers(...)`, and `AuthMiddleware` already serves `/api/auth/signin/{google,github}` and `/api/auth/callback/{google,github}`. Link a button at the signin path and set `.env` credentials instead of hand-rolling OAuth. Read `node_modules/caspian-utils/dist/docs/auth.md` "OAuth Providers" and verify against `main.py` plus `src/lib/auth/auth_config.py`.
- RPC, data loading, streaming, uploads: read `node_modules/caspian-utils/dist/docs/fetch-data.md` and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `.venv/Lib/site-packages/casp/rpc.py`, `public/js/pp-reactive-v2.min.js`, and `main.py`.
- AI/LLM/chat or any one-way streaming output: use the shipped RPC streaming path (generator `@rpc()` that `yield`s chunks, consumed by `pp.rpc(..., { onStream })`); for an LLM SDK, `async for ... yield`. Read `node_modules/caspian-utils/dist/docs/fetch-data.md` "Streaming Responses" and `node_modules/caspian-utils/dist/docs/pulsepoint.md`. Verify against `.venv/Lib/site-packages/casp/streaming.py`, `.venv/Lib/site-packages/casp/rpc.py`, `public/js/pp-reactive-v2.min.js`, and `main.py`. Do not reinvent with raw `fetch`/`ReadableStream`, `EventSource`, or WebSockets.
- WebSockets and live channels: first confirm `caspian.config.json` has `websocket: true`, then read `node_modules/caspian-utils/dist/docs/websockets.md`. Verify against `main.py`, `src/lib/websocket/**` when present, the owning `src/app/**` route files, and `settings/bs-config.json`.
- File uploads and managers: read `node_modules/caspian-utils/dist/docs/file-uploads.md` and `node_modules/caspian-utils/dist/docs/fetch-data.md`. Verify against `src/app/**`, `src/lib/**`, `prisma/**`, `settings/bs-config.ts`, `main.py`, and `.venv/Lib/site-packages/casp/runtime_security.py`; any top-level public directory that receives untrusted uploads must be configured for restricted inline media in `PublicFilesMiddleware`.
- Server state: read `node_modules/caspian-utils/dist/docs/state.md`. Verify against `.venv/Lib/site-packages/casp/state_manager.py` and `main.py`.
- Page caching: read `node_modules/caspian-utils/dist/docs/cache.md`. Verify against `.venv/Lib/site-packages/casp/cache_handler.py` and `main.py`.
- Validation: read `node_modules/caspian-utils/dist/docs/validation.md`. Verify against `.venv/Lib/site-packages/casp/validate.py`.
- Dates, times, "today", or date-range queries: read `node_modules/caspian-utils/dist/docs/core-runtime-map.md` "Application time (`casp.app_time`)". Verify against `.venv/Lib/site-packages/casp/app_time.py` and the `APP_TIMEZONE` resolution in `main.py`. **Never write a bare `datetime.now()` in `src/**`or`main.py`** — it returns the server's local wall clock, which silently disagrees with the UTC timestamps `src/lib/prisma/models.py`writes. Use`app_time.now()`/`today()`for the current moment,`to_app_time(...)`to display a stored value, and`day_bounds_utc(...)`with`gte`/`lt` to query a calendar day. Session expiry and cache TTLs stay on UTC and must not be routed through this module.
- Database and seed flow: read `node_modules/caspian-utils/dist/docs/database.md` — start at "Two Generators, One Schema" for the required command order after schema changes (`npx prisma migrate dev` or `npx prisma db push`, then always `npx ppy generate`; `npx prisma generate` is Node-client-only and never a substitute). Verify against `prisma/schema.prisma`, `prisma/seed.ts`, and `src/lib/prisma/**`.
- Static export (SSG) or previewing a static build: read `node_modules/caspian-utils/dist/docs/static-export.md`. Verify against `package.json` (`static`, `static:serve`), `settings/build-static.py`, `settings/serve-static.py`, and `settings/project-name.ts`. This is an app-owned convention, not a shipped Caspian feature and not gated by a `caspian.config.json` flag. `npm run static` = `npm run build && uv run python settings/build-static.py`, so it regenerates `settings/files-list.json` (via `projectName`) before the exporter walks that route index; do not reduce it back to `tailwind:build` only. `npm run static:serve` runs `settings/serve-static.py`, which auto-selects a free port from a preferred default (8000) and binds loopback `127.0.0.1` — read the port it prints, not `settings/bs-config.json` (that is the dev BrowserSync source of truth, not the static preview).
- Testing, type checking, linting, or the quality gate: read `tests/README.md` and `settings/check.py`. This is a workspace-adopted convention, not a shipped Caspian feature, so it is documented in the workspace files (this section plus `.github/copilot-instructions.md`), not in the packaged docs. Verify against `pyproject.toml` (`[dependency-groups]`, `[tool.pyright]`, `[tool.ruff]`, `[tool.pytest.ini_options]`) and the `package.json` `check` script. The single command is `npm run check`.
- Formatting code or markup: read `tests/README.md` "Formatting" and `settings/format.py`. App-owned tooling, not a shipped Caspian feature. The single command is `npm run format` (`npm run format:check` to report only); `npm run check:fix` runs it first, before the ruff fixes and the gate. It formats markup with **djLint** and Python with **`ruff format`**, in that order — reformatting a template changes how many lines its literal spans, which changes how ruff wraps the enclosing `html(...)` call, so ruff must run last for a single pass to converge. The house style is `html(r"""` on one line with the markup starting on the next; `ruff format` explodes that shape whenever the call has arguments besides the template, so `format.py` rejoins the opening afterwards and iterates the pair to a fixed point. A bare `ruff format` or an IDE format-on-save will re-split them — rerun `npm run format` rather than editing call sites by hand. Prettier is not usable on this markup: it has no Jinja awareness and de-indents `{% for %}` blocks to column 0. Verify against `settings/format.py`, `settings/_markup_equivalence.py`, and `tests/test_format.py`.

## Docs Maintenance Rules

- Treat `node_modules/caspian-utils/dist/docs/**` as packaged Caspian feature docs and AI routing docs, not as a snapshot of the current project.
- Treat `.github/instructions/**/*.instructions.md` as the workspace-local instruction layer for third-party libraries and narrowly scoped implementation guidance.
- Keep workspace instruction files specific to the surface they govern. Use filenames, `description`, and `applyTo` patterns that help the agent discover the right file before coding.
- Do not duplicate broad Caspian or repo-wide rules across many instruction files; keep shared guidance in `.github/copilot-instructions.md` and this file.
- Do not record this project's current feature flags, script inventory, or temporary file tree status inside the packaged docs.
- Gate optional docs with `caspian.config.json`. Use phrasing such as `when caspian.config.json enables MCP` instead of `this workspace has mcp: false`.
- Use the packaged docs to make AI aware of what Caspian can do, when a doc applies, and which project files should be inspected next.
- Use `core-runtime-map.md` to map packaged docs back to `main.py` and installed `casp` modules instead of restating the full runtime file list in every page.
- Use `pulsepoint-runtime-map.md` to map PulsePoint feature names and directives back to the shipped browser runtime instead of restating browser behavior in every page.
- When `caspian.config.json` has `tailwindcss: true`, document Tailwind class handling as the current contract: Python `merge_classes(...)` emits frontend `{twMerge(...)}` expressions and browser `twMerge(...)` resolves conflicts.
- Keep repo-specific clarifications in this file or `.github/copilot-instructions.md` rather than embedding them in the packaged docs unless the behavior is truly framework-wide.
- Keep `index.md` and cross-links aligned so AI can discover the right task doc quickly.
- Continue validating `file-conventions.md`, `routing.md`, `components.md`, `auth.md`, `fetch-data.md`, `websockets.md`, `cache.md`, `pulsepoint.md`, `validation.md`, `database.md`, and `mcp.md` against the installed `casp` runtime before changing behavior claims.
- Validate `static-export.md` against `package.json` (`static`, `static:serve`), `settings/build-static.py`, `settings/serve-static.py`, and `settings/project-name.ts` before changing its behavior claims. This is app-owned tooling, not installed `casp` runtime, so verify the scripts rather than a package module.

## Maintenance Checklist

Before merging doc or runtime changes:

1. Compare the claim or behavior against `main.py`, `src/lib/**`, and `.venv/Lib/site-packages/casp/**`.
2. Update the matching packaged doc in `node_modules/caspian-utils/dist/docs/` if the running behavior changed.
3. Update the "Caspian Core Contracts" section in this file if a contract it states changed (brace dialects, authoring model, props passing, PulsePoint surface, data flows, server utilities).
4. Update `.github/copilot-instructions.md` if the repo-wide implementation rules changed.
5. Update this file if the decision order, task routing, workspace clarifications, or packaged-doc maintenance rules changed.
<!-- caspian:end -->
