# API reference

One public `/v1` API powers the Developer Console, CLI, Hub, and independent
Environments. No management workflow is private to the graphical Console.

## API planes

- **Catalog** is anonymous and exposes published Environments, Apps, Plugins,
  publishers, revisions, versions, compatibility, trust, and health.
- **Management** uses a secure Console session or scoped CLI/CI credential for
  projects, membership, CLI sessions, drafts, artifacts, deployments, tokens,
  rollback, and publication.
- **Runtime** creates an origin-bound standby session, atomically activates one
  fenced lease per principal, and requires that session/lease fence for
  presence, settings, App launch, notifications, and Plugin Grant requests.
  Backend components can introspect a Grant's current lease state.
- **Device** admits the enrolled Desktop Shell with a signed device assertion
  for account-complete control data such as the News Center. No App or
  Environment bearer can use this plane.
- **OIDC** implements Authorization Code with PKCE for registered public
  Environment and CLI clients.

Public App discovery uses `GET /v1/catalog/apps` and `GET
/v1/catalog/apps/{publisher}/{slug}`. The detail response binds the catalog card
to the exact active published `AppRevision`. These anonymous reads do not assign
the App to a Runtime or mint launch authority; effective availability and launch
remain under the authenticated Runtime App API.

Runtime lifecycle endpoints are `POST /v1/runtime/session`, `GET
/v1/runtime/session`, `POST /v1/runtime/session/activate`, and `POST
/v1/runtime/session/release`. Browser Runtime calls always send `Origin`.
Session-bound calls also send `X-Al-Amr-Runtime-Session`; active protected
calls additionally send `X-Al-Amr-Runtime-Lease` and
`X-Al-Amr-Runtime-Lease-Version`. These requirements are declared on each
operation in OpenAPI.

`POST /v1/runtime/control-token` is the only _read_ endpoint intentionally
available to a valid standby or superseded session. It returns a short-lived,
origin-bound, read-only WebSocket credential with no lease claims, solely for
receiving activity and browser-wide sign-out events. The control channel
rejects every client message and cannot be used for Presence or Plugin APIs.

`POST /v1/runtime/session/activate` also accepts a superseded session, and must:
reclaiming the Runtime after another tab took it is the one thing a superseded
session is _for_. What bounds it is a quota rather than a refusal — an account
may evict its own live Runtime a limited number of times per minute, and the
limit binds to the private principal, because the contended resource is one row
per account in `runtime_leases` and not one per session or per Environment.
Exceeding it returns `429` before the coordination store is touched.

Key Runtime authorization, account-policy, fencing, and coordination failures
use the flat RFC 7807-compatible `application/problem+json` contract. Its
stable codes are `runtime_token_invalid`, `runtime_authorization_expired`,
`account_required`, `runtime_session_standby`,
`runtime_session_superseded`, `runtime_lease_stale`, and
`runtime_coordination_unavailable`. A stale or superseded Runtime returns
`409`; a coordination outage returns `503` rather than admitting anybody
unnamed.
General validation and catalog/management failures retain the nested
`application/json` `ApiError` contract.

Environment entry has no mode and no policy: every authorization requires an
account, so `entry.access` and the `auto | login | guest` parameter are both
gone rather than defaulted. Browser
SSO logout revokes correlated Runtime and Console sessions for that browser;
CLI and CI credentials are separate.

Runtime session responses include `environmentRoles` and
`environmentRoleCatalog`. `environmentRoles` holds the derived `"admin"` key
when the caller owns the Environment Project (scoped to that Environment,
never an ecosystem-wide account subject) followed by any platform-managed role
keys assigned to the caller whose scope is global or names that Environment.
`environmentRoleCatalog` lists every role that exists in the Environment — the
derived admin role plus each non-archived role definition visible there — with
`scope: "all" | "scoped"`. Role definitions and assignments are governed by
[ADR-0055](../../decisions/ADR-0055-platform-managed-environment-roles.md).

Authenticated Management `ProjectView` contains a private `owner` summary and
the caller's `currentRole`: `owner | admin | developer | viewer`. Environment
Project owners also report the resulting Runtime admin role. Project
administrators are management delegates; they do not automatically become an
Environment Runtime administrator. Anonymous catalog responses show Publisher
attribution instead of personal ownership.

