# Signing and the path into the catalog

A release freezes the version, manifest, and bytes of an app and moves through the channels up to `stable`. This page covers what belongs to a release, how a version enters the catalog as a signed artifact, the rules of the channels, and the calls and commands with which platform operations manages the catalog.

> **Type:** reference
> **For:** Developers who want to know what happens to their app between build and display, and operators who manage releases.
> **Prerequisite:** none for reading. The calls under `/v1/platform/app-catalog/…` and the `app-release …` commands require an operator credential with the permissions `platform.app-catalog.list`, `import`, `promote`, `retire`, or `update`.

Each version comes into being exactly once: CI builds it from its tag as a signed, immutable artifact `<appId>-<version>.szapp` with the manifest, the bundles for the display software and the editor, images and texts for the store, and the release notes, and every environment fetches this one artifact from the registry. The same version therefore has the same bytes in every environment.

## What belongs to a release

In object storage, a version lives under `platform/apps/<appId>/<version>/`:

| Location | Content |
|---|---|
| `bundle.zip` | The artifact unchanged. Its sha256 is the digest of its layer in the registry. |
| `manifest.json` | The version’s manifest in canonical JSON — the catalog stores it embedded, and its hash is the `manifestHash`. |
| `runtime/` | The unpacked files of the runtime bundle that the player loads. |
| `authoring/` | The unpacked authoring bundle with the configuration form for the scene editor, if the app has one. |
| `store/` | Icon and images for the store. |

The release additionally carries `sizeBytes` and `sha256` (64 lowercase hexadecimal characters) of the stored bytes and the provenance: `source` (`registry` for an artifact from the registry, `seed-placeholder` for a row without bytes that the seed created without access to the registry), under `artifact` the hash of the index, the release key whose signature has been verified, the signature itself, and the digest in the registry, plus `importedAt` and `importedById`. Manifest and bytes are immutable after fetching, and the only fields that can be changed afterward are the notes. They live under `notes` per audience, namely `customer` (“New in this version”), `operator`, and `developer`, and per language (`de-DE`, `en-US`, …), as the artifact’s index brings them. In addition, every release still has the one older note, `releaseNotes` (max. 2000 characters): the text for operations in `de-DE`, otherwise the one for customers.

Devices and clients reach a version through four routes. Manifest and bundle require a device’s certificate or a session, and only the unpacked runtime is free of sign-in. The specification lists the same credentials under `security`, with the names from the **Credential** column:

| Route | Credential | Response |
|---|---|---|
| `GET /v1/apps/{appId}/{version}/manifest.json` | `deviceCertificate` or `sessionJwt` | The stored manifest. Its hash is the ETag. A matching `If-None-Match` yields `304`. |
| `GET /v1/apps/{appId}/{version}/bundle.zip` | `deviceCertificate` or `sessionJwt` | A redirect to a presigned URL — the bytes do not run through the API. For a version from the registry, it is the artifact. |
| `GET /v1/apps/{appId}/{version}/runtime/{path}` | none | A file of the unpacked runtime bundle, immutable and cacheable for a year. Through it, a display without an agent imports the entry file `index.js` and the parts it loads relatively (`./chunks/…`). A placeholder without bytes goes out with `no-cache`, since it still gets its bytes. An unknown version or file yields `404`. |
| `GET /v1/apps/{appId}/{version}/authoring/index.mjs` | `sessionJwt`, `apiKey`, or `oauthAccessToken` | A redirect to a presigned URL, for a version from the registry to the unpacked `authoring/index.js`. If the app declares no authoring bundle, the route answers with `404`. The portal’s scene editor loads the bundle while signed in. |

## The two catalogs across all apps

Besides the individual version, the API delivers two overviews of the **whole** catalog. Both take, for each app, the version that applies in the `stable` channel, the one that entered it most recently and not the one with the highest number, and break down its manifest: one into widget definitions, the other into resource types. The widget catalog additionally lists the six built-in building blocks (text, image, video, shape, graphic, document). They belong to no app and therefore have the reserved identifier `core`. What exists only in `canary` or `beta` appears in neither, and both answer regardless of what an organization has installed: they describe the offering, not the installed base.

