> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crewship.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Pages

# Pages — panel implementation reference

Status: public technical extract of the 2026-08-12 design. Numbered sections
are preserved for implementation references; a retained proposal is not proof
that every feature shipped. The current custom-application contract is
[Pages Apps](pages-apps.md), with [architecture context](../prd/pages-apps-architecture.md).
Internal research, work allocation and session evidence are maintained in
[private context](../development/private-context.md).

## 0. Verdict

Pages combine typed producer payloads, freshness metadata and per-panel
authorization. Producers own data access; the page itself holds no data-source
credentials. The sections below define the technical boundaries and tests.

## Caller-specific folder controls

Folder responses expose `can_rename`, `can_share`, `can_delete` and
`can_accept_pages`, derived from the same live crew membership, workspace role
and folder ACL predicates as mutations. Page index rows expose `can_move` and
`can_remove_from_folder`, including page ownership and live folder write shares.
The Pages rail offers each operation only when its corresponding server flag is
true. Missing flags offer no mutation. Folder write shares permit arrangement,
not sharing or deletion; VIEWER page owners retain their own move controls. Move
destinations require `can_accept_pages`. Existing server checks, version fences
and empty-folder requirements remain authoritative.

## 1. What a Page is

A **Page** is a workspace-scoped, slug-addressable record holding an ordered list of **panels**.
Native saved-result links can address `@<page-id>` instead of a slug. This explicit
reference is resolved by immutable ID inside the same workspace and ACL checks;
it never falls back to a reused slug when the original Page no longer exists.
A panel declares:

* a **schema** from a closed set of five (§3),
* an **owner** — a crew (this is the permission anchor, §7),
* a **producer** — the routine or script permitted to write its data,
* a **freshness contract** — how often data is expected, and what happens when it stops (§4).

A page holds **no query, no datasource, no connection string, and no credentials.** It cannot
reach a database. It renders the last payload a producer pushed, plus the metadata Crewship
attached to that push.

This is the load-bearing property. Everything a client might want on a page — a Postgres row
count, a Redis queue depth, a Google Drive month-end close, a calendar, an uptime check — is
reachable because the producer already runs next to that data inside a crew container with
credentials the page never sees. Adding a data source to Pages is a scripting job, not a
connector engineering job.

**Name.** One noun everywhere: `kind: Page`, route `/pages`, CLI `crewship page`, table `pages`.
With the Artifacts library in the web build, the browser address of a Page is
`/artifacts/<page-id>` instead (§9b.7); the API routes, CLI and table keep the name.
The design artefact used three ("Sails" in the rail, `kind: Surface`, `crewship surface set`);&#x20;
that must not survive into the implementation.

***

## 2. Design constraints

### 2.1 Producer payloads and history

A pushed panel payload is a current view, not a replacement for an event or
metrics store. History and correlation belong to the systems defined in §5.

### 2.2 Portable format

Keep a declarative, versioned definition and validate it at the boundary.
The format and its runtime payload are separate layers (§6).

### 2.3 Panel-level authorization

Filter each panel by its owning crew and the current viewer's authority.
Preserve sealed placeholders rather than reflowing the grid: layout must not
silently change the identity or position of authorized panels. See §7.

### 2.4 Untrusted agent-authored content

Use closed, validated panel schemas. Treat model output as untrusted input;
never execute arbitrary markup, components or operations from a payload.
Actions require server-side authority, explicit declarations and output
filtering. The detailed security requirements are in §8 and §8b.

### 2.5 Rendering

Use the existing shared UI components. Keep data access and credentials in
the producer/server boundary, not in browser panel renderers.

## 3. The vocabulary — five panel schemas, closed

The recurring panel taxonomy across Grafana, Perses, Rill, Cube, Evidence, Observable and the
four home-lab tools reduces to: stat, time-series, table, text, status/link, inside a layout
container. Pages takes exactly five, and the set is **closed** — a new panel kind is a server
release, never a user-supplied string.

| Schema | Payload core | Renders |
| - | - | - |
| `metric.v1` | `{value, unit?, delta?, target?, sparkline?[]}` | one number, delta, optional target and sparkline |
| `series.v1` | `{unit, labels[], series[{name, values[]}]}` | bar (v1), line/area (v1.1); **one unit per panel** |
| `status.v1` | `{items[{name, state: ok\|warning\|critical, label}]}` | status grid; state carries glyph + text, never colour alone |
| `table.v1` | `{columns[{key,label,align?}], rows[]}` | table; collapses to a card list in a narrow container |
| `narrative.v1` | `{blocks[{kind: paragraph\|list, text}], verdict?, actions?[]}` | prose + declared action buttons (§8) |

Rules carried from the palette analysis in the design artefact, and confirmed against
`app/globals.css`:

* **Max 5 series**, sixth merges into "other". Colour belongs to the entity, not to the ordinal —
  a filter that removes a series must not recolour the rest.
* **One unit per panel.** Two units is two panels. Dual axes are the most common chart error and
  cannot be defended at render time.
* **Status colours are reserved.** Green "running" must never also mean "series 3".
* Legend always; direct labels at ≤ 4 series.

### 3.1 The escape hatch — one sandboxed `embed` panel, and no plugin system

A closed set of five cannot "build anything", and the ambition behind this feature is that it
should. That tension has to be resolved in the design, not left to be discovered.

**Do not build a plugin system.** Grafana's panel-plugin catalog holds **130 plugins** after 15+
years of an open ecosystem actively cultivated by a company monetising it, behind a mandatory
signing pipeline. Perses' technically cleaner runtime-pluggable system (CUE schema + module
federation, no backend rebuild) has produced **\~20** panel types. For a small team that is a
multi-month toolchain investment against a catalogue that may never reach double digits.

**Do add exactly one `embed` panel**, rendered in a cross-origin sandboxed iframe. Evidence:

* **Embed-by-URL is the single most universally shipped escape hatch.** Datadog has a dedicated
  iframe widget; Glance ships `iframe` and `html` widgets; Dashy ships iframe/embed/API-response
  widgets; Notion embeds via a vetted 1 900-domain allow-list. Grafana killed its Angular HTML
  panel in the v10 deprecation and the community immediately built unofficial iframe panels to
  route around it — **demand did not go away, it went unsupervised.** That is the failure mode of
  shipping no hatch at all: users build one outside the security model, and we lose the choice of
  how it is built.
* **The technique is now standardised, not research.** MCP Apps (spec `2026-01-26`) and the OpenAI
  Apps SDK converged on the same shape: host ↔ cross-origin sandbox-proxy iframe ↔ inner view
  iframe, with a per-resource CSP (`connectDomains`, `resourceDomains`, `frameDomains` defaulting
  to `frame-src 'none'`), privileged APIs blocked, and all view→host traffic as auditable JSON-RPC
  rather than free-form `postMessage`. Theming is a small CSS-variable contract; sizing is
  negotiated, not free.
* **It absorbs the whole demand tail in one panel type** — iframe, maps, calendar, video, images,
  custom HTML — which the ranked demand evidence shows is otherwise a queue of one-off requests.

Costs, stated plainly: a second origin to host, a proxy to maintain, and the panel's contents are
outside our design system — an `embed` panel is as beautiful as whatever was put in it. It is
also the only reversible choice. Adding it later is additive (one panel type); building a plugin
system first and finding nobody uses it is not.

**Recommended placement: v1.2, not v1** — but the schema must reserve the type name from the first
migration so the closed enum does not need a breaking change to admit it.

**Prerequisite bug, independent of Pages.** `--chart-1..5` are defined **identically** in `:root`
and `.dark` (`app/globals.css:45-49` and `:173-177`). One of the two themes is therefore using a
palette tuned for the other background. The design artefact's separate finding — blue↔purple are
indistinguishable under protanopia, and green↔cyan fall below the ΔE threshold even for normal
vision — is consistent with that. Fix in its own PR before Pages ships a chart.

***

## 4. The freshness contract

A dashboard that silently shows old numbers is worse than no dashboard. This is where push
models fail quietly (Prometheus Pushgateway keeps a pushed metric forever unless the job deletes
it), and where the push-native monitors get it right (Uptime Kuma, Healthchecks.io, Gatus and
Better Stack all flip state when an expected ping does not arrive).

Pages takes the monitor behaviour, not the Pushgateway behaviour:

1. **Every panel declares `sla`.** A panel without one does not validate. There is no default
   that means "never mind".