### Project membership

Membership uses `GET` and `POST /v1/projects/{projectId}/members`, `PUT
/v1/projects/{projectId}/members/{memberId}`, and `POST
/v1/projects/{projectId}/members/{memberId}/remove`. Ownership transfer uses
`POST /v1/projects/{projectId}/ownership/transfer` with an existing non-owner
membership ID, the old owner's retained `admin | developer | viewer` role, and
an audited reason. Responses expose a Project
member ID, display name, role, current-user relation, and timestamps; they do
not expose a raw account subject or reusable email address.

The owner may perform every Project operation. An admin may manage developers
and viewers, but cannot grant, change, or remove owner/admin authority. A
developer may mutate drafts and drive eligible publication work but cannot
manage members or credentials. A viewer is read-only. Every membership change
requires a reason and creates an audit record. Project tokens cannot change
membership or mint/revoke other Project tokens. Only the current authoritative
owner—not a Project admin, platform reviewer, or Project token—may transfer
ownership. Registry demotes the old owner, promotes the target, updates
`owner_user_id`, and writes `project_owner.transfer` in one database transaction.
For an Environment Project, subsequent Runtime and Environment-backend grants
derive `admin` from the new owner; already-issued short-lived grants remain
bounded by their normal expiry and live-introspection rules.

### CLI management sessions

`GET /v1/auth/management-sessions` lists the caller's active CLI OAuth grants;
`DELETE /v1/auth/management-sessions/{sessionId}` revokes one exact owned
session and its access/refresh material. Cookie callers require CSRF. The
response never includes access tokens, refresh tokens, authorization codes, or
raw account subjects. Browser SSO sessions and project tokens are separate
credential classes and do not appear in this list.

### Platform administration

Registry administrators manage the account directory and platform-managed
roles through `GET /v1/admin/users` (search plus cursor pagination) and `GET
/v1/admin/users/{userId}` (profile, role assignments, project attribution),
and through `GET|POST /v1/admin/roles`, `GET|PATCH
/v1/admin/roles/{roleId}`, `POST /v1/admin/roles/{roleId}/archive`, `GET
/v1/admin/roles/{roleId}/assignments`, and `PUT|DELETE
/v1/admin/roles/{roleId}/assignments/{userId}`. Every route requires a console
or management identity whose account holds platform admin; Project tokens are
rejected with `admin_required`.

`PUT /v1/admin/users/{userId}/platform-admin` accepts `{ reason }` and grants
platform-administrator authority to an existing account. Only an existing
platform administrator using a CSRF-protected console session may call it;
Project tokens are rejected. The user mutation and `platform.admin.grant`
audit event commit atomically, and repeating the request for an existing admin
is idempotent.

Role keys are immutable kebab-case identifiers and the derived
Environment-owner key `admin` is reserved. Scope is explicit:
`scopeMode: "all"` applies to every Environment — including ones created
later — while `scopeMode: "selected"` applies only to the listed Environment
ids; an empty list is never a hidden global. An assignment may carry an
`environmentIds` subset of its role's scope (any subset inside a global
role), and an empty subset covers the role's full scope. Mutations enforce
the Runtime fences (63 active definitions, 16 assignments per account) with
`role_quota_exceeded` and `role_assignment_quota_exceeded`, role updates fence
concurrent edits with `expectedUpdatedAt` (`role_version_conflict`), and
every mutation commits atomically with its audit record.

Reviewer authority is deliberately separate from both these Runtime roles and
platform admin. `GET /v1/admin/reviewer-authorities` lists active delegated
grants. `PUT /v1/admin/reviewer-authorities/{userId}` and `DELETE` on the same
path accept `{ reason }`; they require a platform administrator, reject Project
tokens, and atomically store actor, target, reason, and grant/revocation history
with the audit event. Admin users can inspect the queue, but running the review
host requires an explicit, revocable reviewer grant, including for an admin.
An administrator may explicitly grant that authority to themselves. Desktop
management admission requires the server-issued `reviewer:admin` scope. An active delegated
reviewer may use review-work-item and decision endpoints but cannot administer
users, access Projects, publish, or perform Hub placement.