| Route | What the response contains |
|---|---|
| `GET /v1/widget-catalog` | Under `entries`, one entry per widget with `appId`, `version`, and the complete `definition`: `id`, `name`, `description`, the `configSchema` as JSON Schema with field texts and examples (see [Configure widgets](/apps-sdk/configure-widgets)), `size`, `noResolver`, and the `preview` block with `defaultConfig`, `mockData`, `thumbnailUrl`, `variants`, and `states`. The two gate apps without a widget contribute nothing, and the six built-in building blocks are included under `appId: core`. |
| `GET /v1/resource-type-catalog` | Under `entries`, one entry per resource type with `appId`, `version`, and the `definition`: `id`, `slug`, `name`, `description`, `listEndpoint`, `authoringPath`, and the three schemas `baseSchema`, `createSchema`, and `updateSchema`. Only apps with `resources` in the manifest contribute — among the bundled ones, these are `calendar`, `menu-board`, and `pharmacies-on-duty`. |

Both require a credential but **no permission of their own**: a valid sign-in suffices, and an API key reaches them regardless of which permissions are in its `scope`.

```bash
curl -sS "$SZENERA_API/v1/widget-catalog" \
  -H "Authorization: Bearer $SZENERA_API_KEY"
```

Both responses are flat: a list in which an entry carries the definition and also says which version of which app it comes from.

```json
{
  "entries": [
    {
      "appId": "pharmacies-on-duty",
      "version": "2.0.1",
      "definition": { "id": "pharmacies-on-duty-display", "name": "Notdienst-Anzeige", "…": "…" }
    }
  ]
}
```

## Signature: the statement about the version

Signing happens in CI, never on a server: CI signs the statement “this app, this version, this manifest, this index” with the Ed25519 release key of each world (`app-release-staging` for development and staging, `app-release-prod` for production). When fetching, the API checks the signature of its own world against that world’s public key. Without a valid signature of its world, no version enters the catalog, and there is no fallback to the sha256 alone. Whoever fetches a release signs nothing.

On the device, remote maintenance checks every artifact before it unpacks anything: the sha256 value of the downloaded archive must match the one on the release, then it checks the signed statement against the anchor `app-release-<world>` in the image, after that the manifest and every file against the index, and it unpacks only `runtime/`, safeguarded against paths that lead out of the target folder. If the anchor is missing, it rejects every artifact. An archive without an index is not an artifact, and remote maintenance does not accept it. The `signing` entry in the manifest changes nothing about this and remains only because it appears in the manifests of published versions.

## Fetch a release

A release comes into being by importing an artifact from the registry. CI reports every version it has stored, to staging and production. The API does not list the versions of the registry: whoever fetches a version by hand names its version themselves.

`POST /v1/platform/app-catalog/imports` with the body `{ "appId": "…", "version": "…" }` fetches the version. Optionally, `digest` names the digest of the OCI manifest (`sha256:…`) that the caller expects, and CI always sends it. The API checks manifest and layer in the registry, then the archive: every file against its checksum in the index, the manifest against the schema and its hash, identifier and version, and the signature of its world. Only then does it store the bytes and create the release in the `canary` channel.

```bash
curl -X POST "$SZENERA_API/v1/platform/app-catalog/imports" \
  -H "Authorization: Bearer $SZENERA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "appId": "pharmacies-on-duty", "version": "1.3.0" }'
```

The response is `{ "release": …, "created": true }` with `201` if the version is new in the catalog. If it was already there with the same artifact, the call answers with `200` and `created: false`, so the call can be repeated. The import fills a placeholder without bytes that has the same manifest, while the row’s channel and the releases already made into channels remain. It replaces the seed’s note, which only describes the placeholder, with the notes from the index, unless someone has changed it.

In doing so, the import backfills the notes: if a version that it already finds in the catalog lacks an audience or language that its artifact’s index carries, it adds it and records that as `notes-updated` with its actor. That way, a version that entered the catalog before per-audience notes gets the texts for customers and developers. Existing texts remain, and the import does not touch notes that have been changed once. The API reads the index of a version in the catalog from its own object storage, so the version no longer has to be in the registry for that.

