@ziprack/sdk 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ # The Manifest
2
+
3
+ - [Introduction](#introduction)
4
+ - [Core Fields](#core-fields)
5
+ - [The Requires Block](#the-requires-block)
6
+ - [Capability Sections](#capability-sections)
7
+ - [A Complete Example](#a-complete-example)
8
+ - [Validation Errors](#validation-errors)
9
+
10
+ <a name="introduction"></a>
11
+ ## Introduction
12
+
13
+ `manifest.json` sits at the root of the zip and is the applet's entire declaration of intent. It
14
+ names the applet, states which capabilities it calls, and describes the shape of everything the Rack
15
+ will need to validate, store, index, report on and render.
16
+
17
+ **Declaration is mandatory, not advisory.** A call to a capability the manifest did not declare is
18
+ refused at runtime with `undeclared_capability`. That is also what makes capability-diff review at
19
+ upload meaningful: the manifest is a contract a reviewer can read.
20
+
21
+ <a name="core-fields"></a>
22
+ ## Core Fields
23
+
24
+ ```json
25
+ {
26
+ "schema": "ziprack/1",
27
+ "slug": "my-applet",
28
+ "name": "My Applet",
29
+ "version": "1.0.0",
30
+ "entry": "index.html",
31
+ "requires": { "capabilities": [], "profile": [] },
32
+ "media": []
33
+ }
34
+ ```
35
+
36
+ | Field | Rules |
37
+ |---|---|
38
+ | `schema` | Required. Exactly `ziprack/1`. |
39
+ | `slug` | Required. `^[a-z0-9]+(-[a-z0-9]+)*$`, max 100. Becomes `/a/{slug}`. **Immutable.** |
40
+ | `name` | Required, max 255. Shown to participants and admins. |
41
+ | `version` | Required. `MAJOR.MINOR.PATCH` with optional `-prerelease`, max 50. Unique per applet. |
42
+ | `entry` | Optional, default `index.html`. Relative, no `..`, ends in `.html`. |
43
+ | `requires` | Required, even if empty. See below. |
44
+ | `media` | Optional. See [Media](media.md). |
45
+
46
+ Everything else at the top level belongs to a capability. A section whose owning capability is not
47
+ declared is a validation error, and so is a section no installed capability owns.
48
+
49
+ <a name="the-requires-block"></a>
50
+ ## The Requires Block
51
+
52
+ ```json
53
+ "requires": {
54
+ "capabilities": ["auth@1", "attempt@1", "activity@1", "ai@1"],
55
+ "profile": ["display_name", "business_unit", "locale"]
56
+ }
57
+ ```
58
+
59
+ - Each capability reference matches `^[a-z][a-z0-9_]*@\d+$`. Declaring a capability twice is an
60
+ error, and so is declaring one this Rack does not implement — the error names what it does.
61
+ - `profile` lists the attribute keys you want released, and requires `auth@1`. Keys match
62
+ `^[a-z][a-z0-9_]*$`. A key the deployment does not release is simply absent at launch;
63
+ [degrade gracefully](auth.md).
64
+ - Declaring `attempt@1` has a side effect: it pins every participant to the version they started on.
65
+ See [Versioning](versioning.md#version-pinning).
66
+
67
+ <a name="capability-sections"></a>
68
+ ## Capability Sections
69
+
70
+ | Section | Capability | Shape |
71
+ |---|---|---|
72
+ | `segments[]` | `attempt@1` | `{key, label, values[{key,label}]}` — dimensions the admin filters by |
73
+ | `activities[]` | `activity@1` | `{id, type, label, scale?, segment?, sequence?}` — required, at least one |
74
+ | `reporting.metrics[]` | `activity@1` (+ `attempt@1`) | `{key, label, pre, post}` — pairs two declared activities |
75
+ | `collections[]` | `db@1` | `{name, scope, schema, indexed?, quota?, permissions?}` |
76
+ | `uploads[]` | `storage@1` | `{name, scope, mime[], max_bytes?, max_files?}` |
77
+ | `prompts[]` | `ai@1` | `{id, mode, template, vars?, output_schema?, budget?, model?}` |
78
+ | `broadcast` | `broadcast@1` | `{channels?, facilitator_events?}` |
79
+
80
+ Each is specified in full on its capability's page. Two cross-cutting rules are worth stating here:
81
+
82
+ **Scopes are declarations, not runtime choices.** `db@1` collections and `storage@1` uploads each
83
+ declare a scope — `attempt`, `user`, `room` or `applet` — and an `attempt` scope requires
84
+ `attempt@1` while a `room` scope requires `broadcast@1`. The scope answers, at declaration time,
85
+ what happens to the data when an attempt completes.
86
+
87
+ **Quotas and budgets are ceilings you may lower, not raise.** The deployment's config sets hard
88
+ maxima; a manifest asking for more fails validation, and a manifest asking for nothing gets the
89
+ deployment's defaults.
90
+
91
+ <a name="a-complete-example"></a>
92
+ ## A Complete Example
93
+
94
+ An applet exercising every capability at once. Most applets need three or four.
95
+
96
+ ```json
97
+ {
98
+ "schema": "ziprack/1",
99
+ "slug": "team-summit",
100
+ "name": "Team Summit",
101
+ "version": "2.1.0",
102
+ "entry": "index.html",
103
+
104
+ "requires": {
105
+ "capabilities": ["auth@1", "attempt@1", "activity@1", "db@1", "storage@1", "ai@1", "broadcast@1"],
106
+ "profile": ["display_name", "business_unit", "locale"]
107
+ },
108
+
109
+ "media": [
110
+ { "id": "intro.welcome", "provider": "vimeo", "key": "912345678" },
111
+ { "id": "debrief.clip", "provider": "youtube", "key": "dQw4w9WgXcQ" }
112
+ ],
113
+
114
+ "segments": [
115
+ {
116
+ "key": "track",
117
+ "label": "Track",
118
+ "values": [
119
+ { "key": "rd", "label": "R&D" },
120
+ { "key": "commercial", "label": "Commercial" }
121
+ ]
122
+ }
123
+ ],
124
+
125
+ "activities": [
126
+ { "id": "intro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 1 },
127
+ { "id": "team.pitch", "type": "text", "label": "In one sentence, what is your team's pitch?", "segment": "track", "sequence": 2 },
128
+ { "id": "outro.confidence", "type": "rating", "label": "How confident do you feel about the new operating model?", "scale": { "min": 1, "max": 6 }, "sequence": 3 }
129
+ ],
130
+
131
+ "reporting": {
132
+ "metrics": [
133
+ { "key": "confidence", "label": "Confidence", "pre": "intro.confidence", "post": "outro.confidence" }
134
+ ]
135
+ },
136
+
137
+ "collections": [
138
+ {
139
+ "name": "ideas",
140
+ "scope": "room",
141
+ "schema": {
142
+ "type": "object",
143
+ "properties": {
144
+ "title": { "type": "string", "maxLength": 120 },
145
+ "body": { "type": "string", "maxLength": 2000 },
146
+ "status": { "type": "string", "enum": ["draft", "posted", "picked"] },
147
+ "votes": { "type": "integer", "minimum": 0 }
148
+ },
149
+ "required": ["title", "status"]
150
+ },
151
+ "indexed": ["status", "votes"],
152
+ "quota": { "max_records": 500, "max_bytes": 16384 },
153
+ "permissions": { "update": "owner", "delete": "facilitator" }
154
+ }
155
+ ],
156
+
157
+ "uploads": [
158
+ { "name": "artifacts", "scope": "room", "mime": ["image/png", "image/jpeg"], "max_bytes": 5242880, "max_files": 10 }
159
+ ],
160
+
161
+ "prompts": [
162
+ {
163
+ "id": "score_pitch",
164
+ "mode": "analysis",
165
+ "template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
166
+ "vars": {
167
+ "type": "object",
168
+ "properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
169
+ "required": ["pitch"]
170
+ },
171
+ "output_schema": {
172
+ "type": "object",
173
+ "properties": {
174
+ "clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
175
+ "feedback": { "type": "string" }
176
+ },
177
+ "required": ["clarity", "feedback"]
178
+ },
179
+ "budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
180
+ }
181
+ ],
182
+
183
+ "broadcast": {
184
+ "channels": ["main", "board"],
185
+ "facilitator_events": ["advance", "lock", "reveal"]
186
+ }
187
+ }
188
+ ```
189
+
190
+ <a name="validation-errors"></a>
191
+ ## Validation Errors
192
+
193
+ The validator runs core rules first, then the rules of every declared capability, then cross-field
194
+ checks. All errors are reported at once, keyed by manifest path, and the installer surfaces them as
195
+ one message. The checks that catch people out:
196
+
197
+ | Error | Cause |
198
+ |---|---|
199
+ | `Unknown manifest section [x]` | No installed capability owns that top-level key. |
200
+ | `Manifest section [collections] requires the [db] capability` | The section is there; the declaration is not. |
201
+ | `This Rack does not implement [db@1]. Implemented: …` | Wrong capability or wrong version for this deployment. |
202
+ | `Activity references unknown segment [track]` | `activities[].segment` must name a declared segment key. |
203
+ | `Metric references unknown activity [outro.x]` | `pre` and `post` must be declared activity ids. |
204
+ | `reporting.metrics needs the attempt capability` | Metrics are computed over completed attempts. |
205
+ | `A room-scoped collection requires the [broadcast] capability` | Scope implies capability. |
206
+ | `Indexed path [votes] must be a string, integer, number or boolean property declared in the schema` | You can only index what you declared, and only scalars. |
207
+ | `Placeholder {{pitch}} is not declared in vars.properties` | Prompt templates and their `vars` schema must agree. |
208
+ | `Analysis prompts need an output_schema of type object` | `mode: "analysis"` is structured output by definition. |
209
+ | `The $room channel is reserved for the Rack` | Pick another channel name. |
@@ -0,0 +1,85 @@
1
+ # Media
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Media](#declaring-media)
5
+ - [Rendering a Player](#rendering-a-player)
6
+ - [Why the Rack Builds the URL](#why-the-rack-builds-the-url)
7
+ - [Privacy Defaults](#privacy-defaults)
8
+
9
+ <a name="introduction"></a>
10
+ ## Introduction
11
+
12
+ Vimeo and YouTube embeds are a routine requirement, and they need no capability. What they do need
13
+ is a declaration: the applet names a video by id, and the Rack builds the player URL.
14
+
15
+ Everything else — your own images, audio and video — ships inside the zip and is referenced
16
+ relatively. The CSP allows `img-src`/`media-src` of `'self' data: blob:` plus whatever hosts the
17
+ deployment has allowlisted, and nothing else.
18
+
19
+ <a name="declaring-media"></a>
20
+ ## Declaring Media
21
+
22
+ ```json
23
+ "media": [
24
+ { "id": "corine.diagnosis", "provider": "vimeo", "key": "912345678" },
25
+ { "id": "intro.welcome", "provider": "youtube", "key": "dQw4w9WgXcQ" }
26
+ ]
27
+ ```
28
+
29
+ | Field | Rules |
30
+ |---|---|
31
+ | `id` | Required. `^[a-z0-9][a-z0-9_.:-]*$`, max 150, unique. |
32
+ | `provider` | Required. Must be in the deployment's `ziprack.media.providers` — `vimeo`, `youtube`. |
33
+ | `key` | Required. `^\d+$` for Vimeo, `^[\w-]{11}$` for YouTube. |
34
+
35
+ Validation happens at upload, not at runtime, so a typo in a video id fails the package rather than
36
+ the participant's session. Where `ziprack.media.resolve_oembed` is on, the Rack also performs the
37
+ oEmbed lookup server-side at install and caches the title, thumbnail and duration.
38
+
39
+ <a name="rendering-a-player"></a>
40
+ ## Rendering a Player
41
+
42
+ ```ts
43
+ const url = Rack.media.embedUrl('corine.diagnosis');
44
+ if (url) {
45
+ frame.src = url;
46
+ }
47
+
48
+ const embed = Rack.media.get('corine.diagnosis');
49
+ embed?.meta?.title; // cached oEmbed metadata, when resolved
50
+ embed?.meta?.duration;
51
+
52
+ Rack.media.all(); // every declared embed, by id
53
+ ```
54
+
55
+ Both reads are synchronous — the embeds arrive in launch data.
56
+
57
+ ```html
58
+ <iframe id="player" allow="autoplay; fullscreen; picture-in-picture; encrypted-media"></iframe>
59
+ ```
60
+
61
+ The Rack sets a matching `allow` on the applet's own iframe. Permissions Policy has to be chained
62
+ through both frames: if the outer frame does not carry it, your nested player silently loses
63
+ autoplay and fullscreen, which is a genuinely hard bug to diagnose from inside the applet.
64
+
65
+ Nested players inherit the applet's sandbox flags, so the player also runs with an opaque origin —
66
+ no cookies, no storage of its own. Both providers' JS APIs are `postMessage`-driven, so they
67
+ continue to work. A nested frame cannot talk to the Rack: the bridge checks `event.source`.
68
+
69
+ <a name="why-the-rack-builds-the-url"></a>
70
+ ## Why the Rack Builds the URL
71
+
72
+ Because a URL the applet controls is a channel the applet controls. An applet could otherwise encode
73
+ data in a player URL — `player.vimeo.com/video/123?h=<something>` — and while that request lands in
74
+ a provider's logs the attacker cannot read, the cleanest answer is to remove the path and query
75
+ string from the applet's reach entirely. With declared media there is nothing to encode.
76
+
77
+ It also buys back `connect-src 'self'`: the oEmbed lookup happens server-side at upload, so the
78
+ applet never needs to talk to `vimeo.com` at runtime.
79
+
80
+ <a name="privacy-defaults"></a>
81
+ ## Privacy Defaults
82
+
83
+ The Rack builds YouTube URLs against `youtube-nocookie.com` and Vimeo URLs with `dnt=1`. For pharma
84
+ and financial-services clients this is frequently procurement-gating rather than a nicety, and it is
85
+ not configurable per applet.
@@ -0,0 +1,121 @@
1
+ # The Bridge Protocol
2
+
3
+ - [When You Need This](#when-you-need-this)
4
+ - [The Envelope](#the-envelope)
5
+ - [Handshake](#handshake)
6
+ - [Calls and Acks](#calls-and-acks)
7
+ - [Streaming](#streaming)
8
+ - [Events](#events)
9
+ - [What the Shell Does](#what-the-shell-does)
10
+
11
+ <a name="when-you-need-this"></a>
12
+ ## When You Need This
13
+
14
+ Almost never. Use [`@ziprack/sdk`](sdk.md) — it is the reference implementation of everything below,
15
+ plus mock mode and a test host. This page exists for two cases: you are implementing the applet side
16
+ in a language or runtime the SDK does not cover, and you are working on the Rack itself.
17
+
18
+ <a name="the-envelope"></a>
19
+ ## The Envelope
20
+
21
+ Every message in both directions is one JSON object:
22
+
23
+ ```json
24
+ { "ns": "ziprack", "v": 1, "type": "activity:statement", "requestId": "b1f0…", "payload": { } }
25
+ ```
26
+
27
+ | Field | Rules |
28
+ |---|---|
29
+ | `ns` | Always `ziprack`. Anything else is ignored. |
30
+ | `v` | Always `1`. Versioned with the format string, not with capabilities. |
31
+ | `type` | `rack:*` for lifecycle, or `{capability}:{action}` matching `^[a-z][a-z0-9_]*:[a-z][a-z0-9_.]*$`. |
32
+ | `requestId` | Present when you want an ack. The SDK uses `crypto.randomUUID()`. |
33
+ | `payload` | Type-specific object. |
34
+
35
+ The applet posts to `window.parent` with target origin `*`, because it has no origin of its own to
36
+ assert. **The host trusts by `event.source`**: a message is honoured only when it came from the
37
+ applet iframe's `contentWindow`. `event.origin` is never consulted — the applet's origin is opaque
38
+ by design, so comparing it would be theatre. The same rule protects the applet in reverse: ignore
39
+ any message whose source is not `window.parent`, which is what makes a nested Vimeo player unable to
40
+ impersonate the Rack.
41
+
42
+ <a name="handshake"></a>
43
+ ## Handshake
44
+
45
+ ```
46
+ applet → host { type: "rack:ready" }
47
+ host → applet { type: "rack:launch", payload: { …launch data… } }
48
+ ```
49
+
50
+ The applet announces readiness as early as it can; the host replies with the full launch payload
51
+ (see [Launch Data](sdk.md#launch-data)). Do not send capability calls before `rack:launch` arrives —
52
+ queue them, as the SDK does.
53
+
54
+ <a name="calls-and-acks"></a>
55
+ ## Calls and Acks
56
+
57
+ ```
58
+ applet → host { type: "db:create", requestId: "r1", payload: { collection: "ideas", data: {…} } }
59
+ host → applet { type: "rack:ack", requestId: "r1", payload: { ok: true, data: { record: {…} } } }
60
+ ```
61
+
62
+ An ack always carries the `requestId` it answers. On failure:
63
+
64
+ ```json
65
+ { "ok": false, "error": "quota_exceeded", "message": "Collection [ideas] is full (500 records)." }
66
+ ```
67
+
68
+ Error codes are listed in [Acks and Errors](sdk.md#acks-and-errors) and are part of the contract:
69
+ they do not change within `ziprack/1`.
70
+
71
+ A message sent without a `requestId` is fire-and-forget — the host processes it and sends nothing
72
+ back. `rack:eject` is the usual example.
73
+
74
+ <a name="streaming"></a>
75
+ ## Streaming
76
+
77
+ A streamed call receives multiple acks under one `requestId`:
78
+
79
+ ```json
80
+ { "ok": true, "partial": true, "delta": "Well, " }
81
+ { "ok": true, "partial": true, "delta": "that depends…" }
82
+ { "ok": true, "done": true, "data": { "message_id": "m_1", "text": "Well, that depends…", "usage": {…} } }
83
+ ```
84
+
85
+ Only the terminal ack resolves the call; each partial resets the ack timeout. `ai:chat` is the only
86
+ streaming surface in `ziprack/1`.
87
+
88
+ <a name="events"></a>
89
+ ## Events
90
+
91
+ Unsolicited messages from the host carry no `requestId`:
92
+
93
+ ```json
94
+ { "ns": "ziprack", "v": 1, "type": "rack:event",
95
+ "payload": { "event": "advance", "payload": { "step": 3 }, "channel": "main",
96
+ "from": { "participant_id": 42, "role": "facilitator" } } }
97
+ ```
98
+
99
+ The reserved channel `$room` carries Rack-generated room events — `presence`, `state`, `role`,
100
+ `status`. An applet cannot publish to it.
101
+
102
+ <a name="what-the-shell-does"></a>
103
+ ## What the Shell Does
104
+
105
+ Between the frame and the Rack sits the shell, a small script the Rack serves with the player page.
106
+ Knowing its behaviour explains timings you would otherwise find surprising:
107
+
108
+ - **Batching.** Calls are queued for up to 300 ms and posted together to
109
+ `POST /ziprack/s/{session}/messages`, maximum 50 per request. Acks come back in order and are
110
+ relayed individually.
111
+ - **Coalescing.** Only the latest pending `attempt:state` survives a batch, so calling `setState()`
112
+ on every keystroke is safe.
113
+ - **Retry.** A failed batch is retried once after 1 s; after that every message in it acks
114
+ `persist_failed`.
115
+ - **Keepalive.** On page hide the pending batch flushes with `keepalive: true`.
116
+ - **Files.** `storage:put` is intercepted and re-sent as multipart to
117
+ `POST /ziprack/s/{session}/files`; the `File` never travels as JSON.
118
+ - **Streams.** When an ack carries `data.stream === true`, the shell opens an `EventSource` against
119
+ `/ziprack/s/{session}/stream/{requestId}` and relays each chunk as a partial ack.
120
+ - **Completion.** A successful `attempt:complete` flips the Rack's own completion chrome — which is
121
+ why a completed attempt lands on its final screen rather than restarting.
@@ -0,0 +1,127 @@
1
+ # Publishing
2
+
3
+ - [Introduction](#introduction)
4
+ - [Installing a Version](#installing-a-version)
5
+ - [Capability Diff Review](#capability-diff-review)
6
+ - [Promotion](#promotion)
7
+ - [The Review Queue](#the-review-queue)
8
+ - [Retiring a Version](#retiring-a-version)
9
+ - [The Audit Log](#the-audit-log)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ `cn-sim/1` assumed CN authored every package. Accepting client-authored applets changes the problem
15
+ from untrusted *content* to untrusted *code*, and that calls for process rather than more sandboxing.
16
+
17
+ The pipeline is deliberately boring: every upload lands inactive, a human promotes it, and anything
18
+ that widens what the applet can reach needs an explicit approval first.
19
+
20
+ <a name="installing-a-version"></a>
21
+ ## Installing a Version
22
+
23
+ ```sh
24
+ php artisan ziprack:install dist-zip/my-applet-1.0.0.zip
25
+ php artisan ziprack:install dist-zip/my-applet-1.0.0.zip --activate # install and promote
26
+ ```
27
+
28
+ or `POST /ziprack/admin/versions/upload` with the zip.
29
+
30
+ Installation validates the manifest against this Rack's capability set, records the version, runs
31
+ each declared capability's `onInstall()` hook inside a transaction, and only then extracts files. A
32
+ version arrives as `ready` but **not current**, unless it is the applet's first — there is nothing
33
+ to review a first version against, so it is promoted automatically.
34
+
35
+ Once files are extracted the Rack resolves declared media (the oEmbed lookup) and, when
36
+ `publishing.osv_lookup` is on, queues a batch of the SBOM's purls against OSV.dev. Findings attach
37
+ to the version and surface in the review queue; they do not block the install.
38
+
39
+ <a name="capability-diff-review"></a>
40
+ ## Capability Diff Review
41
+
42
+ **This is the control that actually holds.** When a new version of an existing applet is uploaded,
43
+ the Rack diffs its `requires` block against the current version:
44
+
45
+ ```json
46
+ {
47
+ "added": ["ai@1", "db@1"],
48
+ "removed": [],
49
+ "profile_added": ["business_unit"],
50
+ "profile_removed": [],
51
+ "pinning_changed": true,
52
+ "pinned": true
53
+ }
54
+ ```
55
+
56
+ Any addition — a capability or a profile attribute — blocks promotion until someone approves it.
57
+ This is the Android permissions model, and it catches the realistic attack: a benign v1 that earns
58
+ trust, followed by a v2 that quietly widens its reach.
59
+
60
+ `pinning_changed` is flagged just as prominently, because adding or removing `attempt@1` changes the
61
+ applet's own update semantics from rolling to pinned or back. See
62
+ [Version Pinning](attempts.md#version-pinning).
63
+
64
+ Removals need no approval. Narrowing what an applet can reach is always allowed.
65
+
66
+ <a name="promotion"></a>
67
+ ## Promotion
68
+
69
+ ```sh
70
+ php artisan ziprack:promote my-applet 1.1.0
71
+ php artisan ziprack:promote my-applet 1.1.0 --force # approve the pending review, then promote
72
+ ```
73
+
74
+ or `POST /ziprack/admin/applets/{applet}/versions/{version}/approve` followed by
75
+ `…/promote`.
76
+
77
+ Promotion is refused when the version is not `ready`, or when it has an unapproved capability
78
+ addition — the error names exactly what was added. On success, `current_version_id` moves and each
79
+ capability's `onPromote()` hook runs.
80
+
81
+ What promotion means depends on pinning:
82
+
83
+ - **Rolling applet** (no `attempt@1`): everyone is on the new version at their next page load.
84
+ - **Pinned applet** (`attempt@1`): new attempts start on the new version; in-progress attempts stay
85
+ on the one they started on until they complete. Two versions run side by side until the old one
86
+ drains.
87
+
88
+ <a name="the-review-queue"></a>
89
+ ## The Review Queue
90
+
91
+ `GET /ziprack/admin/review-queue` collects everything awaiting a human decision rather than hiding
92
+ it behind a modal:
93
+
94
+ - Versions requesting new capabilities or profile attributes
95
+ - OSV findings from the SBOM
96
+ - The first upload from a publisher
97
+ - Manifest validation warnings that were overridden
98
+
99
+ A version's own page (`GET /ziprack/admin/applets/{applet}/versions/{version}`) carries the diff,
100
+ the SBOM summary, the findings and the publisher.
101
+
102
+ Each version records an attributable publisher, so a bad version can be traced and that publisher's
103
+ other applets reviewed.
104
+
105
+ <a name="retiring-a-version"></a>
106
+ ## Retiring a Version
107
+
108
+ The kill switch:
109
+
110
+ ```sh
111
+ php artisan ziprack:retire my-applet 1.1.0
112
+ ```
113
+
114
+ A retired version can no longer be launched. In-progress attempts pinned to it land on a holding
115
+ screen — a defined state with an explanation, not a blank frame. Retiring the current version leaves
116
+ the applet with no current version until another is promoted, which is deliberate: the operator
117
+ decides what to fall back to.
118
+
119
+ <a name="the-audit-log"></a>
120
+ ## The Audit Log
121
+
122
+ With `ziprack.audit.enabled` on, capability calls are recorded per applet — type, participant,
123
+ outcome — excluding the high-volume types each capability already persists (`activity:statement`,
124
+ `attempt:state`, `broadcast:heartbeat`). Read it at `GET /ziprack/admin/audit-log`.
125
+
126
+ This is what an incident response actually needs: what the applet asked for, when, and what the Rack
127
+ answered. AI spend and quota usage are reported alongside it per applet.