The standalone Admin Panel app (`platform/admin-panel`, OIDC client
`al-amr-admin-panel`) is the reviewed graphical surface for these workflows;
they are equally available through `alamr admin users` and `alamr admin
roles`, never through a console alone.

### Hub placements

`GET /v1/hubs/{hubEnvironmentId}/zones/{zoneId}/placements` is the anonymous
Hub read model. It returns the authored slot ID, label, curation mode, order,
and derived `open | occupied | reserved` state. It includes
`destinationEnvironmentId` only for an eligible `occupied` slot.

`GET /v1/admin/hubs/{hubEnvironmentId}/zones/{zoneId}/placements` requires a
platform administrator and additionally exposes the stored assignment,
updating actor, and update time for diagnosis. `PUT
/v1/admin/hubs/{hubEnvironmentId}/zones/{zoneId}/placements/{slotId}` accepts
`{ destinationEnvironmentId, reason }` to set or replace; `DELETE` on the same
path accepts `{ reason }` to clear. Project tokens are rejected. The reason is
3–1000 characters, fixed slots are immutable, Hub cannot target itself, and a
destination must be unique inside the Hub and resolve to an active published
public catalog Environment. Mutation and audit commit atomically.

Admin Panel `/placements` and `alamr admin hubs` use this same administrator
API. See [Hub placement operations](/docs/publish/hub-placement) for the
operator workflow and fail-closed behavior.

### Server-directed publication lifecycle

Every `PublicationSubmission` contains caller-specific `allowedActions`. Each
entry declares an action ID, target status, and whether a reason is required.
Manage and CLI must render or execute only these server-provided actions rather
than reconstructing transitions from the current status or client-side admin
flags. Dedicated checks, submit, and review endpoints remain separate from the
generic transition route; all transitions preserve the frozen target and
expected current state.

Review Workflow v2 makes human ownership and the final publish boundary
durable. `GET /v1/admin/review-work-items/{submissionId}/claim` returns the
current owner and optimistic version. An authorized reviewer claims it with `POST
/v1/admin/review-work-items/{submissionId}/claim` and the assignment's
`expectedVersion`; `DELETE` on the same path releases it.
For live cases, claim and release also atomically update the exact current
revision's host assignment with the same owner and version. A fresh claim leases
it for 30 minutes. Reads and same-owner retries never renew that lease, and even
same-owner retries require the current version. Release then claim to renew it;
release of an older claim lacking host authority only records a released row.
Only the current
assignee may call `POST /v1/admin/review-work-items/{submissionId}/decision`.
That request binds the assignment version plus both frozen digests. Rejection
requires a category, summary, and at least one actionable remediation item.

Closed decision history remains readable at `GET
/v1/admin/review-cases/{submissionId}/history`. Structured automation evidence
is available at `GET /v1/admin/review-work-items/{submissionId}/evidence`.
Unavailable diff baselines, unconfigured AI analysis, and absent behavioral
evidence are explicit states; Registry never manufactures a finding or treats
missing automation as a pass.

A review-time Plugin Lab runner submits behavioral evidence with `POST
/v1/admin/review-work-items/{submissionId}/evidence/behavioral`
(`ReviewBehavioralEvidenceSubmissionRequest`), under reviewer authority. The
request binds both expected digests and a `ReviewBehavioralReceipt` whose own
digests and submission ID must match the stored submission; a mismatch is
refused, never stored. Storage is insert-only and content-addressed by the
sha256 of the receipt's canonical JSON, so replaying the same run returns the
existing row (`200`) instead of creating a second one (`201`). The latest
receipt is served back through the evidence read's required `behavioral`
member, and its presence is surfaced on plugin-release work items as
`behavioralEvidence`. Behavioral evidence is non-decisional: findings take the
existing advisory shape and never record an approval (ADR-0081).

`ReviewBehavioralReceipt` is a backward-compatible union. Schema version 1 is
the immutable legacy browser/Playwright receipt. New runs emit schema version 2:
the frozen Plugin is mounted in a delivered Plugin Lab bundle inside the
desktop client, and the receipt records the Desktop host, rendering lane,
Electron version and desktop-client version. Both versions stay readable;
producers must not relabel old browser evidence as Desktop evidence
(ADR-0099).