What can fail while fetching names the exact reason in `details.reason`:

| Code | HTTP | Trigger |
|---|---|---|
| `AppRelease.RegistryArtifactNotFound` | 404 | There is no tag under the version in the registry. |
| `AppRelease.VersionAlreadyExists` | 409 | The version is already here with a different artifact or as a placeholder with a different manifest. A version never gets second bytes. |
| `AppRelease.RegistryImportFailed` | 422 | The artifact failed the check: digest, type, or details of the manifest in the registry, checksums, structure of the archive, the app’s manifest, or the world’s signature. An environment that lacks the public key of its world also answers this way. |
| `AppRelease.RegistryUnavailable` | 503 | The environment has no access to the registry, or the registry does not respond. |

## The channels

A new release always starts in `canary`, and nobody chooses the starting channel. Each promotion moves exactly one step, and a jump is not possible. No single person promotes to `stable`: one person requests, a second confirms, also when restoring. The version that entered `stable` most recently is the one that applies there, not the one with the highest number.

| Channel | Label in the operator portal | Meaning |
|---|---|---|
| `canary` | **Canary** | Freshly fetched — first trial channel. |
| `beta` | **Beta** | Extended trial. |
| `stable` | **Stable** | The version recommended for organizations. |
| `deprecated` | **Deprecated** | Demoted but still usable. |
| `retired` | **Retired** | Out of service. End state with a retirement period. |

| Call | Body | Rule |
|---|---|---|
| `POST …/releases/{id}/promote` | `{ "toChannel": "beta" }`, to `stable` with `"reason": "…"` | `canary` → `beta` always promotes immediately. To `stable`, the call promotes immediately if the platform does not require a second person (`releaseApproval.apps` set to `off`, the default). If it requires one (`stable`), the call with `reason` (10–500 characters) creates a request, response `{ "approval": … }` instead of `{ "release": … }`, and without `reason` it answers `AppRelease.SecondPersonRequired` (HTTP 409). At most one request waits per version and operation (`ReleaseApproval.AlreadyOpen`, HTTP 409), and it is valid for seven days. Everything else: `Channel.InvalidTransition` (HTTP 409). |
| `POST …/releases/{id}/restore` | `{ "reason": "…" }`, required (10–500 characters) | Returns a version from `deprecated`, or a superseded one in `stable`, to `stable`, immediately or, if the platform requires a second person, as a request (response as with `promote`). For a restore, the version applies again afterward, even ahead of a higher one, and the one that applied until then moves to `deprecated`. |
| `POST /v1/platform/ops/approvals/{id}/approve` | — | Another person (permission `platform.ops.release.approve`) confirms and executes the operation in the same transaction, response `{ "approval": … }`. The requesting person themselves: `ReleaseApproval.SamePerson` (HTTP 403). Expired, confirmed, or withdrawn: `ReleaseApproval.NotOpen` (HTTP 409, `details.state`). The same route also confirms the requests for device software. |
| `POST /v1/platform/ops/approvals/{id}/withdraw` | `{ "reason": "…" }`, the reason is optional | The requesting person withdraws, and anyone else with the permission to approve rejects with it. The version stays in its channel. |
| `GET /v1/platform/ops/approvals` | Query `state`, `packageKind`, `package` | The requests of both families (apps and device software), without `state` the open ones, newest first. For apps `packageKind=app&package=<appId>`. |
| `DELETE …/releases/{id}/artifact` | — | Deletes the bytes of the version (permission `platform.app-catalog.delete`), the row, manifest, and history remain. A version in canary, beta, or stable, with an installation, a scene pin, or an open request is protected (`AppRelease.ArtifactInUse`, HTTP 409). A second call has no effect. |
| `POST …/apps/{appId}/releases/cleanup` | `{ "keepNewest": 5, "dryRun": true }` | Cleans up the bytes of all versions of the app beyond the newest `keepNewest` that can be deleted. Without `keepNewest`, the platform’s `retention.apps.keepNewest` applies, and with `dryRun` the call deletes nothing and names what it would delete. |
| `POST …/releases/{id}/demote` | `{ "toChannel": "deprecated" }` | The only backward step: `stable` → `deprecated`. |
| `POST …/releases/{id}/retire` | `{ "reason": "…", "grace": "regular" }`, for a vulnerability `{ "reason": "…", "grace": "security", "securityAdvisory": "…" }` | `reason` is required (1–500 characters), and `grace` is the choice of retirement period: `regular` three months, `security` one month, `immediate` none. `securityAdvisory` (up to 2000 characters) belongs exactly to `security` and is stored on the version afterward. The successor to which the platform switches after the period is recorded: the lowest stable version above the retired one. A second retirement: `AppRelease.AlreadyRetired` (HTTP 409). |
| `POST …/releases/{id}/extend-grace` | `{ "graceMonths": 12 }` or `{ "graceEndsAt": "2027-03-31T23:59:59.999Z" }` | Exactly one of the two. The months count from the current end of the period, not from now, and a date lies after the current end. |
| `POST …/releases/{id}/force-retire` | `{ "reason": "…" }` | Sets the end of the period to now. The reason is required (1–500 characters) and appears in the history. |
| `PATCH …/releases/{id}` | `{ "notes": { "customer": { "de-DE": "…", "en-US": "…" } } }` or `{ "releaseNotes": "…" }` | Changes only the notes. A named audience is replaced with all languages, and the others remain. `releaseNotes` sets the text for operations in `de-DE`. Locked for retired releases. |

