@ziprack/sdk 0.1.2 → 0.2.1

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,261 @@
1
+ # Writing a Capability
2
+
3
+ - [When to Add One](#when-to-add-one)
4
+ - [The Capability Interface](#the-capability-interface)
5
+ - [Registering It](#registering-it)
6
+ - [Manifest Rules](#manifest-rules)
7
+ - [Message Handlers](#message-handlers)
8
+ - [Launch Payload](#launch-payload)
9
+ - [Install Hooks](#install-hooks)
10
+ - [Conformance and Status](#conformance-and-status)
11
+ - [The SDK Side](#the-sdk-side)
12
+
13
+ <a name="when-to-add-one"></a>
14
+ ## When to Add One
15
+
16
+ A capability is one applet-facing surface: a set of message types, a manifest section, a slice of the
17
+ launch payload, and the server-side service behind them. `ziprack/db` and `ziprack/ai` are both
18
+ exactly this shape.
19
+
20
+ Before adding one, apply the framework's own test:
21
+
22
+ > If a capability requires an applet author to reason about device identity, transport, or
23
+ > deployment topology, it does not belong in zipRACK.
24
+
25
+ And check it is not already expressible. A great many ideas reduce to `db@1` with a different
26
+ collection, or to `broadcast@1` with a different event name. The value of seven surfaces is that
27
+ there are seven.
28
+
29
+ <a name="the-capability-interface"></a>
30
+ ## The Capability Interface
31
+
32
+ One package, one `Capability`. Extend `AbstractCapability` and override only what you need — every
33
+ method has a sensible no-op default.
34
+
35
+ ```php
36
+ namespace ZipRack\Survey;
37
+
38
+ use ZipRack\Core\Capabilities\AbstractCapability;
39
+
40
+ class SurveyCapability extends AbstractCapability
41
+ {
42
+ public function id(): string
43
+ {
44
+ return 'survey'; // ^[a-z][a-z0-9_]*$
45
+ }
46
+
47
+ public function versions(): array
48
+ {
49
+ return [1]; // may implement several at once: [1, 2]
50
+ }
51
+ }
52
+ ```
53
+
54
+ | Method | Purpose |
55
+ |---|---|
56
+ | `id()` | The name in manifests and message types: `survey`, `survey:submit`. |
57
+ | `versions()` | Capability versions this package implements. Metadata, independent of package semver. |
58
+ | `manifestSections()` | Top-level manifest keys this capability owns. |
59
+ | `manifestRules()` | Laravel rules for those sections. |
60
+ | `validateManifest()` | Cross-field checks; add errors to the validator. |
61
+ | `messageHandlers()` | `type => handler class`. |
62
+ | `launchPayload()` | What to merge into `rack:launch` under this capability's id. |
63
+ | `onInstall()` / `onPromote()` / `onRetire()` | Version lifecycle hooks. `onInstall()` runs inside the install transaction, so row changes only. |
64
+ | `adminRoutes()` | Headless admin routes, registered only when `ziprack/admin` is installed. |
65
+ | `conformanceTests()` | Checks contributed to the conformance suite. |
66
+ | `statusChecks()` | Warnings for `ziprack:status` and `/ziprack/health`. |
67
+
68
+ <a name="registering-it"></a>
69
+ ## Registering It
70
+
71
+ ```php
72
+ class SurveyServiceProvider extends ServiceProvider
73
+ {
74
+ public function register(): void
75
+ {
76
+ $this->mergeConfigFrom(__DIR__.'/../config/ziprack-survey.php', 'ziprack-survey');
77
+ }
78
+
79
+ public function boot(): void
80
+ {
81
+ $this->loadMigrationsFrom(__DIR__.'/../database/migrations');
82
+
83
+ ZipRack::register($this->app->make(SurveyCapability::class));
84
+
85
+ if ($this->app->runningInConsole()) {
86
+ $this->publishes([
87
+ __DIR__.'/../config/ziprack-survey.php' => config_path('ziprack-survey.php'),
88
+ ], 'ziprack-config');
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
94
+ Migrations load; they are never published. Config publishes; it is meant to diverge. That split is
95
+ [not negotiable](installation.md#publishable-and-non-publishable) — it is what keeps the fleet
96
+ uniform.
97
+
98
+ The registry is built from installed providers, so installing the package *is* declaring the
99
+ capability. There is no separate list to fall out of sync.
100
+
101
+ <a name="manifest-rules"></a>
102
+ ## Manifest Rules
103
+
104
+ ```php
105
+ public function manifestSections(): array
106
+ {
107
+ return ['surveys'];
108
+ }
109
+
110
+ public function manifestRules(): array
111
+ {
112
+ return [
113
+ 'surveys' => ['required', 'array', 'min:1'],
114
+ 'surveys.*.id' => ['required', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_.-]*$/'],
115
+ 'surveys.*.questions' => ['required', 'array', 'min:1'],
116
+ ];
117
+ }
118
+
119
+ public function validateManifest(array $data, Validator $validator): void
120
+ {
121
+ $ids = [];
122
+ foreach ($data['surveys'] ?? [] as $index => $survey) {
123
+ if (isset($ids[$survey['id'] ?? null])) {
124
+ $validator->errors()->add("surveys.{$index}.id", "Duplicate survey id [{$survey['id']}].");
125
+ }
126
+ $ids[$survey['id'] ?? ''] = true;
127
+ }
128
+ }
129
+ ```
130
+
131
+ The core validator enforces the section/capability relationship for you: a manifest containing
132
+ `surveys` without declaring `survey@1` fails, and so does one declaring a section no installed
133
+ capability owns.
134
+
135
+ Three conventions worth keeping: uniqueness errors name the duplicate, a section that depends on
136
+ another capability says so explicitly (`"A room-scoped X requires the [broadcast] capability"`), and
137
+ anything with a quota takes its ceiling from config rather than hard-coding it.
138
+
139
+ <a name="message-handlers"></a>
140
+ ## Message Handlers
141
+
142
+ ```php
143
+ public function messageHandlers(): array
144
+ {
145
+ return ['survey:submit' => SubmitSurvey::class];
146
+ }
147
+ ```
148
+
149
+ ```php
150
+ class SubmitSurvey implements MessageHandler
151
+ {
152
+ public function rules(MessageContext $context): array
153
+ {
154
+ return [
155
+ 'survey' => ['required', 'string', 'max:100'],
156
+ 'answers' => ['required', 'array'],
157
+ ];
158
+ }
159
+
160
+ public function handle(MessageContext $context, array $payload): Ack
161
+ {
162
+ $survey = SurveyManifest::of($context->manifest())->find($payload['survey'])
163
+ ?? throw MessageException::notFound("Survey [{$payload['survey']}] is not declared.");
164
+
165
+ // $context->participant, $context->version, $context->attribute('attempt_id'|'room_id')
166
+
167
+ return Ack::ok(['id' => $id]);
168
+ }
169
+ }
170
+ ```
171
+
172
+ The dispatcher has already checked that the manifest declares your capability at a version this Rack
173
+ implements, and has already validated the payload against `rules()` — a handler never sees an
174
+ invalid payload or an undeclared call.
175
+
176
+ Throw `MessageException::forbidden() / notFound() / quota() / completed() / conflict()` for expected
177
+ failures; anything else is reported and acks `persist_failed`. Never leak an internal message into
178
+ an ack.
179
+
180
+ Message types must match `^[a-z][a-z0-9_]*:[a-z][a-z0-9_.]*$` and are namespaced by your capability
181
+ id. Everything the applet may influence about scope comes from `MessageContext`, never from the
182
+ payload — that is how a room-scoped write cannot address another room.
183
+
184
+ <a name="launch-payload"></a>
185
+ ## Launch Payload
186
+
187
+ ```php
188
+ public function launchPayload(LaunchContext $context): ?array
189
+ {
190
+ return ['surveys' => SurveyManifest::of($context->manifest())->summaries()];
191
+ }
192
+ ```
193
+
194
+ Merged under your capability id, so the applet reads `launch.survey.surveys`. Return `null` to
195
+ contribute nothing. Use `$context->remember(key, fn)` to share a resolved model with your message
196
+ handlers rather than querying twice.
197
+
198
+ Send what the applet needs to render its first frame, and nothing more. Launch data crosses into an
199
+ untrusted frame: it is the one place where over-sharing is silent.
200
+
201
+ <a name="install-hooks"></a>
202
+ ## Install Hooks
203
+
204
+ ```php
205
+ public function onInstall(AppletVersion $version, ?AppletVersion $previous): void
206
+ {
207
+ $this->installer->install($version, $previous); // row changes only; no DDL
208
+ }
209
+ ```
210
+
211
+ `onInstall()` runs inside the install transaction, with the previous current version for comparison
212
+ — which is what lets `db@1` register, update and orphan collections as they change between
213
+ versions. If it throws, the whole installation rolls back and extracted files are removed.
214
+
215
+ Keep it to row changes. `CREATE`, `ALTER` and `DROP` commit any open transaction on MySQL and
216
+ MariaDB, which breaks the install and lets the conformance suite's data escape its rollback. If a
217
+ capability needs tables, ship them as migrations; the conformance suite fails any check that commits
218
+ its transaction.
219
+
220
+ <a name="conformance-and-status"></a>
221
+ ## Conformance and Status
222
+
223
+ ```php
224
+ public function conformanceTests(): array
225
+ {
226
+ return [SurveyAnswersAreImmutable::class];
227
+ }
228
+
229
+ public function statusChecks(): array
230
+ {
231
+ return $this->configured() ? [] : [[
232
+ 'level' => 'warning',
233
+ 'message' => 'survey@1 has no store configured; submissions are discarded.',
234
+ ]];
235
+ }
236
+ ```
237
+
238
+ A conformance check is a class with `name()` and `run(Fixture $fixture)`, failing by throwing. Write
239
+ one per invariant the capability promises an applet author — not one per method.
240
+
241
+ <a name="the-sdk-side"></a>
242
+ ## The SDK Side
243
+
244
+ A capability is only half-built until `@ziprack/sdk` can call it. Add a module under
245
+ `sdk/src/{capability}/`, take a `Link` in the constructor, and expose it on `RackClient`:
246
+
247
+ ```ts
248
+ export class SurveyApi {
249
+ public constructor(private readonly link: Link) {}
250
+
251
+ public submit(survey: string, answers: JsonObject): Promise<AckResult<{ id: string }>> {
252
+ return this.link.send('survey:submit', { survey, answers });
253
+ }
254
+ }
255
+ ```
256
+
257
+ Then implement it in `MockRack` as well. **A capability with no mock is a capability applet authors
258
+ cannot develop against**, and standalone development is the property the framework most depends on —
259
+ losing it costs more than the capability is worth.
260
+
261
+ Bump the capability version only when this surface changes. Refactors below it are free.
@@ -0,0 +1,186 @@
1
+ # Installation
2
+
3
+ - [Composing a Rack](#composing-a-rack)
4
+ - [The Deployment Skeleton](#the-deployment-skeleton)
5
+ - [Configuration](#configuration)
6
+ - [Routes](#routes)
7
+ - [Participants and Guests](#participants-and-guests)
8
+ - [Publishable and Non-Publishable](#publishable-and-non-publishable)
9
+ - [Console Commands](#console-commands)
10
+
11
+ <a name="composing-a-rack"></a>
12
+ ## Composing a Rack
13
+
14
+ **A deployment's `composer.json` is its capability declaration.** The capability registry is built
15
+ by iterating installed service providers, so it cannot disagree with what is actually installed.
16
+
17
+ ```json
18
+ "require": {
19
+ "ziprack/core": "^1.0",
20
+ "ziprack/auth": "^1.0",
21
+ "ziprack/attempt": "^1.0",
22
+ "ziprack/activity": "^1.0",
23
+ "ziprack/db": "^1.0",
24
+ "ziprack/ai": "^1.0",
25
+ "ziprack/broadcast": "^1.0",
26
+ "ziprack/storage": "^1.0",
27
+ "ziprack/admin": "^1.0"
28
+ },
29
+ "require-dev": {
30
+ "ziprack/conformance": "^1.0"
31
+ }
32
+ ```
33
+
34
+ Remove `ziprack/db` and an applet requesting `db@1` is refused at upload with a message naming what
35
+ this Rack does implement. Drop `ziprack/broadcast` and there is no Reverb dependency, no WebSocket
36
+ port and no room tables — a smaller attack surface, not just a smaller install.
37
+
38
+ `ziprack/core` is mandatory; every capability package requires it. `ziprack/admin` is optional and
39
+ headless: it ships JSON endpoints and export services, never pages. `ziprack/conformance` is a dev
40
+ dependency that belongs in CI.
41
+
42
+ Packages resolve from private VCS repositories:
43
+
44
+ ```json
45
+ "repositories": [
46
+ { "type": "vcs", "url": "git@github.com:Collective-Next/ziprack-core.git" },
47
+ { "type": "vcs", "url": "git@github.com:Collective-Next/ziprack-attempt.git" }
48
+ ]
49
+ ```
50
+
51
+ Inside the monorepo they resolve from `../packages/*` path repositories instead, so changes are
52
+ testable without tagging.
53
+
54
+ <a name="the-deployment-skeleton"></a>
55
+ ## The Deployment Skeleton
56
+
57
+ `skeleton/` is a Laravel application template cloned once per engagement and then owned by that
58
+ engagement. It holds what is *meant* to diverge: branding, environment, the deployment pipeline, the
59
+ admin UI, and the capability list.
60
+
61
+ ```sh
62
+ cp .env.example .env && php artisan key:generate
63
+ php artisan migrate --seed
64
+ npm install && npm run build
65
+ php artisan ziprack:install path/to/applet-1.0.0.zip --activate
66
+ php artisan ziprack:conformance
67
+ ```
68
+
69
+ Migrations ship inside the packages and load through `loadMigrationsFrom()`; they are never
70
+ published, because schema must be identical on every Rack.
71
+
72
+ <a name="configuration"></a>
73
+ ## Configuration
74
+
75
+ Each package publishes one config file. The defaults are production-safe; the values below are the
76
+ ones you will actually touch.
77
+
78
+ ### `config/ziprack.php`
79
+
80
+ | Key | Default | Notes |
81
+ |---|---|---|
82
+ | `disk` | `local` | Where packages are extracted. Must not be publicly served. |
83
+ | `max_upload_mb` | `50` | Package size cap. |
84
+ | `allowed_extensions` | static list | Anything else rejects the whole upload. |
85
+ | `asset_token_ttl_hours` | `6` | Lifetime of the signed package-asset token. |
86
+ | `session_ttl_hours` | `6` | Lifetime of a launch session. |
87
+ | `csp.img_hosts`, `csp.media_hosts` | `[]` | Per-deployment allowlist. A bare scheme is rejected. |
88
+ | `player.frame_allow` | `[]` | Features added to the applet iframe's `allow` list, e.g. `microphone`. Bare names from a fixed set only. |
89
+ | `media.providers` | `['vimeo','youtube']` | Drives `frame-src` and manifest validation. |
90
+ | `publishing.require_sbom` | `true` | Refuse packages with no SBOM. |
91
+ | `publishing.require_review_for_new_capabilities` | `true` | Block promotion until a widened `requires` is approved. |
92
+ | `publishing.osv_lookup` | `true` | Batch the SBOM's purls against OSV.dev after install. |
93
+ | `audit.enabled` | `true` | Per-applet audit log of capability calls. |
94
+ | `health_token` | `null` | Bearer token for `/ziprack/health`. |
95
+
96
+ ### Capability configs
97
+
98
+ | File | Notable keys |
99
+ |---|---|
100
+ | `ziprack-auth.php` | `releasable` — the single place a deployment answers "what can applets see about our people"; `claim_map` |
101
+ | `ziprack-attempt.php` | `max_state_bytes` (64 KB), `max_segment_keys` (20) |
102
+ | `ziprack-db.php` | `max_records_per_scope` (5000), `max_record_bytes` (64 KB), `max_indexed_paths` (8), `list_limit` (100) |
103
+ | `ziprack-storage.php` | `disk`, `max_bytes_cap` (50 MB), `url_ttl_minutes` (10), `scanner` (`null` \| `clamav`) |
104
+ | `ziprack-ai.php` | `provider`, `models.analysis`, `models.chat`, `allowed_models`, the whole `budget` block |
105
+ | `ziprack-broadcast.php` | `join_prefix`, `facilitator_grace_seconds` (60), `presence_window_seconds` (45), `max_event_bytes` |
106
+ | `ziprack-admin.php` | `prefix` (`ziprack/admin`), `middleware` (`web`, `auth`, `can:ziprack.use-admin`) |
107
+
108
+ Ceilings in these files are **hard**: a manifest asking for a larger quota or budget fails
109
+ validation. Defaults apply when a manifest declares nothing.
110
+
111
+ <a name="routes"></a>
112
+ ## Routes
113
+
114
+ | Route | Purpose |
115
+ |---|---|
116
+ | `GET /a/{applet}` | The player: chrome plus the sandboxed iframe. Middleware from `ziprack.routes.middleware.player`, default `['web','auth']`. |
117
+ | `GET /ziprack/pkg/{token}/{path}` | Package assets, streamed with the contract's headers and CSP. Token-authenticated, stateless. |
118
+ | `GET /ziprack/assets/{file}` | The shell bundles (`shell.js`, `shell-broadcast.js`, `sdk.js`). |
119
+ | `POST /ziprack/s/{session}/messages` | The one door every capability call goes through. Batched, max 50 per request. |
120
+ | `GET /ziprack/s/{session}/stream/{requestId}` | Server-sent events for streamed acks (`ai:chat`). |
121
+ | `POST /ziprack/s/{session}/files` | Multipart upload target for `storage:put` (`storage`). |
122
+ | `GET /ziprack/f/{file}` | Signed, short-lived download of a stored file (`storage`). |
123
+ | `POST /ziprack/s/{session}/broadcast/auth` | Rack-side WebSocket channel authentication (`broadcast`). |
124
+ | `GET /ziprack/health` | Fleet visibility: format, core version, capabilities, warnings. |
125
+ | `GET /r/{code}` | Join a room by code (`broadcast`). |
126
+ | `*` `/ziprack/admin/*` | The headless admin API (`ziprack/admin`). |
127
+
128
+ Session routes are deliberately stateless: token in the path, no session cookie, no CSRF, rate
129
+ limited by `ziprack.routes.session_rate_limit`.
130
+
131
+ <a name="participants-and-guests"></a>
132
+ ## Participants and Guests
133
+
134
+ By default the player sits behind `auth` and each authenticated user maps to a `Participant`.
135
+ Light group activities often have no identity source, so a Rack may enable guests:
136
+
137
+ ```
138
+ ZIPRACK_GUESTS=true
139
+ ```
140
+
141
+ and drop `auth` from the player middleware. An unauthenticated visitor then gets a cookie-keyed
142
+ participant, which is enough for `opt_in` and `first_in` rooms. `roster` rooms and any applet
143
+ declaring `auth@1` still need real users.
144
+
145
+ To resolve participants differently — SSO subject, an LTI launch, an external roster — bind
146
+ `ZipRack\Core\Contracts\ParticipantResolver` in a service provider.
147
+
148
+ <a name="publishable-and-non-publishable"></a>
149
+ ## Publishable and Non-Publishable
150
+
151
+ Every publishable file is a place the fleet can drift, so the split is deliberate.
152
+
153
+ **Publishable — meant to diverge:** config files, the player chrome and holding views
154
+ (`resources/views/vendor/ziprack`), translations, notification templates.
155
+
156
+ **Never publishable — these are the contract:**
157
+
158
+ | Artifact | Why |
159
+ |---|---|
160
+ | Migrations | Schema must be identical everywhere. |
161
+ | `ManifestValidator` | An applet must validate the same way on every Rack, or portability is fiction. |
162
+ | `PackageInstaller` file rules | Traversal, symlink and extension checks are security code. |
163
+ | Bridge and envelope handling | The `event.source` trust check is the whole boundary. |
164
+ | `ContentSecurityPolicy` | Directives are fixed; only the host allowlist is configurable. |
165
+ | `CapabilityRegistry` and dispatch | The runtime rejection of undeclared calls. |
166
+
167
+ Where deployments genuinely need to vary, the variation is configuration the core reads — not code
168
+ the deployment owns. A Rack can add an approved image host; it cannot reintroduce `https:`.
169
+
170
+ <a name="console-commands"></a>
171
+ ## Console Commands
172
+
173
+ | Command | Does |
174
+ |---|---|
175
+ | `ziprack:install {zip} [--applet=] [--activate]` | Install a package as a new version. |
176
+ | `ziprack:promote {slug} {version} [--force]` | Make a version current. `--force` also approves a pending capability review. |
177
+ | `ziprack:retire {slug} {version}` | Kill switch: the version can no longer be launched. |
178
+ | `ziprack:status [--json]` | Format, core version, installed capabilities, warnings. |
179
+ | `ziprack:conformance [--json]` | Verify this Rack honours `ziprack/1`. |
180
+ | `ziprack:conformance:install` | Publish a test that runs the suite inside the deployment's own Pest run. |
181
+ | `ziprack:profile:set {email} {key} {value}` | Set one profile attribute. |
182
+ | `ziprack:roster:import {csv}` | Import profile attributes by email. |
183
+ | `ziprack:room:create {slug} [--name=] [--mode=] [--roster=]` | Create a room and print its join code. |
184
+ | `ziprack:rooms:tick` | Vacate and reassign dropped facilitators. **Schedule every minute.** |
185
+ | `ziprack:db:prune [--force]` | Drop tables of collections no current manifest declares. |
186
+ | `ziprack:response-analysis [--applet=] [--force]` | Regenerate admin AI summaries. **Schedule every few hours.** |
@@ -0,0 +1,124 @@
1
+ # Introduction
2
+
3
+ - [The Shape of a Rack](#the-shape-of-a-rack)
4
+ - [The Trust Boundary](#the-trust-boundary)
5
+ - [Objects](#objects)
6
+ - [Capabilities](#capabilities)
7
+ - [What zipRACK Is Not](#what-ziprack-is-not)
8
+
9
+ <a name="the-shape-of-a-rack"></a>
10
+ ## The Shape of a Rack
11
+
12
+ A **Rack** is one deployed Laravel application. It authenticates people, stores every byte an
13
+ applet produces, makes every outbound call, holds the WebSocket, and renders the admin reporting.
14
+
15
+ An **applet** is a zip containing a static web app. It is uploaded, validated, extracted and served
16
+ inside a sandboxed iframe at `/a/{slug}`. It owns layout, branding, media, navigation and
17
+ interaction — the entire participant-facing experience — and nothing else.
18
+
19
+ ```
20
+ ┌─ Rack (Laravel) ───────────────────────────────────────────┐
21
+ │ auth · attempts · records · files · prompts · rooms │
22
+ │ ┌─ chrome ────────────────────────────────────────────┐ │
23
+ │ │ ┌─ iframe (opaque origin, no allow-same-origin) ──┐ │ │
24
+ │ │ │ applet: index.html + assets │ │ │
25
+ │ │ │ @ziprack/sdk ⇄ postMessage ⇄ shell │ │ │
26
+ │ │ └─────────────────────────────────────────────────┘ │ │
27
+ │ └─────────────────────────────────────────────────────┘ │
28
+ └────────────────────────────────────────────────────────────┘
29
+ ```
30
+
31
+ Every deployment is standalone: its own database, its own domain, its own capability set. There is
32
+ no shared multi-tenant monolith. What unites deployments is the format string `ziprack/1` and the
33
+ set of capability versions each Rack implements — which is why an applet built against one Rack runs
34
+ on another.
35
+
36
+ <a name="the-trust-boundary"></a>
37
+ ## The Trust Boundary
38
+
39
+ The iframe is rendered with:
40
+
41
+ ```html
42
+ <iframe sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
43
+ allow="autoplay; fullscreen; picture-in-picture; encrypted-media">
44
+ ```
45
+
46
+ Note the absence of `allow-same-origin`. The applet document has an **opaque origin**: no cookies,
47
+ no `localStorage`, no `IndexedDB`, no credentialed requests, no way to call the Rack over HTTP.
48
+ Everything an applet needs, it asks for over `postMessage`:
49
+
50
+ ```ts
51
+ await Rack.activity.answer('intro.connection', 5);
52
+ ```
53
+
54
+ That call becomes a `ziprack` envelope posted to the parent window, which the shell batches into a
55
+ `POST /ziprack/s/{session}/messages`, which `MessageDispatcher` checks against the applet's manifest
56
+ before handing to the capability's handler. The applet asks; the Rack decides, enforces and persists.
57
+
58
+ Trust runs on `event.source`, never `event.origin` — the applet's origin is opaque by design, so
59
+ origin comparison is meaningless. A nested frame (a Vimeo player, say) that tries to talk to the
60
+ Rack is ignored, because its window is not the applet's `contentWindow`.
61
+
62
+ **This is why new capabilities are cheap.** Adding an LLM surface does not loosen the CSP, because
63
+ the call never leaves the frame as HTTP — it leaves as a `postMessage` the Rack fulfils server-side.
64
+ An applet can never obtain an API key, a channel token or a database handle, because none of those
65
+ ever cross the boundary.
66
+
67
+ <a name="objects"></a>
68
+ ## Objects
69
+
70
+ | Object | What it is | Capability |
71
+ |---|---|---|
72
+ | **Applet** | One experience or app, identified by an immutable `slug`. Has many versions. | core |
73
+ | **Version** | One uploaded zip. Immutable. Exactly one is current. | core |
74
+ | **Participant** | The person in front of the applet, resolved from the Rack's own users (or a guest cookie). | core |
75
+ | **Attempt** | One participant's run. Holds `status`, `state`, `segment`. Pinned to the version it started on. | `attempt@1` |
76
+ | **Statement** | Append-only record: actor did verb on activity with result. The audit trail and the uniform export. | `activity@1` |
77
+ | **Record** | A mutable row in a declared collection, at a declared scope. Working state. | `db@1` |
78
+ | **Stored file** | A participant upload, addressable only once scanned clean. | `storage@1` |
79
+ | **Room** | A shared session: an applet version plus its members, each `participant` or `facilitator`. | `broadcast@1` |
80
+ | **Profile attribute** | A Rack-owned fact about the participant, released only if requested and permitted. | `auth@1` |
81
+
82
+ Statements and records coexist because they answer different questions. A statement is *what
83
+ happened*; a record is *what is currently true*. Statements are append-only and uniformly shaped, so
84
+ they export to one CSV per applet. Records are mutable and arbitrarily shaped, which is why `db@1`
85
+ requires a JSON Schema — the schema is what buys back the column headers.
86
+
87
+ <a name="capabilities"></a>
88
+ ## Capabilities
89
+
90
+ Seven surfaces. An applet declares the ones it uses in its manifest, and the Rack refuses at runtime
91
+ any call the manifest did not declare.
92
+
93
+ | Capability | Covers | Entry point |
94
+ |---|---|---|
95
+ | [`auth@1`](auth.md) | Who the participant is; released profile attributes | `Rack.auth.user()` |
96
+ | [`attempt@1`](attempts.md) | Lifecycle, resume state, completion, version pinning | `Rack.attempt.complete()` |
97
+ | [`activity@1`](activity.md) | Append-only statements and the CSV export | `Rack.activity.answer(id, value)` |
98
+ | [`db@1`](database.md) | CRUD records in declared collections | `Rack.db.collection('ideas').create({…})` |
99
+ | [`storage@1`](storage.md) | Participant file uploads | `Rack.storage.put('photos', file)` |
100
+ | [`ai@1`](ai.md) | Declared prompt templates: analysis and chat | `Rack.ai.ask('summarize', vars)` |
101
+ | [`broadcast@1`](broadcast.md) | Rooms, presence, facilitator control, shared state | `Rack.broadcast.join('main')` |
102
+
103
+ Capability versions are independent of the format string and of the Composer package version. See
104
+ [Versioning](versioning.md).
105
+
106
+ Two things need no capability: [declared media](media.md) (`Rack.media.embedUrl(id)`) and
107
+ `Rack.eject()`, which asks the Rack to leave the applet.
108
+
109
+ <a name="what-ziprack-is-not"></a>
110
+ ## What zipRACK Is Not
111
+
112
+ These were considered and settled. Reopening them needs new information, not a fresh opinion.
113
+
114
+ - **Not multi-tenant.** Standalone Racks per engagement. Isolation is infrastructure, not
115
+ application code. Say *deployment*, *Rack* or *fleet* — never *tenant*.
116
+ - **Not two standards.** There is no "experience profile" and "app profile". An experience is an
117
+ applet declaring `attempt@1` and `activity@1`; an app is one declaring `db@1` and not `attempt@1`.
118
+ - **No CRDTs in v1.** Shared room state is server-authoritative, last write wins per key.
119
+ - **No static analysis of applet JS.** Minified bundles defeat it (`window["ev"+"al"]`); the CSP
120
+ does that work at runtime where it cannot be evaded.
121
+ - **No display or kiosk role.** Rooms have participants and facilitators. Multi-screen AV work stays
122
+ bespoke. The general test: *if a capability requires an applet author to reason about device
123
+ identity, transport or deployment topology, it does not belong in zipRACK.*
124
+ - **No offline mode.** Participants are in offices with reliable connectivity.