2. **Three states, computed server-side, never by the producer**: `fresh` (within SLA), `stale`
   (past SLA), `failed` (producer's last run failed, or an explicit failure push).
3. **Stale renders degraded** — value dimmed, age shown in absolute terms, not "a while ago".
   It never renders as if it were current.

**Three rules the implementation had to pin because §4 left them open** (2026-08-12):

* **The boundary is `age >= sla` → stale.** Tested at exactly the boundary and one nanosecond
  before it.
* **`fresh` and `stale` are never stored.** They are functions of the clock, so
  `page_panel_data.state` holds only the producer's own verdict — `CHECK (state IN ('ok','failed'))`
  — and there is deliberately no column a producer could write a timestamp into. Freshness is
  computed from the server's `produced_at` on every read.
* **The newest payload always survives the age cut.** Without this, a producer dead for eight days
  has nothing inside the seven-day window, the sweep deletes its last payload, and the panel flips
  from *"stale, last value 12:40"* to *"never produced"* — the system forgets a failure instead of
  reporting it, which is the exact dishonesty this section exists to prevent.

4. **`on_failure: {issue: crew/<slug>}`** opens an issue on the owning crew. A page that quietly
   stops updating must generate work for a human.
5. Every panel footer carries provenance: producer, run id, timestamp. Server-attached, not
   producer-claimed.

***

## 5. Where history, alerting and correlation live

This section defines the history boundary described in §2.1.

**They do not live in the panel.** They live where Crewship already keeps them:

| Concern | Grafana's answer | Pages' answer |
| - | - | - |
| History | query engine over a TSDB | `internal/journal` — every push emits an entry; the journal is the queryable record |
| Alerting | alert rules over queries | `internal/automation` — journal event + matcher → routine |
| Correlation | multi-panel query joins | `internal/chain` — the causal walk added by #1836 |
| Ad-hoc time range | re-query with new range | **not supported, by design** |

The last row is a real limitation and must be stated in the docs, not discovered by a user.
Pages answers *"what is happening now, and what did the system do about it"*. It does not answer
*"show me last March"*. When somebody needs last March, the honest answer is a routine that
queries and pushes a `series.v1` panel — and that routine is manifest-managed, journaled and
reviewable, which is more than a Grafana panel's query string ever is.

A bounded ring of previous payloads is retained per panel — **200 payloads with a hard age cut at
7 days, whichever comes first** (§10b.3; an earlier draft of this paragraph said 24 and was never
corrected when §10b.3 settled on 200). Enough for a sparkline and for "what did this look like
before it broke", not enough to be a time-series database. Retention follows the
`run_retention_days` convention
(`internal/database/migrate_consts_v158_run_retention_days.go:13`): nullable INTEGER on
`workspaces`, NULL = instance default.

**The wake gate is the payoff.** A panel declares:

```yaml theme={null}
wake:
  - when: any(state == "critical") for: 5m
    agent: crew/devops
    writes: incident
```

Under the covers this compiles to an `automations` row — the substrate delivered by #1836 already
does journal event → in-memory matcher → coalesced enqueue, with `debounce_seconds` (default 10),
`max_per_hour` (default 60) and depth pricing against loops
(`internal/database/migrations/20260807160000_automations.sql:31-35`). Pages does not build a
new eventing path; it emits `page.panel.*` journal entries and writes automation rules.

Because unknown journal types are forwarded by design
(`internal/journal/feed_filter.go:33-35`), `page.*` entries reach the activity feed with no
change to the filter.

***

## 6. Format — two layers, both already in the repo

**Layer 1 — the page definition, authored by a human, YAML.**

⚠ The example below is written **at the v1.1 feature level** — it shows `wake:` and `actions:`,
which land in v1.1 (§12). The v1 parser sets `KnownFields(true)` and therefore rejects them today,
by design: a spec that names a field the server does not implement must fail loudly rather than
be silently ignored. Read the example as where the format is going; read §12 for what parses now.

```yaml theme={null}
apiVersion: crewship/v1
kind: Page
metadata:
  name: Flotila .201
  slug: fleet-201
spec:
  panels:
    - id: sluzby
      schema: status.v1
      title: Jede to?
      owner: crew/lookout            # permission anchor, not a label
      producer: script/watch-services.sh
      sla: 30s
      span: 8
      wake:
        - when: any(state == "critical")
          agent: crew/devops
          writes: incident
    - id: incident
      schema: narrative.v1
      owner: crew/devops
      producer: routine/incident-rozbor
      refresh: on:wake
      sla: 1h
      span: 12
      actions:                        # declared here, never authored by the agent
        - run_routine: restart-api
        - create_issue: {project: infra}
```

**Layer 2 — the panel payload, produced by a machine, JSON**, validated against a published
schema in the existing `schemas/` directory alongside `schemas/routine.v1.json` (which already
carries `dsl_version` and is embedded via `schemas/embed.go`).

Human writes YAML, machine writes JSON, both validated. No third DSL. This matches Perses'
architecture and reuses two systems that already exist here.

***

## 7. Permission model

Private chat artifacts are an explicit exception to the ordinary Page role and
grant paths described below. Their custody (write, administration) belongs only
to the human custodian and the workspace OWNER. Their read audience is the
custodian plus workspace OWNERs and ADMINs (owner decision 2026-10-30): an
ADMIN's read is read-only and never becomes write, share, publish or custody
power. Unauthorized reads return 404 and catalogs omit the name and entry. An
OWNER or ADMIN opening another human's private output writes a content-free
audit before metadata or content is released; unavailable audit storage refuses
that access. Neither role's reach passes to an agent (§7.1c). Ordinary grants,
folders and legacy public links cannot silently publish a private artifact. Private grant administration and production are also limited to the custodian and workspace OWNER; an ADMIN role or a legacy producer grant does not widen those controls.
The only explicit publication path is the experimental workbook share
(`docs/cli/workbook.mdx`). The owner confirms it, it is audited and one-way, and
it names editors as owner-issued user `write` grants. Workbook contents remain
limited to the owner, those editors, and the audited workspace OWNER. Generic
write grants are refused on a workbook Page, and a write grant not issued by
its current owner is inert; one that custody moved away from its issuer stays
ended if custody returns. Deleting, renaming, rolling back, moving into or out
of a folder and reading spec versions of a workbook Page remain owner-only,
because workbooks have no backup; deleting also needs a current human
credential. A workbook is a human-only private output for departure and
erasure (§7.1 rule 1b): it is never transferred to a crew, and a workspace
OWNER selects a human successor through the custody flow. Workbook custody opens
only once the owner is no longer a live member (removed, suspended, restricted,
or with a removal or erasure refused because of the workbook within 7 days,
recorded as `artifact.workbook_custody_requested`); ADMINs are never workbook
custodians, and each transfer is audited as
`artifact.workbook_custody_transferred` in the same transaction. A workbook
whose owner is no longer a member without such a transfer may be deleted only
by a workspace OWNER, audited as `artifact.orphaned_workbook_deleted`.
Shared Page authorization and workspace-wide storage accounting are unchanged.
Private read checks require a live account, workspace and membership. Guest
catalogs omit unauthorized private outputs without failing the whole listing.
The Page metadata reader checks a live custodian independently of trusted
mutation authority; the account-wide Guest ceiling disables the OWNER override
for others' outputs. Public routes retain the Guest allowlist, and the Document
engine retains its trusted-account gate: this metadata check does not enable
Document content access for a globally restricted custodian. Private mutation
checks retain their separate trusted-account requirement.

Metadata detail, export, versions, grants and Page/subject access reports retain
one read snapshot for classification, custody, role and the emitted metadata.
Ordinary reads use a read-only transaction without acquiring the SQLite writer
lock. Private reads restart with fresh authorization inside a writer transaction;
subject access re-prepares its final page once and audits only private entries
actually emitted. Required audit inserts and the complete final batch are checked
natively and committed before metadata is released. A later audit insert removing
an earlier row, an ignored insert or commit failure withholds the response.
Document engine reads retain their separately captured actual-read custodian;
this metadata boundary does not replace that identity with a later owner lookup.
Project/publication source and panel-history reads are not newly qualified by
these metadata consumers.

The requirement: creator owns the page; a page created by an agent is owned by the human who
authorised the agent; crew members inherit visibility of their crew's panels; the owner can grant
read and write to others.

### 7.1 The rules

1. **A page has exactly one owner, and it is either a user or a crew.** `owner_user_id` xor
   `owner_crew_id`. An agent-created page records the authorising human as owner and the agent as
   `created_by_agent_id`; an agent never owns permissions, it acts under one. A crew-owned page is
   the natural home for a crew's own status board and needs no personal owner at all.
   1b. **When a user owner leaves the workspace, a regular panel page transfers to a crew, it is not deleted.**
   Target crew, in order: the crew that owns the most panels on that page, else the crew the
   departing user belonged to. The transfer emits a journal entry and notifies workspace
   ADMIN/OWNER through the existing notification path, so a role that does not leave can reassign
   the page. A page must never be orphaned and must never silently vanish.

   Private Documents, Artifacts, Boards and Slides retain human custody and
   require an explicit reassignment through the workspace page-ownership API or
   `crewship workspace page-ownership` commands before the owner can leave.

   ⚠ **An earlier draft ended that list with "else none", which is the orphan the same rule
   forbids.** Corrected 2026-08-12 during implementation. The database enforces it rather than
   trusting the handler: `pages.owner_user_id` carries `ON DELETE RESTRICT`, so **transferring the
   page is a precondition of deleting the user**, not a cleanup step that might not run.

   **This lands on a path outside Pages:** the user-erasure handler in `internal/api` must
   transfer or delete a departing user's pages before deleting the row, or the delete now fails.
   That is deliberate — a silent cascade is how a page would vanish — but it is work the erasure
   path owns, and a GDPR deletion that errors out is worse than one that transfers first. Track it
   as its own item.
2. **A panel's visibility is its owning crew's visibility.** `panel.owner_crew_id` is not
   decoration; it is the ACL. If the viewer is not a member of that crew (and holds no explicit
   grant), **the panel's payload is omitted server-side, before serialisation, and what is
   serialised in its place is a sealed placeholder** — the panel id, its grid slot and its owning
   crew's display name, and nothing else. It is never rendered as an error.

   Preserve the stable layout required by §2.3: a page
   that silently changes shape per viewer cannot be talked about, and "look at the panel top right"
   stops meaning anything. Reflow is wrong; the placeholder holds the slot. Leaking the existence
   of a panel and its owning crew is a far smaller disclosure than the confusion of a silently
   different page — and where even existence is sensitive, the author moves the panel to its own
   page.
3. **Explicit grants layer on top**, in one table, not in panel properties:
   `page_grants(page_id, subject_type ∈ {user, crew}, subject_id, level ∈ {read, write})`.
   `write` means editing the page definition. Grants are issued by the page owner or by a
   workspace ADMIN/OWNER — that is the "who may look at this page" control.
   **A grant widens access to the page, never to a crew's data.** A grantee still sees only the
   panels their own crew membership and workspace role already permit; a page owner cannot use a
   grant to leak their crew's panel to somebody outside it. (An ADMIN sees the crew panels anyway
   — `effectiveRole` takes the max of workspace and crew role,
   `internal/api/helpers.go:481-486` — so this rule costs an admin nothing and closes the
   privilege-escalation path for everyone else.)

   ⚠ **This rule states the panel ACL precisely and left the PAGE rule implicit, and the first
   build filled the gap with "every workspace member reaches every page" — under which a `read`
   grant widens nothing and the three verbs of §7.1b collapse into two.** Settled 2026-08-13
   during implementation, since neither this rule's own "who may look at this page" nor §7's
   requirement sentence ("the owner can grant read and write to **others**") is satisfiable
   otherwise. **A page is reached by its owner, by a workspace ADMIN/OWNER, by a member of any
   crew that owns one of its panels, and by whoever a live grant names — every level, since a
   `write` grantee who cannot open the page they may edit is two paths disagreeing.** Nobody else,
   and "nobody else" answers 404 rather than 403, so the endpoint is not an existence oracle. The
   panel rule is untouched by this: a grantee with no crew membership on the page reaches it and
   is served a grid of sealed placeholders, which is §7.1b rule 2's own description one level up.
4. **Producer authority is separate from viewer authority.** Only the declared producer may write
   a panel's payload. A crew member who can *see* a panel cannot *write* it.
5. **Everything is decided server-side.** The client receives only panels it may see. There is no
   client-side hiding, because a hidden-but-delivered panel is a data leak.

### 7.1b Agents as grant subjects — three verbs, not one

A page is worked on by more than one agent, and which agents those are is configuration, not
convention. That requires splitting what the first draft treated as a single "producer" field
into three independent verbs:

| Verb | Means | Typical holder |
| - | - | - |
| `read` | may see the page and its panels | humans, crews, an agent that summarises it |
| `comment` | may read the shared artifact and submit feedback, without edit or execution rights | humans and human crew/workspace members |
| `produce` | may push payloads **into named panels** | the routine or script that owns those panels |
| `write` | may edit the page spec — add, remove and re-arrange panels | the agent in charge of the page; a human owner |

`page_grants.subject_type` therefore becomes `{user, crew, agent}` and `level` becomes
`{read, comment, produce, write}`. Human feedback follows Read \< Comment \< Edit;
`comment` is accepted only for user, crew and workspace subjects. It permits
feedback on the currently shared artifact version, without changing content,
versions, restoration, sharing, editor leases or agent write authority. A plain
reader cannot submit feedback. Revocation and issuer standing are rechecked in
the final feedback transaction. `produce` remains an independent capability.
`produce` additionally carries the panel ids it covers, so an agent
granted `produce` on one panel cannot overwrite another agent's panel on the same page.

Human artifact Project editors with a live `write` grant can read the latest
manifest and save a complete new version using its exact `base_version`. The
server records the editor's own identity; a competing save returns 409 without
changing existing data. Editor reads expose only that latest manifest, not the
owner's history, quota or source chat. Read and Comment remain pinned to the
shared version. Restore, selecting a shared pin and sending an owner agent task
are separately authorized owner operations, advertised by `can_restore`,
`can_share` and `can_send_to_agent`; `can_write` alone never grants them.

Artifact comments retain their actual human author and immutable shared-version
anchor. Creating feedback does not dispatch an owner run or borrow the owner's
credentials. Processing another member's feedback requires a fresh explicit
owner message in the owner's private chat and the existing live run admission.
Native Craft comment-only observer access stays denied. Read and Comment
grantees reach only the shared native version; latest bytes, history and proposals
need Edit or the workspace OWNER/ADMIN read rule (`docs/specs/craft-native.md`).

Catalog state is independent of content and access. `created_by_test` is an
explicit server-stored, creation-only boolean with no name-based backfill;
`test_items=exclude|only|include` defaults to `exclude`. `archived` and optional
`archived_at` describe discovery state; `archived=exclude|only|include` also
defaults to `exclude`. Filters run after authorization and before pagination and
folder counts. Direct authorized reads and version history remain available.
QA creates marked items only in a dedicated QA workspace.

`GET /api/v1/pages` also accepts `q` (a plain case-insensitive substring of
name or slug), `kind=project|dashboard|page|word|sheet|slides|design`,
`access=private|open`, `version=shared|unshared|unversioned`, and canonical
`owner=user/ID|crew/slug|me`. Each facet accepts comma-separated and repeated
values (OR within a facet, AND between facets). `me` means the current user's
`user/ID`, never a display label or implicit crew ownership. `folder` selects
a folder slug or `~unfiled`. `empty=exclude|only|include` (omitted: include)
selects Pages with no typed kind, no Word/Sheet/Slides storage and no panels,
the library's empty items. Unknown enum values and malformed owner/folder
values return 400. Filters narrow only rows admitted by the canonical Page
index; kind and version use the same selected metadata as the list response.
Legacy Word/Sheet/Slides storage without an artifact version head remains
`unversioned`; panel Pages remain `page`. For nonowners, type classification
uses shared-version metadata and never inspects an unshared latest manifest.

`sort=updated|name|kind` defaults to updated descending. Name sorts by
case-insensitive text ascending; kind follows Project, Dashboard, Page, Word,
Sheet, Slides, Design. Ties keep the index order, `updated_at` descending then
slug ascending, so every window is deterministic. The order comes from the
index query itself, so stored `datetime('now')` and RFC 3339 timestamps keep the
same order as the unfiltered array. The response remains an
array with `X-Total-Count` after all authorization and filters, before paging.
With no `limit` or `offset`, the result remains unpaged; existing pagination
defaults and clamps are unchanged. Filtering and sorting add no per-row queries.

Archive/unarchive requests first use `dry_run=true` with `pages_version`.
Confirmation requires `confirm=true`, the matching `review_token` and the same
version and live authority. Preview does not mutate; archive retains content,
ACLs and history. Folder filing supports the same review fields plus the target
`acl_version`, with before/after effective access and changes. Changed versions,
authority or access invalidate the token. Hidden ACL identities remain withheld.
The existing legacy fenced folder request remains supported; unsupported bulk
preview fields are rejected rather than silently ignored. Access responses keep
`page`, `subjects` and pagination and add `page_id`, `pages_version`, per-subject
`effective_permissions` and permission `sources`. No guest or expiry mechanism
is offered.

**The invariant that makes this safe: an agent's authority is a subset of the authorising human's,
never a superset.** A grant to an agent is evaluated against the granting human's own rights at
use time, not at grant time — if that human loses access to a crew, every agent grant they issued
narrows with them, and a grant whose issuer lost standing does not return with it.
Without this rule, "grant my agent access" is a privilege-escalation primitive.

**Agent identity comes from the token, never from the request body.** The sidecar already
overwrites caller-supplied identity fields with the token-derived one
(`internal/sidecar/identity.go:26-39`, `internal/sidecar/pipelines.go:21-25`); a page write takes
the same path, so agent A cannot claim to be agent B.

**Every grant change is journalled** (`page.grant_added` / `page.grant_removed`, actor and
subject recorded) — an ACL nobody can audit is not a security control.

`PUT` and `DELETE` grant mutations commit together with the configured keyed
journal. They recheck the current human account, trusted workspace membership
and page owner/ADMIN/OWNER authority under the writer fence. A journal failure,
ignored insert, altered final grant or failed commit rolls back the mutation;
notifications follow the successful commit. Existing Page events retain their
actor, subject, level and panel fields and add `previous_grant` / `grant` stored
row snapshots (`null` at creation/removal respectively; a snapshot of a retired
grant adds `retired_at`). These snapshots remain
canonical audit data: the common GET/SSE/WS serializer omits only these two new
keys, preserves the previous public event fields and never rewrites the stored
payload. Reissuing still records
one fact and reanchors its issuer. Revoking every level records each removed row,
including grants whose subject has departed; a missing grant remains 404 without
a change event. When a reference matches both a resolved subject and a departed
stored ID, revoke checks and removes every matching stored row, preserves other
subjects and unselected levels, and records each removal in the same transaction. References matching multiple live subjects return 400 without mutation; PUT also verifies that every other subject grant remains unchanged before commit.
Loss of issuer authority at the final commit check returns 403 and rolls back
the grant and audit; operational database errors remain 500. Folder ACL mutations and broader permission-audit coverage are
separate work.

Three rules decided 2026-08-12:

1. **Only a human issues a grant.** An agent with `write` may rebuild the page freely but can
   never widen who reaches it — not even to an agent in its own crew. This closes the escalation
   path where an injected agent grows its own blast radius one grant at a time. When an agent
   needs another agent's help, it asks a human, and that request is a normal inbox item.
2. **Layout and data are separate authorities.** An agent with `write` may place a panel owned by
   a crew it cannot see. It does not receive that panel's data — the server filters it exactly as
   for any other viewer and the agent sees the sealed placeholder. This is what lets an agent
   assemble a cross-crew page for a team whose numbers it is not entitled to read, which is the
   orchestration role the feature exists for. `write` is authority over arrangement, never over
   content.
3. **An unauthorised push is a signal, not noise.** A `produce` attempt on a panel the caller does
   not hold returns **403** if the caller can reach the page, or the same **404** as a missing page otherwise. It writes a journal entry and notifies the page owner. It is equally
   likely to be a misconfiguration or an injection, and both deserve a human's attention on the
   first occurrence rather than the hundredth.

CLI, complete and symmetric with the API:

```
crewship page grant  <slug> --agent <agent-slug> --level produce --panels sluzby,zatizeni
crewship page grant  <slug> --crew  <crew-slug>  --level read
crewship page grant  <slug> --user  <email>      --level write
crewship page revoke <slug> --agent <agent-slug>
crewship page grants <slug>
```

### 7.1c Agent access modes — what a chat run may reach (#5317)

Behind the workspace flag `artifact_agent_access_modes` (off by default). Off
changes nothing: the chat bridge ignores the `artifact_refs` key as it did
before the flag existed, and turning the flag off after references were
recorded starts their runs the ordinary way, binding nothing. Decided by the
owner on 2026-11-13.

1. **A reference is a structured chip, never text.** A chat message carries
   `metadata.artifact_refs: [{"page_id": "<id>"}]`, 1–10 entries, filled from
   the composer's attachment chips. Names, links or ids written in the message
   body are never references.

2. **The server resolves references when the message is sent, as the human
   would be as an ordinary member.** Before the message is stored it checks
   each reference against the sending human with their workspace role read as
   MEMBER (never above their own role): reach that comes only from the ADMIN or
   OWNER role does not pass to an agent, for reads or for writes. A reference
   that is missing, unreadable, in another workspace or malformed is refused
   with one identical error, so a refusal never reveals that something exists.
   References are admitted only in the human's own private chat with the agent,
   and are refused, never silently dropped, on a turn that would not bind
   them: with `page_context`, a Document create or update, a Native Craft
   instruction or an origin run.

3. **Only the trusted human admission writes the run scope.** After the message
   is stored, the references are recorded (`artifact_message_refs`), and the
   run's admission copies them into `artifact_run_scope` in the transaction
   that captures the run principal, checking each one against the human again;
   access lost since sending refuses the run. Database triggers hold the same
   shape: a reference row names the human's own message in their chat and a
   page of the workspace, and a scope row mirrors its own principal message's
   references. An internal token or an agent can neither record a reference
   nor widen a scope. If access is lost between the pre-check and the record,
   the stored message keeps no references and starts no run.

4. **A follow-up message carries its own references.** An attached artifact is
   not in reach of the next run unless that message attaches it again (the
   composer keeps chips sticky and the server re-checks them every time).

5. **With the flag on, a chat run reaches an artifact in one of three ways,
   checked on every use** (no exact-run consent): it was attached to the
   message that started the run; the run's agent created it in this chat and
   the human still owns it (it stays in reach for later messages of the same
   chat); or the agent holds its own live grant on it (standing access). In
   every case the human must still read it as an ordinary member, so a human
   who loses access mid-run stops the run's access too, and role-only reach is
   never delegated. Anything else is refused exactly like a missing artifact.

6. **Writes are narrower than reads.** A run writes only an artifact the human
   owns and that it reaches as attached or created; standing access is read
   only. An agent never shares, grants, moves or publishes. An exact-run
   consent the human gave today ("edit with agent", a comment task) keeps its
   one page in reach with the flag on. Word and Sheet
   documents and Design keep their exact-run consent path for now, so their
   agent edits remain proposals under the document lease.

7. **Limits, read from the workspace policy on every use.** One chat run
   creates at most `chat_run_create_limit` artifacts (default 3, 1–20) and
   reads at most `run_read_limit` distinct artifacts through standing access
   (default 50, 1–500); past either the run gets 429 `artifact_run_limit`.
   One crew creates at most `crew_hourly_page_limit` pages per hour through
   the sidecar save path (default 20, 1–1000; 429 `crew_hourly_page_limit`);
   pages a person created for the crew do not count; agent, routine and
   onboarding saves do.
   `crewship workspace artifact-policy set --chat-run-create-limit --run-read-limit --crew-hourly-page-limit` changes them; a change bumps the
   policy version. A workspace that never saved a policy allows every live
   crew for this chat-run scope only while the flag is on (the policy read
   reports `scoped_default: true`); every other agent surface keeps requiring
   a saved, enabled policy, and a saved policy, on or off, stays the ceiling.

8. **The sidecar page save carries the agent run token (#5336).** The
   `save_page` tool sends the same derived run token the artifact tools send,
   as the `Authorization` bearer on `POST /api/v1/internal/pages/save`. With
   the flag on and a token that verifies for the crew and the acting agent,
   the save is bound to its run: it counts against `chat_run_create_limit`
   (429 `artifact_run_limit` past it) and the page is recorded as created by
   that run. A token that does not verify (another crew, another agent, not a
   run token) is refused with 403 `invalid_run_token`, never treated as
   absent. A run without a human principal (routine, or admitted with the flag
   off) has no run to bind to and is counted under the crew hourly limit
   only, and so is a verified token whose principal is no longer live (the
   artifact tools' check: expiry, suspended or restricted human, membership or
   chat revision, run ended); neither records a created page nor counts as a
   save without a token, and a verified token always satisfies the
   requirement below. A save without a token (a sidecar that
   predates this release, still running in an existing container, or an agent
   holding only a per-agent v1 token, which the sidecar does not forward) is
   accepted under the crew hourly limit only, logged and counted
   (`crewshipd_page_save_without_run_token_total`, after a successful save),
   unless the workspace policy sets `save_requires_run_token` (`crewship
   workspace artifact-policy set --save-requires-run-token=true`); then it is
   refused with 403 `run_token_required`. The host's own routine
   `page.create` (dispatcher, master internal token, never held by a
   container) is exempt from the requirement and the counter. An onboarding
   save for a target crew (`target_crew_slug`) counts against the Guide's own
   run. The requirement is an explicit
   opt-in, not a date: an admin turns it on once the counter stays at zero.
   **The token is accounting, not a boundary between agents of one crew:**
   they share the container, so a token can be omitted or borrowed; it only
   makes the per-run limit apply on this path.

9. **Only a run started by the human's own message carries a human.** A plain
   chat run records its human principal at admission; routine, webhook, cron,
   agent-to-agent and crew-token runs record none and reach nothing through
   this path.

The folder subject for a crew's agents and the audit with the human follow in
later steps of #5317.

### 7.2 What this costs

This is greenfield in this repo, and the PRD should not pretend otherwise. Per-resource ACLs are
an explicit recorded non-goal of the current RBAC:
`internal/database/migrate_consts_v100_rbac_extensions.go:53-56` defers "full per-row ACL" to
Phase 3, and `migrate_consts_v109_member_capabilities.go:19-21` names per-resource ACLs as an
excluded non-goal. The only precedent is `saved_views(user_id, shared BOOLEAN)`
(`migrate_consts_v42_v45.go:71-85`, read filter at `internal/api/saved_view_handler.go:52`) —
an owner plus a workspace-wide boolean, not a grant list.

So Pages either (a) ships with the SavedView boolean and no grants, or (b) builds the first grant
table in the codebase. The user requirement asks for (b). Recommended: **build it, scoped to
Pages only** — one table, two subject types, two levels — and do not generalise it into a
platform ACL until a second consumer exists.

New capability `page.create` joins the closed set in `internal/api/capabilities.go:24-83`, with
the frontend mirror in `lib/capabilities.ts` (hand-maintained, `lib/capabilities.ts:1-19`).

***

## 7.3 Public pages — a page for someone with no account

Decided 2026-08-12: a page can be published to someone outside the workspace — the accountant, the
client, an external auditor — optionally behind a password. This is the highest-risk surface in
the feature and it gets its own rules.

### 7.3.1 It is a different product, not a permission level

A public page is served from a **separate URL space** (`/p/{token}`) that shares no session, no
cookie and no workspace context with the app. Nothing about it is "the same page with a looser
grant" — it is a distinct rendering path with its own middleware, and that separation is what
makes it auditable.

### 7.3.2 Six rules

1. **Read-only. No actions, ever.** A public page renders no buttons. A button behind a public
   link is remote code execution with a URL for a credential. `PageAction` is stripped server-side
   before serialisation, not hidden in CSS.
2. **Opt-in per panel, not per page.** Publishing a page publishes only the panels explicitly
   marked `public: true`. Default deny. Publishing must never be a bulk action over panels the
   author has not looked at.
3. **Only a human publishes.** An agent can build the page; it cannot make it public, and it
   cannot add a panel to an already-public page without that panel being separately marked by a
   human. This is §7.1b rule 1 taken to its conclusion — an agent may not widen reach, and
   "public" is the widest reach there is. The human must also hold reach over the page: its user
   owner, a member of its owning crew whose role there (workspace role or crew override) is
   MANAGER or above or who holds the crew's Manage grant, or a workspace ADMIN/OWNER. Plain crew
   membership reads a crew page but never publishes it. A link speaks for the human who created
   it, so it opens only while that human is a member of the workspace and is not suspended; it is
   withheld, not revoked, and opens again when their standing returns. Listing a page's links (which names their creators) takes the same authority
   as publishing; revoking takes it too, except that anyone may revoke a link they created.
4. **Every public link expires.** A required expiry, default 30 days, maximum 1 year. Tokens are
   high-entropy, revocable individually, and one page may have several so revoking the
   accountant's link does not break the client's.
5. **Provenance is stripped by default.** Run ids, agent slugs, crew slugs and producer names are
   internal vocabulary. A public panel shows the value, the unit and the age — nothing that maps
   our org chart for a reader outside it. An author may opt provenance back in per page.
6. **Not indexable, rate limited, and logged.** `X-Robots-Tag: noindex`, `Referrer-Policy:
   no-referrer`, a per-token request cap, and a journal entry for each token's first view per day
   so the owner can see the link is being used and by roughly whom.

### 7.3.2b Staleness on a public page — show the age, never the reason

Decided 2026-08-12. The two halves pull in opposite directions and both are right:

* **Show the age.** An outsider acting on a stale number is the worst version of the failure this
  whole PRD exists to prevent — internally someone would have caught it, externally they will
  invoice on it. A public panel always carries when its data was produced.
* **Hide the reason.** Failure text is internal vocabulary: container names, routine slugs, OOM
  traces, crew names. A public failed panel reads *"Data nejsou aktuální — poslední hodnota
  12:40"* and nothing more. The detail stays on the internal page for the people who can act.

### 7.3.3 Password

Optional per token. Stored hashed with the same primitives the auth layer already uses — never
reversible, never in the URL. A wrong password must be rate limited per token, and the failure
must not distinguish "wrong password" from "unknown token".

### 7.3.4 What stays out

No public actions, no public writes, no public streaming, no embedding of a public page inside a
third-party site in 1.0 (that is a `frame-ancestors` decision that deserves its own thought).

## 8. Agent-authored pages — the security rules

The untrusted-content boundary in §2.4 motivates these requirements.

1. **The agent fills a schema; it never emits markup, HTML, CSS or code.** `narrative.v1` accepts
   typed blocks, not a markdown blob. (Adaptive Cards: "no code behind"; host owns look and feel.)
2. **No images in agent-authored content. None.** Not sanitised — absent from the schema.
   (CamoLeak exfiltrated through a *trusted first-party* image proxy; CSP did not help.)
3. **No free-form links.** A narrative block may reference an internal Crewship entity by id
   (issue, run, page, agent) and the renderer builds the URL. It may not carry a URL.
   (Slack AI's private-channel exfiltration was a rendered link.)
4. **Actions come from the page's declared allow-list only.** The YAML declares which routines and
   issue templates a panel may offer; the agent selects an index and supplies parameters that are
   validated server-side against that action's schema. The agent cannot author an action.
5. **The confirmation dialog is drawn by host chrome, never by panel content**, so an injected
   panel cannot fake or bypass it.
6. **Every action click carries a server-verified token bound to (user, page, panel, action
   index).** A rendered button is not evidence of authorisation. (Slack signs every interactive
   callback; the server recomputes.)
7. **Calibrate friction to blast radius.** Read-only and reversible actions do not prompt.
   Anthropic's own containment write-up reports Claude Code users approve \~93 % of prompts —
   universal confirmation is a rubber stamp, not a control.
8. **Treat the platform's own agent as an untrusted producer.** Its context may already contain
   injected content read from a container or integration; server-side scope validation and audit
   logging apply identically. (Confused-deputy framing; OWASP LLM01/LLM05/LLM06.)
9. **Lethal-trifecta check per panel.** A panel that both displays untrusted data and can trigger
   an action must not additionally be able to emit outbound URLs or webhooks.
10. **Text renders through a React element renderer, never `innerHTML`.** No
    `dangerouslySetInnerHTML` anywhere in the panel registry.

The write path itself is already the right shape in this repo: an agent writes through the
sidecar, which overwrites caller-supplied identity fields with the token-derived one
(`internal/sidecar/identity.go:26-39`, `internal/sidecar/pipelines.go:21-25`). Panel writes must
use the same pattern — the producer identity comes from the token, never from the body.

***

## 8b. Actions — the interactive half

Actions are not a v1.1 nicety. A page that cannot *do* anything is a report; the requirement is a
surface you operate. This section is therefore load-bearing.

### 8b.1 The vocabulary

Block Kit, Adaptive Cards and amis converged independently on the same small set: an id, a label,
a semantic style triad, an opaque payload merged with visible input values, an optional confirm
step, and a distinction between "this calls the server" and "this only affects the client".
Adaptive Cards' `Action.Execute` — a named remote operation identified by a `verb` — is the exact
analogue of "run this routine".

```ts theme={null}
type PageAction = {
  id: string                                  // unique within the page
  kind: "call" | "link" | "toggle" | "custom"
  label: string
  style?: "default" | "primary" | "danger"
  confirm?: { title: string; body: string; confirmLabel: string; cancelLabel: string }
  // kind: "call"
  routine?: string                            // declared in the page spec, never chosen at click time
  params?: Record<string, JSONValue>          // fixed payload
  inputs?: InputSpec[]                        // collected from the user before dispatch
  onSuccess?: { toast?: string }
  // kind: "link" → internal entity ref only (§8 rule 3); "toggle" → local panel state
  // kind: "custom" → a handler registered in our own client registry at build time
}
```

`kind: "custom"` exists from day one on Airbnb's advice — their SDUI system ships an extension
point for action types precisely so it does not have to be retrofitted. It resolves to a handler
**we** registered at build time, not to user-supplied code, so it costs nothing in safety.

### 8b.2 The button posts an index, not a routine

This is the design decision that makes §8 rule 4 verifiable rather than aspirational:

```
POST /api/v1/pages/{slug}/panels/{panelId}/actions/{actionId}
```

The request body carries only the collected `inputs`. **The server resolves `actionId` against the
stored page spec** and dispatches the routine named *there*. A caller — a compromised client, an
injected narrative panel, an agent — cannot name a routine at click time, because the wire format
has no field for one. The allow-list is not a check we remember to perform; it is the only path
that exists.

### 8b.3 Dispatch: 202, not a held connection

`POST /api/v1/workspaces/{ws}/pipelines/{slug}/run` is **synchronous** — it holds the HTTP
connection for the entire run and returns the full `RunResult`
(`internal/api/pipelines_exec.go:193-224`; the CLI uses a long-timeout client for exactly this
reason, `cmd/crewship/cmd_pipeline.go:584`). No existing frontend surface has a long-request
strategy. A Pages button on a ten-minute routine would hang.

The server already has the answer, unused by any client:

* **`Idempotency-Key` header** → `IdempotencyStore.LookupOrReserve`, atomic, 24 h TTL,
  pipeline-namespaced since v153 (`internal/pipeline/idempotency.go:26-77`). Read at
  `internal/api/pipelines_exec.go:131`. **Zero frontend call sites send it today** — Pages is the
  first consumer. Client generates a UUIDv4 per logical click, the Stripe pattern.
* **`debounce_key` / `delay_seconds`** → `enqueueDeferredRun` returns **202**
  `{status:"SCHEDULED", pending_id, fire_at, coalesced}` immediately
  (`internal/api/pipeline_deferred.go:92-98`), with `(pipeline_id, debounce_key)` coalescing in
  `pending_runs` (`internal/pipeline/pending_runs.go:83-85`).
* **`concurrency_key`** → `ErrConcurrencyLimitReached` becomes 429 + `Retry-After: 5`
  (`pipelines_exec.go:211-218`) — the "already running" answer.

So the Pages action endpoint enqueues and returns 202 with a run or pending id. Double-click,
idempotency and long runs are one construction, not three.

⚠ **Open question for implementation:** Stripe's rule is that a replayed idempotency key with
*different* parameters must be rejected, not silently accepted. Verify whether
`LookupOrReserve` compares params; if it does not, a Pages action must hash its inputs into the
key.

### 8b.4 What the UI reuses

Nothing here needs new infrastructure — the routines surface already does all of it:

| Need | Reuse | Location |
| - | - | - |
| "is it running", app-wide, already mounted | `useActiveRoutineRuns()` / `bySlug` | `hooks/use-active-routine-runs.tsx:156`; provider `app/(dashboard)/layout.tsx:53` |
| Inline progress after a click | `<PipelineRunActivity workspaceId slug runId awaiting/>` | `components/features/activity/pipeline-run-activity.tsx:30` |
| Approve/reject a parked run | `usePendingApproval` + `RoutineApprovalBanner` | `hooks/use-pending-approval.ts`; `routines-detail-panel.tsx:493` |
| Cancel | `useCancelRoutineRun` | `components/features/routines/live-run-row.tsx:42` |
| Parameter form from a **server-declared** schema | `SlashActionModal` + its `Field` switch | `components/features/chat/composer/slash-action-modal.tsx:57,185` |
| Reading a refusal | `readApiError` / `apiErrorMessage` | `lib/api-error.ts:30,51` |
| Toasts with an action button | sonner, mounted once | `components/providers.tsx:39`; idiom `routines-detail-panel.tsx:214-224` |
| Destructive confirm | shadcn `AlertDialog` | `components/ui/alert-dialog.tsx`; usage `profile-section.tsx:944` |

**Net-new frontend dependencies: none.** `react-hook-form` is deliberately *not* added — the repo
has no forms library, `SlashActionModal` proves a server-declared field switch is sufficient, and
adding one would be a new dependency for a problem that is already solved here.

### 8b.5 The bug this must not reintroduce

`apiFetch` **resolves on 4xx/5xx**; it rejects only on transport failure. Issue #1563 was four
mutations claiming success the server refused. The fix established four rules
(`components/features/orchestration/task-actions.ts:18-73`): check `res.ok` before any success
toast; say the server's own words; never destroy the state a retry needs; `catch` covers transport
only. **There is no lint rule enforcing this** — only per-site tests.

Mitigation for Pages: exactly one action executor function, unit-tested, used by every button.
The convention cannot drift if there is only one place it lives.

## 9. Rendering

| Concern | Choice | Cost |
| - | - | - |
| Layout | CSS Grid, `col-span-n` from YAML `span`; `@container` queries for in-panel reflow | 0 KB |
| `metric`, `status` | hand-written inline SVG + CSS | 0 KB |
| `table` | semantic `<table>`; collapses to card list under a narrow container | 0 KB |
| `narrative` | typed blocks → React elements | 0 KB |
| `series` | existing `recharts@^3.10.1` (`package.json:137`), bar only in v1 | already paid |
| Animation | CSS transitions + `@keyframes`, gated on `prefers-reduced-motion` | 0 KB |
| Dispatch | `Record<PanelSchema, ComponentType<PanelProps>>` registry keyed on the validated `schema` field | 0 KB |

The registry pattern is the same one Perses, Grafana Scenes and react-jsonschema-form use:
validate, then look up a closed enum in a flat map, then render. No `eval`, no dynamic `import()`
of a user-supplied path, no `dangerouslySetInnerHTML`.

Mobile and tablet first: single column below the tablet breakpoint, no drag-to-arrange on any
touch target (nobody ships that — react-grid-layout's own issue tracker treats disabling drag on
mobile as the expected configuration).

Reuse `components/ui/section-card.tsx` — already documented as *"the canonical container for
grouped content inside a page"* — rather than inventing a panel shell.

Second choice, if recharts' known React 19 `ResponsiveContainer` bug (a minified `displayName`
breaks its internal `isChart` check, producing blank charts) bites in testing: swap `series` to
visx (\~40–80 KB gzip), which is a net reduction against today's baseline.

***

## 9b. Shell and visual language — copy Routines, invent nothing

Pages sits in the sidebar next to Dashboard, Inbox, Issues and Routines, and must read as the
same product on first glance. The Routines and Credentials surfaces already define the language;
this section pins what Pages takes from them so no part of it gets re-invented.

### 9b.1 Three zones, and Pages is the second surface on the shared filter panel

```
[icon rail]  [filter rail 280px]              [main]
AppSidebar   SidebarToolbar / Search /        Overview  ·  or a single page
             FilterButton / Collapse
             ── STATUS  ────────── n
                All · Fresh · Stale · Failed · Never produced
             ── OWNER   ────────── n
                per crew
             ── PAGES   ────────── n
                the list, per-item icon + right-side badge
```

Everything in the middle column already exists in `components/layout/sidebar-kit.tsx`:
`SIDEBAR_WIDTH` (`:29`), `SidebarToolbar` (`:34`), `SidebarSearch` (`:43`),
`SidebarFilterButton` (`:105`), **`SidebarFilterPopover` (`:164`)**, `SidebarFacet` (`:279`),
`SidebarFacetOption` (`:319`), `SidebarCollapseButton` (`:378`), `SidebarActiveChip(s)`
(`:403`, `:430`), `SidebarSection` (`:439`), `SidebarRow` (`:514`).

**This is a hard requirement, not a preference.** Issue #1776 is open: five surfaces (Inbox,
Routines, Credentials, Integrations, Skills) still hand-roll their own filter popover and have
drifted in both appearance and behaviour — Credentials' `set({category}); setFilterOpen(false)`
makes combining two facets impossible. #1777 lifted the panel into the kit with Issues as the
parity proof. **Pages must be the second surface on the shared panel, never the sixth hand-rolled
one.** Facets are multi-select and the panel stays open after a pick.