Promoting to `beta`, promoting or requesting `stable`, and confirming a request each require a checklist with no blocking items: “New in this version” (`notes.customer['de-DE']`) is present, and no text for customers contains a semicolon or a blocked word. Otherwise the API answers `AppRelease.ReviewBlocked` (HTTP 409) and names the blocking items in `details.blocks`. The checklist does not block restoring and retiring.

The retirement period is a choice, not a keyword in the reason: a reason that contains `security` shortens nothing. For new installations, a retired release can no longer be chosen immediately. Installations on exactly this version get the successor as their target: if it requires nothing new, the platform adopts it immediately, otherwise the installation moves to **Update waiting** with the end of the retirement period as the deadline. When the period is over, the platform switches installations and scenes to the successor, even without consent, and notifies the organization. If there is no successor, everything stays on the version, and the version’s history records it (`no-successor`). With `immediate`, both happen in the same call. If the successor reaches `stable` only after the period has ended, the version gets a period of seven days (entry `grace-extended` with a reason), in which a successor that requires consent waits for the organization. After that, the platform switches.

All calls under `/v1/platform/app-catalog/…` carry the note “Operators only”. Reading requires `platform.app-catalog.list`, fetching requires `platform.app-catalog.import` (also with an operator key, which is how CI reports), promoting, requesting, restoring, and demoting require `platform.app-catalog.promote` (confirming and rejecting a request require `platform.ops.release.approve`), deleting and cleaning up require `platform.app-catalog.delete`, retiring and periods require `platform.app-catalog.retire`, and notes require `platform.app-catalog.update`.

## The history of an app

Every movement of a release is recorded as an entry in the history of its app: creation, fetching from the registry, every release into a channel, demoting, restoring, retiring, retirement periods, changed notes, and every step of a request to a second person (`promotion-requested`, `promotion-approved`, `promotion-withdrawn`, `promotion-expired`). If a second person confirmed, that person is the actor, and `requestedById` names the person who requested. Each entry names the channel from and to, the time, and the actor: a person in operations (`operator` with identifier), CI (`ci`), an environment’s seed (`seed`), the platform itself (`auto`), the zero hour (`zero-hour`), or `legacy` for what happened before the history and is reconstructed from the releases’ timestamps.

| Call | For whom | What it delivers |
|---|---|---|
| `GET /v1/platform/app-catalog/apps/{appId}/history` | Operations (`platform.app-catalog.list`) | every entry, newest first, in pages with `limit` and `cursor` |
| `GET /v1/apps/{appId}/history?locale=en-US` | every signed-in organization | the versions that have reached `stable`, newest first: identifier (`releaseId`, the same as on installation, proof of play, and update decision), number, since when it has been open to everyone (`stableSince`), “New in this version” in the requested language or, failing that, in German (`note`, `null` without text), and whether it is retired. For an app that operations has switched off for the organization, the call answers with `App.NotFound` (HTTP 404). |