Approval atomically creates one `PublicationHandoff` bound to the approval
review and exact digests. Platform administrators list and inspect handoffs at
`GET /v1/admin/publication-handoffs` and `GET
/v1/admin/publication-handoffs/{handoffId}`. A second administrator—never the
approver—claims it with the optimistic version, then calls `POST
/v1/admin/publication-handoffs/{handoffId}/publish` with the claimed version and
both exact digests. Publication and handoff completion commit atomically;
retries return the completed handoff without publishing twice.

In non-production local development only, the desktop client may open the
newest built loopback draft before publication. This copy is not catalog
discoverable; exact bundle identity, the platform-assigned origin, bridge
admission, and Runtime fencing still apply. A draft is not a publisher-hosted
OAuth client and does not publish a product handshake.

## App Runtime and Host authority

`GET /v1/runtime/apps` lists revisions available to the current Runtime; it
does not mint launch authority. Before launch the Widget calls
`POST /v1/runtime/apps/capabilities/assess`. A `consent_required` response
contains a short-lived challenge, immutable assessment digest, bounded
required/optional disclosures, and a `ceremonyUrl`. The decision is taken on
that URL, on the Registry's own origin, and there is no other way to record
one: a decision posted from inside the Environment's document is a decision the
Environment could have posted itself.

A decision binds to exactly one Environment. Consenting to an App inside one
Environment grants it nothing in another Environment context, even for the
same account and the same App revision, and the person is asked again on the
first visit there.

A connected App can revisit exactly one optional capability declared by its
immutable manifest through `POST /v1/apps/capabilities/request`, using its
App-session bearer. A successful response is either `already_granted` or
contains a short-lived Registry-origin `ceremonyUrl`; the route cannot record a
decision and rejects required or undeclared capabilities.

`POST /v1/runtime/apps/launch` accepts the exact held App revision, a PKCE
challenge generated inside the host, bridge nonce, display mode, host
generation, and assessed capability-set digest. Registry rechecks the active
Runtime lease, publication, platform-assigned bundle origin, compatibility,
Environment Host Service support, and consent before returning a one-time code.

The SDK running at the platform-assigned App origin exchanges that code at
`POST /v1/apps/session/exchange`, directly for a frontend-only App or through
the App's own backend when its manifest declares one. The short-lived response
is App-audience authority containing pairwise App identity, exact source and
parent revision, display mode, host generation, permission-set digest, and
only granted optional data. `POST /v1/apps/session/introspect` is for the App's
own backend and fails closed when the parent Runtime authority is no longer
current.

Host Service requests do not pass through an HTTP Runtime endpoint. The desktop
AppHost transfers a generation-bound channel through its narrow preload bridge
and Main owns the protocol session; the browser-only adapter may represent that
channel as a `MessagePort`. Registry remains the authority for capability
assessment, while the Environment remains the authority for actual scene
context and navigation.

Cross-Environment launch is a deep link, `alamr://environments/{environmentId}`,
which the operating system hands to the desktop client. It names a world and
carries nothing else.

There was a Registry route here, `GET /v1/runtime/launch/{environmentId}`. It
read the destination's `entry.url`, appended `al_amr_return_target`,
`al_amr_focus_location` and a lab-validated `al_amr_try_plugin`, and 302'd a
browser at it. `ADR-0094` leaves it nothing to redirect: a world is bytes, its
address is `alamr-env://{environmentId}`, and no browser resolves that scheme.
`focus_location` and the try-it spec travelled as query parameters on that
redirect and do not travel on a deep link; the catalog's «try it» affordance
now opens the lab Environment rather than the lab Environment with a release
preloaded, and `ADR-0082`'s eligibility decision is unchanged.

## Notification Runtime and workload APIs

The V1 App write is an immutable Occurrence reduced onto a current Subject.
`POST /v1/apps/notifications/signal` uses the App-session bearer and accepts the
strict `NotificationOccurrenceSignalRequest`:

```json
{
  "eventId": "edit_01J9Y7K6",
  "eventType": "document.changed",
  "occurredAt": "2026-09-04T10:42:17.000Z",
  "effect": "raise",
  "subjectKey": "document:doc_42",
  "revision": 81,
  "content": {
    "kind": "source",
    "title": "Document updated",
    "action": { "kind": "open_app" }
  }
}
```