### 9b.2 The main pane mirrors the Routines overview

Routines' Overview is the reference composition and Pages' index should be recognisably the same
page: a band of four stat tiles, then paired cards below.

* Stat tiles — `components/layout/stat-card.tsx:18` `StatCard{title, value, subtitle, icon}`.
  For Pages: **PAGES**, **STALE NOW**, **UPDATED TODAY**, **NEEDS ATTENTION**.
* Cards — `SectionCard` (`components/ui/section-card.tsx`, documented as *"the canonical
  container for grouped content inside a page"*) or `DashboardCard`
  (`components/features/dashboard/dashboard-card.tsx`) for the tile shell.
* Card header idiom, copied exactly: small icon + 11px uppercase tracking-wider label on the
  left, right-aligned muted status word — `38 total`, `none scheduled`, `no runs yet`,
  `nothing pending`, `all clean`. The right-hand word is always the *answer*, never a repeat of
  the label.
* **Type comes from the Pages register**, declared once in `app/globals.css` under *"The Pages
  register"* and never as a pixel value in a component. Five roles, named for the job:
  `.type-page-label` (the uppercase header above, and a table's column heads), `.type-page-value`
  (the content — cells, rows, prose, banners), `.type-page-meta` (the qualifier — status word,
  age, counts, hints), `.type-page-stamp` (machine text — ids, slugs, run ids, versions) and
  `.type-page-metric` (the one big number, and the em dash that stands in for it). Every one is
  sized from a `--typo-*` house token, so the product's scale still reaches Pages; a surface that
  writes `text-[11px]` has left the system and `type-register.test.tsx` fails. The rail is the
  exception and is not Pages' to size: it belongs to the navigation register (`.type-nav`,
  `.type-nav-sub`), because a rail is scanned and a card is read.
* Page shell: `PageShell` / `PageHeader` (`components/layout/page-shell.tsx:32`,
  `page-header.tsx:7`); in-page tab switching `ToolbarStrip` (`toolbar-strip.tsx:40`) or `SubBar`
  (`sub-bar.tsx:82`).
* Header line: icon + name + `·` + a dense count summary, exactly as Routines does
  (`38 routines · 0 runs`) and Credentials does (`12 secrets · 2 waiting on a tool`).
  Pages: `12 pages · 3 stale`.

### 9b.3 Empty states are instructions

Routines never renders a blank card. It renders a centred icon and two lines that name the next
action — *"Nothing has run yet. Pick a routine on the left and press Run, or give it a
schedule."* Use `components/layout/empty-state.tsx:16` and write the same kind of sentence for
every Pages empty state. A panel with no data yet says how to make data arrive.

### 9b.4 The em-dash rule — the app already solves our hardest problem

On the Routines overview, **RUNS TODAY** shows `0` and **SUCCESS · 7D** shows `—`. That is not an
inconsistency, it is the distinction this whole PRD is about: `0` is a measured zero, `—` is *no
basis to compute*. The design language already separates "we looked and there was nothing" from
"we have nothing to look at".

Pages inherits it verbatim, and it maps one-to-one onto the freshness states in §4:

| State | Rendering |
| - | - |
| `fresh` | the value, full contrast |
| `stale` | the value dimmed, with an absolute age next to it |
| `failed` | `—` plus the failure, in the destructive tone |
| never produced | `—` plus the empty-state sentence |

**Do not invent a fourth glyph for "no data".** The product already has one.

### 9b.5 Nav registration

`components/layout/app-sidebar.tsx:39-66` holds four groups: Plan (Dashboard, Inbox, Issues,
Routines), Run (Activity, Journal), Build (Crews, Skills, Credentials, Integrations), System.
**Pages belongs in Plan, after Routines** — it is where a person goes to see the state of their
work, not a thing they build once. Icon comes from `CONCEPT_ICON`, pinned by
`lib/__tests__/concept-icons.test.ts`.

### 9b.6 The list of things Pages must not re-invent

Filter popover · card shell · stat tile · empty state · the "no data" glyph · toast surface ·
confirm dialog · run-progress rail. Every one of them exists; each re-invention is a new way for
the same control to behave differently, which is the exact debt #1776 is tracking.

### 9b.7 Addresses — one canonical URL per artifact

The Artifacts library's **New Docs** action opens collaborative document
creation only when Page creation is allowed and the Docs service is confirmed
enabled. It explicitly chooses the `doc` format even when native Word or Sheet
is also available, initializes at `CREATE_SCHEMA`, and opens the returned
immutable Page ID. It reuses the original Page and initialization request on
retry. Its audience text includes workspace owners and admins; it does not
promise a private audience. The menu and format/identity regressions are in
`artifacts-library.test.tsx` and `new-document.test.tsx`.

Every Page has one browser address, `/artifacts/<page-id>?workspace_id=<id>`
(issue #5129), with `&version=N` for a pinned project version. `<page-id>` is
the immutable ID, so a renamed or reused slug never changes what a link opens.
The server generates only this form: the document creation `url`, the project
`url` (#4995) and the agent project tool's `page_path`.

The static export has no server redirect. `/artifacts/[id]` is exported once
as `/artifacts/_.html`, the Go binary serves it for every ID, and the client
reads the ID from `window.location` (`useUrlSegment`), as `/pages/[slug]` does.

With `NEXT_PUBLIC_ARTIFACTS_LIBRARY` in the build, the client maps every old
address with `legacyPagesTarget`
(`components/features/artifacts-library/legacy-pages-url.ts`), resolves the
reference with the same page read Pages makes and calls `router.replace`:

| Old address | Target |
| - | - |
| `/pages[?…]` | `/artifacts[?…]` |
| `/pages?ref=<id>&workspace_id=<ws>` | resolve `@<id>` |
| `/pages/<slug>`, `/pages/@<id>` | resolve the slug or `@<id>` |
| `/pages/<slug>?artifact=1[&version=N]` | resolve as a project; moves only to a project viewer the detail has |
| `/pages/<slug>?version=N` | resolve with the pin; moves only for a project, a Doc or a Design, to `/artifacts/<id>?version=N` (#5168). A Doc opens History with revision N selected, or says it is not there; a Design opens the requested historical snapshot read-only. A changed pin selects that snapshot after pending local writes are confirmed; it does not remount or discard a local draft |
| `/pages/craft/editor?page_id=<id>&workspace_id=<ws>` | resolve `@<id>`; the detail mounts the same editor host |
| `/pages/<slug>/editor`, `/pages/@<id>/editor` | `/pages/<slug>?mode=edit` by address alone (`lib/legacy-app-address.ts`, from the in-shell 404) |
| `/artifacts/@<id>` | `/artifacts/<id>` by address alone, query kept (`app/(dashboard)/artifacts/[id]/page-client.tsx`) |
| `/artifacts/<slug>` | the catalog entry whose ID is `<slug>` first, else (only when the address is not shaped like a generated ID, so a slug can never stand in for a deleted or unreachable ID) whose slug is `<slug>`; then `/artifacts/<id>`, keeping `workspace_id`, a numeric `version` and `ref`. Not in the catalog: the `GET /api/v1/pages/@<slug>` probe, whose 404 is the same for missing and unreachable pages |

It fails closed: an unknown `mode` or `edit` value, a malformed or
cross-workspace reference, a refused or failed read and a kind the Artifacts
detail cannot open keep the Pages view. The decision is made once for the
entry address, so navigation inside Pages never tears down an open editor.

A restricted session has no Pages screen (#5132). Its one surface, the
server's `pages` surface (`internal/api/restricted_surfaces.go`), is
`/artifacts` (`lib/restricted-surfaces.ts`), with or without
`NEXT_PUBLIC_ARTIFACTS_LIBRARY`. There `ArtifactsRoute` renders only
`RestrictedArtifacts`: the artifacts that have declared actions shared with
the member, and those actions. It sends exactly the requests the Page actions
catalog sent at `/pages`: `GET /api/v1/workspaces/{id}/restricted-pages`, the
publication-bound action `POST` and its own run poll. It never mounts the
library, the detail or Design, because `GET /api/v1/pages` and
`GET /api/v1/pages/{slug}` are not on the restricted allowlist.

| Restricted address | Shows |
| - | - |
| `/artifacts` | the granted artifacts, each with its action count |
| `/artifacts?item=<slug>` | that artifact's actions, if the catalog lists it; otherwise the access gate |
| `/artifacts/<id>` | that artifact's actions, if a catalog entry carries this `id`; otherwise the access gate |

The gate reads "This artifact isn’t part of your access". It never names the
address or any artifact. Both addresses are matched only against the catalog
the home reads too, so the address is never sent to decide access: an ID that
was never granted, never existed or was deleted gets the same request, the same
response and the same gate (#5135). An artifact listed without actions was never shown
before and is not shown now.

Old `/pages` addresses stay mountable for the surface only so that they can
move (`restricted-artifacts-url.ts`, by address alone, with `router.replace`).
`/pages[?…]` and a malformed `?ref=` go to `/artifacts`. `/pages/<slug>` goes
to `/artifacts?item=<slug>`, and `/pages/@<id>` or `?ref=<id>` to
`/artifacts/<id>`. `/pages/craft/editor?page_id=<id>` goes to `/artifacts/<id>`
without mounting the editor host (its availability read is not allowlisted);
it first runs the same local read-loss proof, whose only request is the
allowlisted `draft-key`. Only `workspace_id` is kept. Permissions are
unchanged, and the server remains authoritative.

Without the library, `/artifacts/<id>` resolves the kind and opens
`/pages/@<id>?artifact=1[&version=N]` for a project and `/pages/@<id>` for any
other kind. `/p/<token>` is a separate public route and is unchanged.

## 10. Data model

Era-2 migrations, one `.sql` per file under `internal/database/migrations/`, filename
`<YYYYMMDDHHMMSS>_<lower_snake_case>.sql`, versions strictly ascending above 169
(`internal/database/migrate_registry.go:42-68`; `migrations/README.md:24-25`).

```
pages(id, workspace_id, slug, name, description,
      owner_user_id NULL, owner_crew_id NULL,   -- exactly one of the two
      created_by_agent_id NULL,
      spec_json TEXT NOT NULL,                  -- the validated spec
      created_at, updated_at)
  UNIQUE(workspace_id, slug)
  CHECK ((owner_user_id IS NOT NULL) <> (owner_crew_id IS NOT NULL))
  owner_user_id ON DELETE RESTRICT              -- transfer before deleting a user (§7.1 rule 1b)

page_panels(id, page_id, panel_id, schema, title,
            owner_crew_id NOT NULL,             -- NULL would read as "visible to everyone"
            producer_kind, producer_ref,        -- kind ∈ {routine, script, agent, webhook}
            sla_seconds, span, config_json)
  UNIQUE(page_id, panel_id)
  owner_crew_id ON DELETE RESTRICT              -- a removed crew must not silently widen access

page_panel_data(panel_id, seq, payload_json, produced_at,
                producer_run_id, state)   -- ring: newest 200, hard age cut 7d (§10b.3)
  PRIMARY KEY(panel_id, seq)

page_versions(page_id, seq, spec_json, author_user_id, author_agent_id, created_at)
  PRIMARY KEY(page_id, seq)                -- last 50 kept; rollback target

page_public_tokens(id, page_id, token_hash, password_hash NULL, expires_at NOT NULL,
                   show_provenance, created_by_user_id, revoked_at NULL, last_seen_at)
  -- created_by_user_id NOT NULL: only a human publishes (§7.3.2 rule 3)
  -- expires_at NOT NULL: every public link expires (rule 4)

page_grants(page_id, subject_type, subject_id, level, panel_ids NULL,
            granted_by_user_id, granted_at, retired_at NULL)
  PRIMARY KEY(page_id, subject_type, subject_id, level)
  -- subject_type ∈ {user, crew, agent};  level ∈ {read, produce, write}
  -- panel_ids: JSON array, only meaningful for level = produce (NULL = all panels)
  -- granted_by_user_id is NOT NULL: only a human issues a grant (§7.1b rule 1)
  -- retired_at: the issuer lost standing; cleared only by issuing the grant again
```

JSON-in-TEXT is the house style (`migrate_consts_v109_member_capabilities.go:22-24`), and size
caps are enforced in Go at the handler, never as a DB `CHECK` — the whole schema contains only two
`length()` constraints and neither is a size cap. Follow
`internal/api/memory_config_handler.go:171-201` (`MaxBytesReader` → decode → explicit 400) with
the richer 422-plus-rejection-envelope shape from `internal/sidecar/memory_write.go:47-55` for
oversized payloads. **Proposed caps: 64 KiB per panel payload, 256 KiB per page spec.**

⚠ `/api/v1/internal/*` bypasses the global `BodyCap` middleware — any internal Pages write
endpoint must bound its own body explicitly (the repeated warning at
`internal/api/crew_messaging.go:60`).

***

## 10b. Lifecycle, limits and portability

All decided 2026-08-12.

### 10b.1 Editing and versioning

**The editor already exists.** CodeMirror 6 is in `package.json:27-40` — including
`@codemirror/lang-yaml` and `@codemirror/lint` — and `components/features/routines/
routine-editor-tab.tsx` already wires it up for routines. The Pages editor is the same component
with the YAML mode and a linter fed by our own schema. Authoring is therefore: the CLI, the
in-app editor, or an agent — three doors onto one document.

**Every save is a version.** `page_versions(page_id, seq, spec_json, author_user_id,
author_agent_id, created_at)`, following the `pipeline_versions` precedent. Several agents may
rewrite one page, and the one who breaks it is rarely the one who notices — so
`crewship page rollback <slug> --to <seq>` is not a nicety. Retain the last **50** versions.
Panel *data* is not versioned; the payload ring in §5 is the only history it gets.

**Rollback restores structure, never numbers.** A panel brought back by a rollback renders
**dimmed, in a "waiting for first data" state**, even if rows for it survive in the ring. Old
payloads are never resurrected and shown as current — that is precisely the lie §4 exists to
prevent, and a rollback is exactly when someone is most likely to believe what they see.

**The save gate, and why import skips it.** Routines cannot be saved without a passing
`test_run` less than five minutes old (`ErrTestRunGateFailed`,
`testRunFreshness = 5 * time.Minute`, `internal/pipeline/store.go:24-38,119-133`). But
**imports skip that gate by design** — *"a marketplace \[bundle]…"*
(`internal/api/pipelines_crud.go:336`). Pages copies both halves:

* **Authoring** (CLI, editor, agent) validates the spec against the schema and checks that every
  declared producer and owner resolves. Cheap, synchronous, no render run. It stops an agent
  saving a page that names a routine which does not exist.
* **Import** skips it, exactly as routines do — a bundle from elsewhere is bound at import time
  and its references are checked there instead (§10b.2).

### 10b.2 Templates = export/import, the currency the marketplace already uses

**Revised 2026-08-12.** An earlier draft proposed a built-in catalog kind following
`CrewTemplate` / `Recipe`. That was the wrong precedent. The right one is **routines**, which
already ship the marketplace mechanism:

* `POST /api/v1/workspaces/{ws}/pipelines/{slug}/export` produces a portable bundle with
  workspace-specific ids stripped, explicitly *"so a marketplace consumer can import via
  POST …/import"* (`internal/api/pipelines_crud.go:163-178`).
* Import is *"marketplace install flows + cross-workspace transfer"*
  (`pipelines_crud.go:240`).

A "page template" is therefore **not a separate noun**. It is a page spec exported as a bundle:
producer references become declared placeholders, ids are stripped, and import binds them to
local crews and routines. The same document is a page here and a template there.

```
crewship page export <slug> > weekly-close.page.yaml
crewship page import weekly-close.page.yaml --slug uzaverka --bind crew/ucetni=crew/finance
```

**Marketplace readiness in 1.0 means three things**, and no more: the export bundle carries no
workspace ids, every external reference the page needs is *declared* (so the importer can see
what it must bind before installing), and import is a single transaction that either binds
everything or refuses. Whether the marketplace lists pages is a marketplace decision; the bundle
being clean is ours.

The unbound-reference case is the one to get right: importing a page whose producer routine does
not exist locally must not create a page full of dead panels. Import either binds it, or refuses
and says which reference it could not resolve.

### 10b.3 Hard limits

Chosen against SQLite's single-writer reality, not plucked from the air. The dangerous number is
push frequency: 24 panels × 100 pages × one push every 5 s is 2 880 writes per second, which no
amount of index tuning survives.

| Limit | Value | Why |
| - | - | - |
| Pages per workspace | 100 | soft, admin-raisable; stops an agent loop producing thousands |
| Panels per page | 24 | beyond this nobody reads it anyway |
| Payload size | 64 KiB | enforced in Go at the handler (§10) |
| Spec size | 256 KiB | as above |
| **Push rate per panel** | **12/min sustained, burst 30** | one push per 5 s is the artefact's own fastest producer |
| Push rate per workspace | 600/min | the real backstop; per-panel limits do not compose |
| Over the limit | **429 + `Retry-After`** | the pattern `pipelines_exec.go:211-218` already uses |
| Public page views per token | 600/h | §7.3.2 rule 6 |
| Payload ring | **200 payloads, hard age cut at 7 days**, whichever comes first | see below |
| Versions per page | 50 | |

**The *rate* rows live in `config/rate-limits.yml`; the *size and count* rows are Go constants.**
That split is deliberate and was left implicit in an earlier draft, which read as though every
number belonged in the YAML and contradicted §10 ("size caps are enforced in Go at the handler").
Rates are operational and get tuned by whoever runs the instance; sizes and counts are contract —
a payload cap that varies per deployment is a payload cap no producer can code against.

For the rate rows, that file is the existing
declarative surface — `{name, requests, window, scope, applies_to[]}` per limit, with `window`
in `1s|10s|1m|1h|1d` and `scope` in `ip|user|agent` — parsed by `internal/ratelimitcfg`. Pages
adds its entries there so the limits are visible, reviewable and adjustable in Settings rather
than compiled in. The table above is the proposed default set, not a hard-coding.

⚠ That file's own header records a limitation to design around: *"MVP: per-process, neskaluje přes
více instancí."* With one binary this is exact; with N replicas every cap becomes N×. Panel push
limits must therefore also be enforced where the write lands — a cheap `produced_at` check
against the panel's minimum interval, in the same transaction — so the floor holds regardless of
how many processes are serving.

On retention: "about a week" is right for the *age* bound but cannot be the only bound. A panel
pushed every 5 s produces \~120 000 rows in a week — per panel. So the ring is bounded by **count
first, age second**: keep the newest 200 payloads, and drop anything older than 7 days even if
that leaves fewer. A sparkline needs about 30 points; 200 is already generous. Configurable per
workspace as `page_retention_days`, following `run_retention_days`
(`migrate_consts_v158_run_retention_days.go:13`).

### 10b.4 When the ground moves

A panel never disappears quietly. If its producer is deleted, its owning crew is removed, or the
agent holding `produce` is dismissed, the panel switches to **`failed`** with a stated reason —
"producer routine `x` no longer exists" — and stays on the page. A page is a fixed structure with
a template behind it; silently shrinking it would mean the page lies about what it is supposed to
show. `on_failure` fires as it would for any other failure.

### 10b.5 Export and backup

* **`crewship export`** carries the page **spec** only. Export moves configuration between
  installs; panel data is state and belongs to the install it came from.
* **Backup** carries spec, grants, versions and panel data — the backup bundle is already a
  whole-instance snapshot including containers, and a page whose numbers vanish on restore would
  be a page nobody trusts afterwards.

### 10b.5b Liveness — every page is live, and `stream: true` is deleted

Decided 2026-08-12 on architectural grounds, and it makes the spec **smaller**.

The first draft had a per-panel `stream: true` flag, and §13 obstacle 4 warned that realtime is
not free. Re-examined against what the Hub actually is, the flag was solving a problem we do not
have: **the client already holds exactly one websocket for the whole app**
(`internal/ws/hub.go:61`, `HandleUpgrade` `:797`, auth by ws-token frame `:899`), and channels are
just subscriptions multiplexed over it, authorised by `DBChannelAuthorizer.CanSubscribe`
(`internal/ws/channel_auth.go:46-104`).

So an open page subscribes to **one channel, `page:{pageId}`**, and a push broadcasts an
invalidation on it. That is not a socket per panel or a socket per viewer — it is one more
subscription on a connection that already exists. Cost per additional live panel: zero.

Consequences, all simplifications:

* **`stream: true` is removed from the schema.** Every page is live. There is no slow mode to
  configure and no fast mode to abuse.
* **The push rate limit (§10b.3) is the only throttle**, and it is where it belongs — on the
  producer, not on the viewer.
* **Broadcast carries no payload**, only "panel X changed". The client re-reads through the normal
  authorised path, so the per-panel permission filter (§7.1) cannot be bypassed by a broadcast
  reaching a subscriber who should not see the data. A channel that shipped payloads would need
  its own copy of the filter — this way there is only ever one.
* Fallback stays honest: if the socket is down, the page polls at its shortest SLA. The realtime
  banner already tells the user which mode they are in
  (`components/layout/realtime-status-banner.tsx`).
* `page.panel.updated` joins `VALID_REALTIME_TYPES` (`hooks/use-realtime.tsx:115-146`), which
  silently drops unknown types — a step that is easy to forget and produces a page that simply
  never updates.

### 10b.5c Inbound webhooks — a producer that cannot run the CLI

A panel should be writable by anything, not only by something that can execute the `crewship`
binary — a cron on someone else's box, a Zapier step, a PLC gateway, a GitHub Action.

`POST /api/v1/page-webhooks/{token}` with the payload as the body. (This section first named
`POST /api/v1/pages/webhooks/{token}`; that pattern cannot be registered — Go's ServeMux rejects
it as a conflict against `POST /api/v1/pages/{slug}/public` and `.../rollback`, both of which match
`/api/v1/pages/webhooks/public`, and the failure is a panic at boot. The endpoint therefore gets
its own top-level space, as every other token-addressed door already has: `/api/v1/webhooks/{token}`
for a pipeline, `/api/v1/public/pages/{token}` for a published page. See
`internal/api/router_pages_webhooks.go`.) The shape is copied from
`pipeline_webhooks` rather than invented: tokens are **SHA-256 hashed at rest** (since #1888,
`internal/pipeline/webhooks.go:23-46`), holding the token is the authorisation, and the token is
bound to exactly one panel — so a leaked token can write one panel and nothing else.

The webhook is a `produce` grant in a different coat, and it obeys every rule that grant does:
issued only by a human, rate limited per panel, revocable, and every write journalled with the
token id as the actor.

### 10b.5d The Dashboard strip

Pages earns a strip on the Dashboard — `DashboardCard` titled **PAGES**, listing the most
recently updated pages the viewer may see, with the stale count as the right-hand status word
(`3 stale` / `all fresh`, per the idiom in §9b.2). It is the cheapest way for the feature to find
people's hands, and it is a read-only view over data the page index already returns. Ships after
the Pages surface itself, not inside the first slice.

### 10b.6 Notifications

New category **`pages.stale`**, in the `system` group (`internal/notify/categories.go:103-122` —
`AllCategories` is derived from `CategoryGroups`, so a category with no group fails CI). It
notifies the page owner. Default **on** for the owner, off for everyone else; `on_failure` → issue
remains the escalation path for anything that needs work rather than awareness.

### 10b.7 Language

A page inherits the workspace's `preferred_language` (already on the workspace record). The
producing agent is instructed to write panel titles and narrative in that language. Nothing is
translated at render time — a page shows the words its producer wrote, which is the only way the
provenance stays honest.

### 10b.8 Print and PDF

A print stylesheet ships in 1.0: `@media print` collapses the rails, forces the single-column
grid, prints the provenance footer and the age of every panel, and breaks between panels rather
than through them. That covers "send the monthly close to the accountant" via the browser's own
Print-to-PDF, and costs a stylesheet.

Server-side PDF generation (a headless browser in the binary, scheduled email delivery) is **not**
in 1.0. It is a rendering service with its own failure modes, and the print stylesheet is what
makes it cheap to add later if anyone actually asks.

## 11. API and CLI surface

Repo rule: every endpoint gets a CLI command, and the acceptance test drives the CLI.

| Endpoint | CLI |
| - | - |
| `GET /api/v1/pages` | `crewship page list` |
| `GET /api/v1/pages/{slug}` | `crewship page get <slug>` |
| `POST /api/v1/pages` | `crewship page create` |
| `PATCH /api/v1/pages/{slug}` | `crewship page update <slug>` |
| `DELETE /api/v1/pages/{slug}` | `crewship page delete <slug>` |
| `POST /api/v1/pages/{slug}/archive` | `crewship page archive <slug>` |
| `POST /api/v1/pages/{slug}/unarchive` | `crewship page unarchive <slug>` |
| `PUT /api/v1/pages/{slug}/panels/{id}/data` | `crewship page set <slug>/<panel> --data -` |
| `GET/PUT/DELETE …/grants` | `crewship page grant` / `revoke` |
| `PUT /api/v1/internal/pages/{slug}/data` | sidecar path for container scripts (panel is a body field, not a path segment — `internal/api/pages_internal.go`) |
| `POST/GET …/{slug}/webhooks`, `DELETE …/{slug}/webhooks/{id}` | `crewship page webhook create` / `list` / `revoke` |
| `POST /api/v1/page-webhooks/{token}` | none, deliberately — §10b.5c is the door for a producer that cannot run the binary |

`crewship page set <page>/<panel> --data -` reading JSON on stdin is the single write path, and
it is what appears in every producer script. Provenance is attached server-side.

Registration constraints, all CI-gated:

* Mutations must register through `authedMut` / `authedAdmin`, not raw `authed(...)`
  (`internal/api/rbac_routes.go:224-268`); `internal/api/route_authz_invariant_test.go` fails the
  build otherwise.
* A `scopeForRoute` case is required (`rbac_routes.go:104-171`) or the scope resolves to `""`
  and the build fails.
* Regenerate the golden: `go test ./internal/api -run TestMutationRouteRolesMatchManifest
  -update-route-roles`.
* Ownership-gated mutations use the `roleSelf` sentinel (`rbac_routes.go:69-74`), as
  saved-views DELETE already does (`router_orchestration.go:159`).
* OpenAPI schemas under `internal/apidocs/`.

**A routine writing a panel needs a governed verb.** `internal/pipeline/crewship_step.go:119-178`
refuses any verb without a `PolicyAction` at *save* time (`ErrCrewshipVerbUngoverned`). So
`page.write` requires a new `policy.Action` constant (`internal/policy/types.go:70-203`) with a
decided cell for all four autonomy levels in `DecideAction`, plus the existing parity test
`TestCrewshipVerbs_EveryPolicyActionIsDeclared`.

***

## 11b. Wire decisions — pinned 2026-08-12

Two agents building against this PRD in parallel each found the same class of gap: the document
named a concept without fixing its shape on the wire. Every one of these is now pinned, because
an ambiguity here becomes a client and a server that both pass their own tests.

1. **Routes are workspace-unscoped: `/api/v1/pages/...`**, with `wsCtx` supplying the workspace,
   following `saved-views`, `missions`, `runs`, `journal` and `automations`. Pipelines' scoped
   shape (`/api/v1/workspaces/{ws}/pipelines`) is the older pattern. The CLI already appends
   `workspace_id` (`internal/cli/client.go:208-218`).
2. **`page create --file` sends the parsed spec as JSON**, not the YAML verbatim. §10 stores
   `spec_json TEXT` described as *the validated spec*, and §10b.1 requires validation at authoring
   time, which the server cannot do on an opaque string. The CLI parses; the server validates.
3. **SLA is `sla_seconds` (integer) on the wire**; `sla: 30s` is YAML sugar the CLI converts.
   One representation in the database, one on the wire, one for humans.
4. **Provenance is a nested object**, not flat fields: `provenance: {producer, run_id, produced_at}`.
   Flat fields would collide with payload keys the moment a producer emits `produced_at` itself.
5. **`page delete` requires `--yes`**, consistent with every other destructive CLI command
   (`confirmAction`). A delete an agent cannot script non-interactively is unusable.
6. **The size cap is enforced at the handler**, and a client-side pre-check may only be additive.
   The same 422 must arrive on the sidecar path, where no client pre-check exists.
7. **`table.v1` rows are keyed objects** (`{colKey: value}`), matching `columns[{key,…}]`.
   Positional arrays may be accepted as a convenience but keyed is canonical and is what the
   schema validates.
8. **There are four panel states, not three.** §4's `fresh` / `stale` / `failed` plus
   **`never_produced`**, and the **server sends it** — it knows there is no `page_panel_data` row,
   and having the client infer it from an absent field is how two clients end up disagreeing.
9. **`metric.v1` `delta` is directionless by default.** It renders with a sign and an arrow in the
   muted tone. An optional `delta_good: "up" | "down"` opts into success/destructive colour.
   Green-up on an error rate would be a lie, so the payload has to say which way is good.
10. **`metric.v1` `target` is a ceiling** rendered as a ratio meter clamped to 0–100 %.
11. **`status.v1`: `name` is the row title, `label` is a detail line.** The state word renders
    separately from `label`, so "glyph + text, never colour alone" holds even when a producer
    writes something unrelated in `label`.
12. **`table.v1` carries a row cap of 200.** §10b.3 caps bytes only, and 64 KiB is roughly a
    thousand rows — more than anyone reads and more than we will virtualise.
13. **CLI flags, pinned after the documentation pass found them unspecified.** A command whose
    flags are undefined cannot be implemented to the line, so:
    * `page update <slug> --file <yaml>` — symmetric with `create --file`; same parse-and-validate
      path, and the update is what creates a new row in `page_versions`.
    * `page revoke <slug> --user|--crew|--agent <ref>` — fully symmetric with `grant`. An
      asymmetric revoke is how a grant becomes impossible to remove.
    * `--bind old=new` is **repeatable**, not comma-separated. Slugs may contain a comma far more
      plausibly than they may contain a repeated flag, and a bundle with six references needs six.
    * `page list --status <fresh|stale|failed|never_produced> --owner <crew>`, both repeatable,
      mirroring the rail facets in §9b.1 so the CLI and the UI filter by the same vocabulary.
    * `page export <slug>` takes no history flag. Export carries the spec only (§10b.5); a flag
      that offered to include data would contradict that section.
14. **The sealed placeholder has a wire shape** (§7.1 rule 2). A panel the viewer may not see is
    serialised as `{panel_id, span, sealed: true, owner_crew_name}` and nothing else — no schema,
    no payload, no producer, no SLA. The renderer keys on `sealed`, not on a missing field, so a
    serialisation bug can never be mistaken for a permission decision.
15. **The page index carries a freshness rollup**, or every derived number on the overview is an
    em dash. Each row in `GET /api/v1/pages` carries `panel_states: {fresh, stale, failed,
    never_produced}` as counts, plus `last_produced_at` — which is deliberately **not**
    `updated_at`, because §10 defines that as the spec's modification time. A page whose spec was
    edited an hour ago and whose data last arrived a week ago must not read as "updated today".
16. **`metric.v1` `sparkline` points are evenly spaced by contract.** A producer that pushes
    irregularly must send a `series.v1` panel instead. Even spacing is only honest if the producer
    guarantees it, so the schema states it rather than implying it.

## 12. Implementation reference groups

The original v0/v1/v1.1 labels below are retained because source comments and
regression tests use them. They group technical contracts; they are not a
release schedule or a fresh implementation-status claim.

### v0 — typed producer payloads

Named pages contain typed panels with producer provenance, freshness states
and per-panel authorization. Definition and payload validation are separate;
unauthorized panels retain sealed placeholders.

### v1 — actions and declarative installation

Actions use the closed vocabulary in §8b, server-authorized index dispatch,
idempotency and explicit confirmation. `kind: Page` participates in declarative
installation; grants and payload retention obey the boundaries in §7 and §10.

### v1.1 — producer refresh and wake gates

`wake:` gates and `refresh:` declarations connect producers to authorized
routine dispatch and panel updates. Preserve refresh configuration through
parsing, API storage and client round trips; never bypass producer permissions.

### v1.1b — controlled sharing

Public page access remains per-panel opt-in with token/expiry/password rules
from §7.3. Authoring and portability retain their separate authority checks.

### v1.2 — extended panel and producer interfaces

Series rendering, sandboxed embeds and inbound panel webhooks retain their
schema, sandbox and producer-authorization boundaries. Their original group
label does not authorize arbitrary browser code or data-source access.

## 12b. Parallelism — which slices can run at once

Schema and payload contracts precede consumers. API registration, permission
checks, rendering and producer integration must agree on the same shape.
Work allocation and release sequencing belong to private working context.

## 13. Obstacles, ranked

Verified against the tree; each is real work, not a caveat.

1. **No durable named-artifact primitive exists.** `pipeline_run_step_outputs` is keyed by
   `(run_id, step_id)`; crew shared files are a filesystem with no query surface; the routine
   DSL's `outputs` is documentary only (`schemas/routine.v1.json:36-38`). A Page is a new noun
   with new tables.
2. **No per-object ACL exists**, and it is a recorded non-goal (§7.2). `page_grants` is the first.
3. **A routine cannot write a page without a governed verb** (§11).
4. **Realtime is not free.** `hooks/use-realtime.tsx:115-146` silently drops event types outside
   its allowlist, and the `journal:{workspaceId}` WS channel — the obvious live-update carrier —
   has zero subscribers today and no `Last-Event-ID` replay. SSE
   (`internal/api/journal_handler.go:84`) is the only gap-free stream and it is journal-shaped.
   v1 polls; streaming is v1.2 and needs a decision, not an assumption.
5. **Adding a manifest kind touches 11 hand-maintained lists across 4 Go files** plus docs and
   fixtures: `schema.go:45-66`, `parse.go:81-103` (`isEmpty` — silent regression if forgotten),
   `parse.go:105-165`, `parse.go:238-384`, both hard-coded kind strings at `parse.go:381,383`,
   `apply_kinds.go` plan block, `apply_kinds.go:375-431`, `apply_kinds.go:436-454`,
   `plan.go:236-269`. The rank table is capitalised while kinds emit lowercase — bridged by
   `snakeToDocKind` (`plan.go:300-309`), and it has already caused an ordering bug once
   (`plan.go:272-280`).
6. **Manifest export is dead code for SPEC-2 kinds.** `ExportSavedViews`, `ExportRoutines`,
   `ExportProjects` have no non-test callers; `crewship export` knows only Crew and Workspace
   (`cmd/crewship/cmd_export_manifest.go:89,122`). `ExportPages` would ship tested and unreachable
   unless the CLI grows a kinds path — hence v1.2.
7. **Route registration is CI-gated in four places** (§11).
8. **Manifest apply is client-side over the public REST API** (`schema.go:14-18`) — full public
   CRUD must land before the manifest kind can exist. Sequence the work accordingly.
9. **Frontend mirrors are hand-copied** — `lib/capabilities.ts`, `lib/notification-categories.ts`.
10. **Do not repeat the SavedView slug mistake.** SavedView drift-detects on `name`, not slug,
    because the table has no slug column (`kinds/saved_view.go:216-220`). Pages are
    slug-addressable from the first migration.

***

## 14. Test plan

Repo rule: red test → fix → green, and it must fail on current `main`.

* **Go, table-driven** — spec validation (unknown schema, missing `sla`, two units in one
  `series.v1`, >5 series, span out of range); payload validation per schema; freshness state
  transitions across an SLA boundary with an injected clock; payload cap → 422 envelope;
  ring eviction at N+1.
* **Permission invariants** — the highest-value tests in this PRD:
  * a viewer without crew membership receives a response that **does not contain** the panel
    (assert on the serialised body, not on a rendered flag);
  * a `read` grant does not widen crew visibility;
  * a `write` grant cannot write panel *data*;
  * a `produce` grant scoped to panel A cannot write panel B on the same page;
  * an agent with `write` **can** add a panel it cannot read, and **does not** receive that
    panel's data in any subsequent read (§7.1b rule 2 — one test, both halves);
  * an agent cannot issue a grant by any route, including on its own crew (§7.1b rule 1);
  * an agent grant narrows automatically when the granting human loses the underlying access;
  * an unauthorised `produce` returns 403 for a reachable page (404 otherwise), writes a journal entry **and** notifies the owner
    (§7.1b rule 3);
  * a producer token cannot claim another agent's identity via the request body;
  * agent-created page records the authorising human as owner.
* **Security** — the §8 rules as executable tests: a narrative payload containing an image block,
  an external URL, or an undeclared action index is rejected at the API boundary; an action token
  bound to another user/panel is refused.
* **Route gates** — `route_authz_invariant_test.go`, `route_read_scope_invariant_test.go`,
  regenerated `internal/api/testdata/route-roles.txt`.
* **Manifest** — parse coverage table entries (`internal/manifest/parse_cov_test.go:115,149`),
  and `examples/manifests/full-complete.yaml` extended (exercised by `examples_validate_test.go`).
* **Frontend** — Vitest for the panel registry (every schema resolves; unknown schema renders the
  fallback, never throws); Playwright for the per-viewer reflow (two accounts, same page URL,
  different panel counts).
* **Acceptance drives the CLI binary**, per repo rule.

***

## 15. Open decisions

1. **Grant table now, or the SavedView boolean first?** The requirement says grants. Building the
   first ACL in the codebase is the single largest v1 risk. Alternative: ship v1 with
   owner + `shared` boolean, add `page_grants` in v1.1 once the rest is proven.
2. ~~What happens when the owner leaves?~~ **Decided 2026-08-12:** transfer to a crew, notify
   ADMIN/OWNER to reassign (§7.1 rule 1b).
3. ~~Can a crew own a page?~~ **Decided 2026-08-12:** yes, `owner_user_id` xor `owner_crew_id`
   (§7.1 rule 1). Owner or admin issues view grants (§7.1 rule 3).
4. ~~Panel payload ring depth~~ **Decided:** 200 payloads, hard age cut 7 days (§10b.3).
5. ~~Should `stream: true` exist?~~ **Decided:** deleted. Every page is live over the single
   existing websocket; the producer's rate limit is the only throttle (§10b.5b).
6. **Can an agent create a page unprompted?** Owner decision 2026-08-12: yes — it counts against
   the workspace cap and records the authorising human as owner, like any other agent-created
   page. Open sub-question: should an agent-created page arrive in the owner's inbox for
   acknowledgement, or appear silently? Leaning toward an inbox card the first time a given agent
   creates a page, and silence thereafter.
7. **Public pages: how is the password delivered?** Owner decision 2026-08-12 was "keep it as
   simple as possible for 1.0". Simplest is one token, one optional password, no per-recipient
   naming — at the cost of never knowing who looked. Named per-recipient tokens are a small
   addition later; the schema in §10 already allows several tokens per page, so this is a UI
   decision, not a migration.
8. ~~Does a public page show staleness?~~ **Decided:** age yes, reason no (§7.3.2b).
9. ~~Templates: catalog or user-authored?~~ **Decided:** neither — export/import bundles, the
   mechanism the marketplace already uses (§10b.2).

***

## 16. Sources

Public implementation references: `internal/pages/`, `internal/api/pages_*.go`,
`cmd/crewship/cmd_page.go` and `components/features/pages/`.
Market research and its bibliography are retained in private context; they
are not required to build, test or use the product.

A Page grant issuer must also have an active trusted account and no restricted membership anywhere. Every non-NULL suspension value, including an empty string, makes its Page shares inactive at use time. Existing membership/ownership/crew conditions remain required; the administrative grants list preserves inactive metadata and explains account unavailability.

A grant ends when its issuer loses the authority needed to issue it. Restoring the role does not revive the old grant; a new grant is a new operation (#5069). Retirement is recorded in `page_grants.retired_at` in the transaction of the change that removed the standing, whether that is a role, membership, access mode, suspension, crew membership or Page ownership change, so no read is needed between a loss and a return. Standing is one definition, the view `page_grant_verdicts`, read by both the authorization path and the retirement triggers. A change that keeps standing, such as ADMIN to OWNER, or a demotion of a Page owner who keeps the Page, retires nothing. A retired grant stays in the grants list, inactive, and revoke removes it; issuing it again clears the marker and journals like any other grant. Comment grants and workbook editor grants are Page grants and follow the same rule. Folder ACLs do not: `set_by_user_id` is audit, not authority. Retirement also closes the editor sessions of a workbook grant in the same transaction, so a reissue cannot revive a session opened on the old grant. A restore keeps the marker, and the target remembers each retired issuance in `page_grant_retired_issuances`, which survives revoke and reissue and is excluded from backups: a replacing restore of an earlier bundle does not bring back an issuance retired on that target, while a new issuance is unaffected: its `granted_at` is moved to the first second no retired issuance of that page, subject, level and issuer holds. Every imported grant is judged on arrival against the target's standing, whether or not the bundle carries memberships; an issuer unknown on the target retires it. Erasure and deletion of a person remove what names them.

### Folder membership event privacy

Folder add, move, remove and batch move retain their canonical journal target
Page ID and summary. Their workspace invalidations use the existing Page output
classification: private chat audiences disclose no Page identity; Documents and
empty human staging Pages expose only opaque IDs. Failed or missing native
classification discloses neither identity nor descriptive metadata.

Folder membership journal producers record two internal boolean classifications.
The shared public projection removes these markers and preserves canonical input.
It is applied to journal List/Get/SSE/WS serialization, before automation preview
matching and before runtime automation matching/rendering. Only newly classified
ordinary events retain their public descriptive metadata. Historical unclassified
(including ordinary), missing or malformed classifications receive a generic
projection without Page identity. Full-text List/Count excludes private-output and
unclassified folder entries before matching, preventing title confirmation.
Canonical backup/recovery data retains its existing OWNER/instance-admin gates;
this does not introduce Page folder ACL mutation auditing or change its atomicity.

### Private browser draft recovery

Native documents, text documents, Project source/comments and Design drafts use
an immutable Page ID and human identity as their recovery scope. New durable
payloads are encrypted in the browser. Recovery obtains a key envelope through
a fresh authenticated Page-read check; an expired session retains the copy and
does not authorize releasing it to another identity. Bound legacy records are
migrated before entering an editor. Unbound slug records (Project source and
comment drafts from builds before Page-ID keys) are never replayed
automatically: the project editor shows the signed-in person's own copy as an
older draft that may belong to another Page, and adopts it only on an explicit
choice, into an empty Page-ID draft (a compare-and-set in one IndexedDB transaction;
a draft another tab stored meanwhile is kept, and so is the slug copy).

A positively verified loss of current Page read erases reachable local drafts;
a network failure, expired cookie, or loss of Edit alone does not prove that
loss. Offline clients cannot promise immediate deletion. Deferred deletion
compares the captured revision, preserving a later replacement.

#### When a draft is erased: Native and Project

A local draft is erased only on explicit sign-out and on proven loss of read of
the Page. It survives an expired sign-in. This rule covers two scopes: native Word
and Sheet drafts (the journal in `lib/mooring/native-draft.ts`) and Project source
and comment drafts (`lib/artifact-drafts.ts`). Text documents and Design have their
own paths and are not part of this table.

Three things can be read, and they are lost separately:

* **Latest content**: the current native document or Project source, its history
  and proposals. It needs Edit, or the administrator read rule.
* **The shared version**: the pinned version. Read and Comment reach only this, and
  only when a version is pinned.
* **The Page**: Read, Comment, Edit or a folder ACL entry reaches it, with or without
  a pin. A private Page is reached only by its private audience. A Project is
  private until its first share, so a grant alone reaches no unshared Project.

Only losing the Page erases a draft. Losing Edit takes latest content away, at any
level down to Comment: the person keeps the same shared version, or the same
absence of one, and the draft stays. Native admission refuses Comment, but a refused
admission is not proof.

"Proven" means a `403 draft_read_revoked` from `draft-key` or, on an open native
editor, a `session_retired` event with `draft: "purge"`. The event says `purge` only
while the person's credential is still current and the same read `draft-key` asks
refuses them, so native admission or application eligibility never erases, and a
restricted private custodian keeps the draft. A `readable: true` answer or a
delivered shared version supersedes every older proof still on the wire for that
Page in that tab. From the moment an editor holding a kept draft asks for a
replacement admission, it asks `draft-key` every 30 seconds, one request at a time,
until it unmounts, signs out or erases. So Comment to none is found without a
reload, even if that admission never finishes.
Native
asks `draft-key` with `operation: "check"`; Project asks `wrap`. Both ask the same
read question, canonical Page visibility. A native draft is asked about after a
refused or unavailable admission and on any entry short of the editor (preview,
read-only, refused or failed availability, or a workspace gone from the directory).
A proof that arrives late still erases the revisions captured when it was asked,
unless read was confirmed after it was asked. A later confirmation (fresh read,
admission, reauthorization or stream event, in this tab) supersedes it for good: it
closes no key and revokes no journal.

| Event | Latest content | Shared version | Native (Word, Sheet) | Project |
| - | - | - | - | - |
| Explicit sign-out, confirmed | Unchanged | Unchanged | Captured revisions erased | Captured revisions erased |
| Sign-out cancelled or not acknowledged by the server | Unchanged | Unchanged | Kept | Kept |
| Sign-in expired, or session ended elsewhere | Unchanged | Unchanged | Kept, sealed; content hidden | Kept, sealed |
| 401 | Unchanged | Unchanged | Kept | Kept |
| Generic 403, 404, 5xx, network failure | Unknown | Unknown | Kept; content hidden, nothing offered until a fresh admission | Kept; a 403 or 404 asks `draft-key` for proof |
| Edit to Read | Lost | Readable if pinned | Kept, sealed; nothing offered, because read of the shared version gives no right to unpublished content | Kept |
| Edit to Comment, or Read to Comment | Lost | Readable if pinned | Kept, sealed; admission is refused, so the editor offers nothing and points to the shared version | Kept |
| Folder ACL loses write, keeps read | Lost | Readable if pinned | As Edit to Read | Kept |
| Comment, Read or Edit removed, no other reach | Lost | Lost | Erased | Erased |
| Private audience excludes the person | Lost | Lost | Erased | Erased |
| Workspace membership removed | Lost | Lost | Erased | Erased |
| Page deleted | Lost | Lost | Erased | Erased |
| Another person signs in, same browser | Not theirs | Not theirs | Sealed rows kept, unreadable to them; recovery needs the original person | Same |
| Offline | Unknown | Unknown | Kept; see below | Kept; see below |

Unsealed legacy plaintext copies are the exception to surviving expiry: they do
not qualify for retention and are removed at any identity change.

Erasing happens where this browser can reach the row. A draft whose Page ID the
browser no longer learns, because nothing opens that Page again, stays sealed:
its key is refused, so it cannot be read, but it is not erased.

Revocation has three separate stages on an open native editor, and each has its
own bound:

1. **Delivery.** The server rechecks every 5 seconds
   (`craftNativeEventInterval`), so a healthy stream delivers within that
   interval plus transit. With a silent stream, the 30-second reauthorization
   delivers the refusal. Each attempt has a deadline equal to that interval, so
   with both silent the editor concludes it is unconfirmed within 60 seconds
   (`NATIVE_DETECTION_BOUND_MS`).
2. **Client reaction.** On a purge event the frame is removed and the draft
   offer withdrawn in the same turn; the draft is then erased. On a refusal or
   a missed deadline content is hidden and the offer withdrawn at once. A refusal
   alone erases nothing; the editor then asks `draft-key` for proof. An editor being
   captured turns transparent first and is hidden only after the capture, because
   full Chromium stops rendering a `visibility:hidden` frame and an engine that
   answers on an animation frame would never give up its last changes.
3. **Reconnect.** Nothing is read or offered before a fresh admission. A refused
   admission triggers the same proof request and offers nothing. Back online, an
   existing offer is withdrawn first and returns only with a fresh read of the
   latest content. A tab returning from hidden or frozen (`visibilitychange`,
   `pageshow`) does the same at once, without waiting for a timer. A live editor
   leaves the screen in that same turn, before the browser paints: its frame turns
   transparent and leaves the pointer and the accessibility tree, and keeps rendering
   for a capture. The engine keeps its focus, its selection and any cell being typed,
   so a return costs no typing position; a blur, `inert` or a read-only flip would
   lose them for good. A tab going hidden takes no keys, so it stores a local copy then,
   through the save queue, and that row is the draft before the return. A return with
   work not yet in a row asks for a local capture too, and nothing
   is sent until the read answers. Input during the check stays out of the journal, and
   a snapshot reaches it only if the engine's revision proves it older than that input,
   whatever order the messages arrive in. If the read confirms Edit, the typing carries on and is captured whatever
   the transport is doing. If Edit is lost instead, that capture is the draft and
   whatever was typed unseen is dropped. A capture that did not finish keeps the
   editor's buffer and reports a failed capture, as any retirement does.
   One loss remains, a stated limit: an engine export is not atomic with its request, so
   a key pressed while it runs lands in the snapshot, and no clean copy then exists.
   Losing work needs all three at once: the capture on hide never finished, a key
   arrived inside the export after the return, and Edit was lost during that check. The
   measured export on a thawed tab took up to about one second in Word. A return from
   the network has no hide: the row then covers everything up to the last idle autosave
   (300 ms), offline included, because capture does not need the transport, so only the
   last moments of continuous typing depend on the capture at the return.
   Only a read asked after the return shows it again, and every return
   starts a new generation that earlier answers cannot satisfy: an admission or
   reader refresh still running at a return neither reveals a frame nor restores
   an offer, and cannot cancel that read. An unclear answer, or a read with no
   answer within 10 seconds, leaves it off screen and asks again after 5 seconds,
   and after one second the page says it is checking access. A frozen editor frame leaves the same way, with
   its download, and a pending download is dropped; only a fresh read of the latest
   content restores them, and the buffer stays until read loss is proven. The
   60-second bound is detection, not an access condition.

An offline browser cannot learn of a revocation, so delivery has not happened
and no bound applies. It keeps the draft, and on reconnect it follows stage 3.
These bounds hold while the page's timers run; a browser may throttle a hidden
tab. Content already delivered cannot be recalled.

A Project tab has no stream. Its 15-second refresh delivers a 403 or 404, which
removes cached content and asks `draft-key` for proof. The browser pauses that
refresh in a hidden tab, so a return reads at once. Until a read asked after the
return answers, the source draft and the comment draft are off screen, in the same
turn. Returns are counted, not timed, so a clock cannot decide
which read answered one, and a read that fails or never settles is asked again after
10 seconds. These fields stay mounted, transparent and inert, and take no input until
then; focus and selection come back with that read, and after a second the page says
it is checking access.

A kept draft is offered only by the current right to its content, not by access to the
shared Page, and the same before a reload and after one. A person reduced to Read or
Comment keeps the draft sealed and is offered nothing, in Word, Sheet and Project; a
refused read or an unclear one offers nothing either, whatever `draft-key` says. The
draft comes back when a read says this person may edit that content again. An expired
session is not lost read: it keeps the draft and offers it to the same person after
sign-in.

Before an authentication transition unmounts an editor, admitted local writes
must drain. Failed capture, unavailable storage, and an over-limit Design draft
keep the live model mounted behind recovery UI and block navigation. Explicit
sign-out warns before deleting persistent recovery copies and deletes only the
revisions covered by that confirmation, plus the exact revision this tab's own
suspended native editor made from one of them. Later writes remain for recovery,
including another tab's. A retiring editor's capture keeps the root revision of its
chain, and a real edit starts a new chain, so confirming any revision confirms the
root. After the server acknowledges sign-out and before any cleanup, the browser
stores those roots beside the rows. A final capture reads them in its own transaction
and stores nothing for a confirmed root, and every provider mount erases what such a
tab left behind. Local storage holds the same roots from before that write until the
row exists, so a refused or timed-out write cannot leave a later capture standing: a
final capture reads them too, and the next sweep moves them to the row. Each confirmation
has its own local storage key and is never rewritten. A move stores it, then removes
only that key, so a confirmation another tab adds at any moment stays, and the row keeps the newest time, so a
sign-in clears only what is older than its request. Only if both
stores refuse is there no record; the sign-out still completes. Only a sign-in the server accepted clears that person's intent, and only if the intent is older than that sign-in's request; a session answer or a signed-out mount does not,
since another tab may still be retiring. The sign-out broadcast only makes tabs look
sooner. An unsaved edit the editor never stored before retiring is part of its
capture, and goes with it.
Recovery does not replay native snapshot saves, custody operations or agent
dispatch. Design resumes standard CRDT synchronization only after fresh
same-person Edit admission; read-only viewers do not restore a writer canvas.

### Design canvases (opt-in)

Design uses the same Page identity, grants and retained artifact versions. The
server `CREWSHIP_ARTIFACTS_DESIGN_ENABLED` flag and browser
`NEXT_PUBLIC_ARTIFACTS_DESIGN` flag are disabled by default. The server also
accepts the workspace-scoped `artifacts_design` feature flag. The embedded Yjs
engine uses an existing Node runtime; no additional service is started.
The browser uses standard Yjs sync and awareness; frame, rectangle, text and
image nodes have stable IDs. Live operations also work without an open browser.
Cookie-authenticated collaboration refreshes credentials before each connection
and replaces only its websocket every five minutes, preserving the Yjs document
and unsaved draft. Fresh metadata must confirm the same actor, immutable Page,
Edit capability and opaque authority revision. Browser websocket admission sends
that `authority_revision`; the host rejects a mismatch before upgrading, including
a revoke-and-regrant gap. Revocation never restores an earlier writer draft.
The authority revision is scoped to the authenticated person and this Design
Page: it is built from their `user` and `page` rows in
`mooring_authority_generations` and their row for this Page in
`mooring_page_grant_generations` (installed on a fresh admission if absent),
the same generations native Craft sessions use. A change whose recipients
include that person or that Page (their grants on this Page, crew or workspace
membership, folder ACL, account status, or the Page's owner, audience or
folder) closes an open Design websocket, including a revoke-then-restore
cycle. A grant on another Page, even one naming everyone or their crew, does
not (#5123). The broad
workspace delivery epoch is not part of it, so creating another Page, changing
a grant for someone else on another Page or adding a member does not close an
open canvas or drop its unconfirmed edits. Current Page rights, membership and
the credential are still checked live on every frame and heartbeat.
Credential renewal does not replay an HTTP snapshot or mutation.
Human Design creation accepts the optional create-only `created_by_test` boolean
and retains it in Page catalog metadata, just like other artifact creation.

The authenticated CLI surface is:

```bash theme={null}
crewship page design create checkout --name "Checkout"
crewship page design get checkout
crewship page design apply checkout --file changes.json
crewship page design snapshot checkout --message "Added cart"
crewship page design versions checkout
crewship page design restore checkout --version 1
```

`changes.json` contains an `operations` array and optional `message`. An upsert
changes supplied fields of a stable node; new nodes need complete geometry:

```json theme={null}
{"operations":[{"op":"upsert","node":{"id":"cart","type":"frame","name":"Cart","x":20,"y":20,"width":390,"height":844,"fill":"#ffffff"}}]}
```

Delete uses `{"op":"delete","id":"cart"}`; reorder uses
`{"op":"reorder","order":["cart"]}`. Keep existing IDs when changing colors,
text or geometry. A snapshot creates an ordinary retained artifact version;
restore creates a new version and preserves history. The CLI reads at most
1 MiB from `--file`; the HTTP route accepts up to 4 MiB.

Persistence has two layers. The live Yjs head (at most 4 MiB encoded) is
written on every accepted websocket update and REST operation. Each `apply` and
`restore` also commits a retained version. Browser edits become a retained
version through `snapshot` or through an automatic `Automatic Design checkpoint`
snapshot, taken while an editor's websocket is open once the live head has been
idle for about 10 seconds. Engine limits (500 operations per request, 2,000
nodes, 4,000 order entries, unknown node fields) answer 400; history quota
answers 413 and keeps existing versions. A websocket update whose merge leaves
an order entry for a layer that no longer exists (a reorder concurrent with a
delete of that layer or its screen) is accepted, and the engine drops the
entry in the broadcast update; a REST `reorder` naming a missing layer still
answers 400.

When saved history is full, the editor shows the refused checkpoint and offers
both a draft download (canvas and unsent feedback) and **Continue in a new
Design**. Continuing copies the current canvas, including pending local changes,
to a new Page with version 1. It does not prune or rewrite the original history.
The original tab and unsent feedback remain open; the confirmed copy opens by
an explicit link in a new tab. A failed or uncertain request keeps the original
canvas and feedback and is not automatically replayed, including after login
expiry. Creation is confirmed only by a 201 response and a version-1 receipt.

The existing human Design-create API accepts `source_page_id`, `expected_human`
and base64 `initial_state` together for this action. The decoded Yjs state is
bounded to 4 MiB. Current source Edit authority, the expected identity and target
Page-create authority are checked under the same writer transaction as the new
Page and initial version. After the final document insert, identity, creation
and source authority are checked again, followed by exact Page/document
acknowledgement. Failure rolls back the entire new Page and leaves the source
unchanged. Copying does not transfer comments or their anchors. The
normal empty-create and CLI behavior is unchanged.

The corresponding agent tools are `design_get`, `design_apply`,
`design_snapshot`, `design_versions` and `design_restore`. CLI invocations with a
live agent token use those same tools through the sidecar. The original live
run capability and exact Page mandate are forwarded to the host; an agent
cannot choose a human identity, workspace, alternate profile or server. A
visible tool never grants access. Creating a Design requires human authentication;
agents edit an explicitly selected existing Design. The host rejects calls when Design is
not enabled. A failed response is never automatically retried.
The agent tool's `slug` accepts a Page slug or the exact immutable Page ID
selected by that run's original human mandate, with or without `@`. This does
not permit selecting another Page. Tool arguments are closed objects of at most
1 MiB. When the run was triggered by a `--send-to-agent` comment, a successful
`design_apply` that commits a newer version answers that one bound comment task
with the reply "Updated the selected Design element."; snapshot and restore do
not.

Additional human CLI commands list or create anchored comments, pin a shared
version, and connect the owner's private chat:

```bash theme={null}
crewship page design comments checkout
crewship page design comment checkout --version 2 --node cart --text "Use brand colors"
crewship page design share checkout --version 2
crewship page design agent checkout --chat-id private-chat-id
crewship page design watch checkout --count 1
```

`watch` exposes unchanged standard Yjs frames as base64 NDJSON and stops after
`--count` binary frames (zero watches until interrupted). It uses the saved
human credential with the normal server-host guard; agent runs use the bounded
MCP read/apply tools. `get` returns the live canvas for an editor. Read and
Comment users receive only the shared immutable snapshot, including its version
number; before a version is shared, content reads return 404. Their websocket
can synchronize that shared snapshot and presence but receives no private live
updates. Comment users anchor feedback on the shared version. `versions` also
stays at that shared pin for readers. Sharing never grants edit rights.

Workspace ADMIN and OWNER may read and edit every Design, including another
human's unshared or private Design. A read-scoped credential keeps full manager
read and history access but cannot mutate or send websocket updates. Normal
Read/Comment audiences still receive only the shared snapshot.

Access to a foreign Design is recorded as `design.workspace_admin_accessed` in
the existing audit log, bound to the Page, workspace, original human, actor and
custodian. The compliance row and configured keyed journal share one identity
and commit together under the writer fence. Permission probes verify the
original native journal receipt rather than appending duplicates; notifications
follow commit. One HTTP request or websocket admission has one audit identity,
and incoming websocket writes receive their own identity.

This records authorized read/write access, not a saved-version receipt. REST
versioned changes can commit a preflight access receipt before publication;
that receipt remains when later publication is refused. The publication
transaction rechecks both receipts and current authority after the final
content write. Unacknowledged or corrupted receipts refuse access and preserve
confirmed content. Manager edits
use the existing room lock, revision checks and VersionStore. This policy is
local to Design and does not alter other Page families' audience rules.

### Browser-local draft key recovery

`POST /api/v1/pages/{slug}/draft-key` accepts only an explicit immutable
`@<page-id>` reference, a current human credential with `pages:read`, and the
current workspace. It uses the canonical per-Page read model, including
workspace administrators. Restricted members may use only the existing
exact private-custodian boundary. No document bytes or server rows are created.

`{"human":"<expected-human-id>","operation":"wrap","key":"<base64 32-byte data key>"}` returns
`{"envelope":"<versioned encrypted key>"}`. The encrypted payload contains an
explicit domain, original human ID, workspace ID and Page ID. A missing server
master key refuses protection; the plaintext-secrets escape hatch does not apply.
`{"human":"<expected-human-id>","operation":"unwrap","envelope":"..."}` returns `{"key":"..."}` only to
that original human after a fresh current-read check under the SQL writer fence.
All responses use `Cache-Control: no-store`. A stale client identity is refused
before creating a key envelope; an account switch cannot rebind the draft.
Slugs cannot alias an old draft.

`{"human":"<expected-human-id>","operation":"check"}` returns `{"readable":true}`
or the same refusal, judged by the same canonical Page read. It handles no key.

A 403 with `code: "draft_read_revoked"` is positive evidence for deleting that
Page's local draft. `draft_identity_mismatch`, generic 403, 400 (unavailable or
invalid envelope), 401 (sign in again), 500 and 503 do not authorize deletion.
An offline browser cannot immediately learn revocation. This endpoint is the
key-protection primitive; retention, sealing and explicit sign-out confirmation
in the browser are separate integration work, not established by this route.

Browser key envelopes are external to the server database. Keep their master-key
versions resolvable while those drafts may need recovery; a successful database
re-encryption walk does not migrate browser envelopes or prove old versions safe
to remove. See [encryption rotation](https://docs.crewship.ai/security/encryption.md).

The matching CLI is `crewship page draft-key @PAGE_ID --file -` (or a local
JSON file). Input is `{human,operation:"wrap",key}` or
`{human,operation:"unwrap",envelope}`. Key material belongs in stdin/a protected
file, not shell arguments. Output contains sensitive key material. Agent runs
cannot use a saved human profile for this command.

For draft-key recovery only, workspace resolution does not pre-empt the fenced
membership check: a current credential for the expected human with removed
membership receives `403 draft_read_revoked`. ACL/storage faults return 5xx and
retain drafts. Workspace administrators use the canonical Page read policy;
the envelope still belongs to its original human and cannot cross identities.

Legacy plaintext recovery copies do not qualify for encrypted retention. Before
admitting a different identity (or completing expiry/sign-out), the browser drains
mounted writers and removes reachable unsealed rows belonging to other identities
from both IndexedDB journals and legacy Design/comment localStorage keys. Sealed
rows remain. Cleanup uses strict IndexedDB transactions; a failed cleanup blocks
identity admission behind the retry curtain. A legacy copy for the current human
migrates durably before entering an editor. Explicit sign-out counts legacy
localStorage copies in its discard warning; cancel leaves those copies intact.

Rollout order for encrypted draft retention is mandatory: deploy the backend
`draft-key` route first, including current membership and Design administrator
checks, then the durable artifact-draft base, then the complete browser migration.
The browser migration and all its writer/auth changes are one release unit.
A binary that embeds the frontend may ship these prerequisites and the browser
unit atomically; separate frontend upgrades must follow the backend.
Deploying that browser unit against an older backend is unsupported and prevents
editing; it must not be rolled out independently. Keep the backend route present
when rolling back browser code. Release integration must exercise this order and
fresh same-human recovery; unit/browser fixtures alone are not deployment proof.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.