## As CLI commands

The CLI maps the lifecycle after fetching in nine commands. It does not know fetching itself: the environment syncs with the registry every ten minutes, and CI speeds it up with the call above. The requests to a second person are run by the group `approval` (`list`, `approve`, `withdraw`).

| Command | Purpose |
|---|---|
| `app-release list` | The releases of the app catalog, filterable with `--app` and `--channel`. |
| `app-release set-notes` | Change release notes. |
| `app-release promote` | Promote to beta or stable with `--channel`. To stable, the command promotes immediately or, if the platform requires it, creates a request to a second person, and `--reason` is then required. |
| `app-release restore` | Restore a deprecated or superseded version to stable, `--reason` is required. Immediately or as a request, depending on the setting. |
| `app-release demote` | The way back when a stable release turned out not to be stable after all: `--channel=deprecated`, the only permitted step, from `stable`. |
| `app-release retire` | Take a release out of service. `--reason` and `--grace=regular\|security\|immediate` are required, and `--advisory` carries the security note and belongs exactly to `--grace=security`. |
| `app-release grace-period` | Extend the retirement period (`--months` or `--until=<yyyy-mm-dd>`) or end it immediately (`--immediately` with `--reason`), exactly one of these. |
| `app-release delete-artifact` | Delete the bytes of a version, `--yes` is required. |
| `app-release cleanup` | Clean up the bytes of superseded versions of an app, `--dry-run` or `--yes`, plus `--keep-newest`. |

```bash
szenera-ops --profile=dev-operator app-release promote arl_01K… --app=weather --channel=beta
szenera-ops --profile=dev-operator app-release promote arl_01K… --app=weather --channel=stable --reason="Drei Tage in beta ohne Meldung"
szenera-ops --profile=dev-operator approval approve rap_01K…
szenera-ops --profile=dev-operator app-release retire arl_01K… --app=weather --reason="Nachfolger da" --grace=regular
```

All of them belong to the platform operations tool `szenera-ops` and require an operator credential. The commands with a release position require `--app`, while `list` uses it as an optional filter. The release is the positional argument `id` (identifier `arl_…`). The complete register is at [Commands for platform operations](/cli/commands-platform-operations).

## What the organization sees from this

- **New stable version without new permissions and without changed terms:** Existing installations adopt it automatically, and the earlier decisions in the app’s **Update** tab show the entry **adopted silently**.
- **New stable version with new permissions or changed terms:** The installation moves to **Update waiting**, and the app’s widgets cannot be inserted in the scene editor until the organization accepts in the **Update** tab (see [Data access, permissions, and terms](/apps-sdk/data-access)).
- **Publishing a scene:** If a widget pins a version that does not exist, that is retired and whose retirement period is over, or if it belongs to an app that the organization has not installed, the platform rejects publishing and states the reason in one sentence. A retired version within an ongoing period is a notice.
- **Retired release:** No longer selectable for new installations. Whoever has installed exactly this version gets the successor, the lowest stable version above it. If it requires nothing new and the organization has not declined it, the platform adopts it immediately. Otherwise the installation moves to **Update waiting**, with the end of the retirement period as the deadline, and after that the platform switches. Without a successor, the app keeps running unchanged until the end of the period.

## What does not exist today

The catalog is purely curated: CI builds and signs every version, and platform operations fetches it into the catalog and steers its lifecycle. There is no developer submission, no review queue, and no statement about review times or criteria. The 26 bundled apps are created in the platform’s source tree, built from their tag, signed as an artifact, and fetched into every environment. Anyone preparing an app does so with platform operations, and the pages of this area describe the contract it is written against.

## Related

- [What is an app, and how does it get onto a display?](/apps-sdk/about-apps) — the lifecycle in a picture.
- [The manifest](/apps-sdk/manifest) — `signing` and the other fields.
- [Commands for platform operations](/cli/commands-platform-operations) — `app-release` and `app-status` in the `szenera-ops` tool.