The exact immutable source revision must declare reviewed `policy` for that
event: closed `family`, `keyed`/`source_monotonic` or
`per_occurrence`/`event_identity` Subject policy, allowed effects, stable
`preferenceKey`, `new_attention | none` Pulse policy, and `source` or named
`platform_template` presentation. A keyed event supplies both `subjectKey` and
a source-domain monotonic `revision`; a per-occurrence event supplies neither.
The source must retry with the same `eventId`. Duplicate and stale revisions are
accepted no-ops. `occurredAt` is required and immutable across retries;
Registry records receipt time independently. The same-account foreground route
refuses occurrences older than 30 days or over five minutes in the future; the
delegated route preserves its accepted-only envelope and reduces them to
content-free no-ops. A multi-recipient source derives a stable
recipient-scoped opaque `subjectKey` rather than exposing a shared raw domain
identifier.

Source-authored content has the default `file` ceiling even for first-party
Apps. The reviewed first-party `message_waiting` platform template accepts only
an optional App-pairwise `actorSub`; Registry composes the title from the
recipient's own roster and supplies `open_app`. A source cannot select template
text, preview, category, sound, or treatment. The foreground signal returns
`202 { "accepted": true }` without treatment or recipient state.
The public App client exposes the same call as
`appClient.signalNotificationOccurrence(occurrence)`.

An App acknowledges only source content it has actually presented through a
known revision:

```http
POST /v1/apps/notifications/acknowledge
```

```json
{
  "acknowledgements": [
    {
      "eventType": "document.changed",
      "subjectKey": "document:doc_42",
      "throughRevision": 81
    }
  ]
}
```

The request contains 1–100 unique entries and returns only
`202 { "accepted": true }`. Registry stores the maximum watermark even before
the Subject exists. Opening the App or clicking a notification is not an
acknowledgement.

An account App that declares delegated background delivery creates and revokes
its current user's opaque credential through:

- `POST /v1/apps/notifications/delegation`;
- `DELETE /v1/apps/notifications/delegation`.

The plaintext delegation is returned only at creation. An App backend combines
it with an expiring Project token scoped to `notifications:publish` and signals
through `POST /v1/workloads/apps/notifications/signal`. Invalid workload
credentials, missing caller scope, and malformed requests may be refused.
After those outer facts are admitted, every recipient-dependent outcome is the
same `202 { "accepted": true }`; no count, recipient existence, consent,
treatment, or read state is disclosed. `@al-amr/backend` exposes this operation
as `createAppNotificationWorkloadClient(...).signal(delegation, occurrence)`.

The Desktop Shell and Environment Widget use different reads:

- device plane: `GET /v1/account/notifications`, plus `POST` to
  `/v1/account/notifications/read` and `/dismiss`, for the account-complete
  Center;
- active Runtime: `GET /v1/runtime/notifications`, plus `POST` to
  `/v1/runtime/notifications/read` and `/dismiss`, narrowed to the current
  Environment.

Both list endpoints use opaque keyset `cursor` pagination with a bounded
`limit`. `nextCursor` is present only when another page may exist. Dismissed
rows are absent from the normal feed; dismissal spends the current Attention
episode, while a newer reviewed `raise` may reopen the Subject. The list count
is independent of the current page.

The response distinguishes `unseenCount` (all active unseen Subjects, including
`mute`) from `attentionCount` (unseen attention-bearing Subjects, excluding
`mute`). The legacy required `unreadCount` is an alias of `attentionCount`.
V1 Subject rows may add `projectionVersion` and `subjectRevision`; the Inbox
schema is tolerant so immutable Widget readers can ignore them. New read and
dismiss requests send a `projectionVersions` map keyed by `notificationId` so a
stale action cannot consume a newer Subject projection. The optional `raisedAt`
map remains the timestamp fallback for immutable readers.

Presence and account-control events are content-free invalidation hints. They
never replace the authorized list read, and list/reconnect reconciliation never
reconstructs a transient Pulse. The Shell receives account-complete News; an
Environment receives only its narrowed rows and the three-word
`none | waiting | urgent` attention projection, never account content or a
count.

The original publish routes remain additive legacy APIs:

- `POST /v1/runtime/notifications/publish` for an active Environment;
- `POST /v1/apps/notifications/publish` for an open environment-hosted App;
- `POST /v1/workloads/apps/notifications/publish` for a delegated App backend;
- `POST /v1/workloads/environments/notifications/publish` with a short-lived
  exact-endpoint Environment Backend Grant carrying
  `notifications.publish@1.0.0`.

Legacy payloads remain bounded plain text with closed semantic actions,
`category`, TTL, and `dedupeKey`; `category` grants no family, sound, or Pulse
authority. Legacy writes do not gain V1 Subject revision/watermark semantics.
`POST /v1/apps/notifications/read` remains the legacy source-wide acknowledgement
for immutable callers.

These endpoints create durable News only. A deadline-bound live `CallAlert` is
a separate device-plane projection, and external push, email, and SMS are not
implemented by the V1 notification API.

## Environment backend Grants

`POST /v1/runtime/environment-backend/grants` exchanges an active Environment
Runtime for authority to the exact backend declared by its immutable revision.
The request contains only declared scopes and context; Registry derives actor,
origin, Environment roles, endpoint and Runtime fence. Backends call
`POST /v1/runtime/environment-backend/grants/introspect` to fail closed after
logout, expiry, revocation or Runtime supersession.

These endpoints do not store conference events, organizer assignments, or any
other Environment domain record. That state belongs to the Environment
publisher's backend.

## Runtime settings

`GET /v1/runtime/plugins/{pluginId}/settings` resolves objects recursively in
the documented precedence order. Its `layers` object returns the exact
`plugin_default`, `environment`, `user`, and `user_environment` inputs; an
inherited value is never copied into a persisted user layer. The parallel
`provenance` tree identifies the winning scope for every resolved leaf.
Persisted leaves that no longer satisfy the installed release's schema are
excluded fail-safely instead of being delivered to Plugin code.

The response body `etag` and HTTP `ETag` header are identical. Clients must use
that aggregate tag as `If-Match` with `PUT` and `DELETE` so concurrent changes
in either editable layer fail with `412`. The tag includes a non-repeating
generation for each write, so reset-and-recreate cycles cannot reuse an old
precondition. Reset is idempotent: deleting an already-empty
layer with the current ETag still returns `204`. Guests may resolve settings,
but persistent `user` and `user_environment` writes require an account.

The generated OpenAPI document is published by the Registry at
[`https://registry.al-amr.com/v1/openapi.json`](https://registry.al-amr.com/v1/openapi.json).
Errors have stable machine codes and request IDs. CLI automation should combine
`--json` with documented process exit codes.

## Package artifacts

External npm registration accepts only the package name. The Registry derives
the version from the immutable Plugin release and resolves the registry URL,
tarball URL, and SHA-512 itself:

```json
{ "provider": "npm", "packageName": "@publisher/plugin" }
```

After validation, Registry mirrors those exact bytes into the Al-Amr
content-addressed store. The published installation URL therefore stays bound
to the reviewed SHA-512 even if upstream npm metadata changes later.

Managed artifacts use the binary `/artifacts/managed` upload. Before
publication, an owner may issue a 60–900 second private URL through
`POST /v1/projects/{projectId}/artifacts/{artifactId}/draft-download-url`.
Cookie sessions require CSRF and a project token requires `project:read`.
Published managed artifacts remain anonymous, immutable, and publication-gated.

The CLI keeps managed upload as the default. Use `alamr test --provider npm`
followed by `alamr publish --provider npm` for a package already published to
the configured public npm registry.

## Owner presentation media

Desktop Main reads an exact submission's managed image through
`GET /v1/projects/{projectId}/publication-submissions/{submissionId}/presentation-media`
and its `/bytes` endpoint. This includes App icons, Environment covers, and Plugin
icons. Both routes require an enrolled Desktop management session with
`publication:read-own` and durable membership in the submission's project;
platform administrator status alone does not grant access. Unpublished bytes
remain private and are not exposed through the public media route.

## CI credentials

Every project token requires an explicit `expiresAt` value in the future and
cannot live longer than 366 days. Console defaults new tokens to 30 days.
