@ziprack/sdk 0.1.1 → 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.
Files changed (47) hide show
  1. package/dist/broadcast/index.d.ts +8 -1
  2. package/dist/core/mock/MockRack.d.ts +6 -0
  3. package/dist/core/types.d.ts +4 -0
  4. package/dist/docs/README.md +52 -0
  5. package/dist/docs/activity.md +140 -0
  6. package/dist/docs/ai.md +159 -0
  7. package/dist/docs/applet-packages.md +118 -0
  8. package/dist/docs/attempts.md +184 -0
  9. package/dist/docs/auth.md +106 -0
  10. package/dist/docs/broadcast.md +226 -0
  11. package/dist/docs/conformance.md +102 -0
  12. package/dist/docs/database.md +176 -0
  13. package/dist/docs/extending.md +256 -0
  14. package/dist/docs/installation.md +185 -0
  15. package/dist/docs/introduction.md +124 -0
  16. package/dist/docs/manifest.md +209 -0
  17. package/dist/docs/media.md +85 -0
  18. package/dist/docs/protocol.md +121 -0
  19. package/dist/docs/publishing.md +127 -0
  20. package/dist/docs/reporting.md +146 -0
  21. package/dist/docs/sdk.md +258 -0
  22. package/dist/docs/security.md +127 -0
  23. package/dist/docs/storage.md +131 -0
  24. package/dist/docs/studio.md +425 -0
  25. package/dist/docs/versioning.md +101 -0
  26. package/dist/docs/ziprack-1-authoring.md +182 -0
  27. package/dist/index.js +11 -2
  28. package/dist/index.js.map +1 -1
  29. package/dist/mcp/bin.d.ts +2 -0
  30. package/dist/mcp/bin.js +3 -0
  31. package/dist/mcp/docs.d.ts +31 -0
  32. package/dist/mcp/docs.js +107 -0
  33. package/dist/mcp/index.d.ts +4 -0
  34. package/dist/mcp/index.js +4 -0
  35. package/dist/mcp/rpc.d.ts +33 -0
  36. package/dist/mcp/rpc.js +86 -0
  37. package/dist/mcp/server.d.ts +23 -0
  38. package/dist/mcp/server.js +55 -0
  39. package/dist/mcp/tools.d.ts +20 -0
  40. package/dist/mcp/tools.js +106 -0
  41. package/dist/{store-DSV5q7C7.js → store-BXGon4iJ.js} +15 -5
  42. package/dist/store-BXGon4iJ.js.map +1 -0
  43. package/dist/testing.js +1 -1
  44. package/dist/ziprack-sdk.iife.js +1 -1
  45. package/dist/ziprack-sdk.iife.js.map +1 -1
  46. package/package.json +10 -4
  47. package/dist/store-DSV5q7C7.js.map +0 -1
@@ -0,0 +1,184 @@
1
+ # Attempts — `attempt@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [The Lifecycle](#the-lifecycle)
5
+ - [Resume State](#resume-state)
6
+ - [Segments](#segments)
7
+ - [Preview Runs](#preview-runs)
8
+ - [Completion](#completion)
9
+ - [Version Pinning](#version-pinning)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ An **attempt** is one participant's run of an applet. It holds three things: a `status`, a `state`
15
+ blob you own, and a `segment` map. The Rack creates it on first launch, hands it back on every
16
+ subsequent launch, and freezes it when you complete it.
17
+
18
+ Declaring `attempt@1` is also the only way an applet becomes version-pinned. That is not a side
19
+ note; see [Version Pinning](#version-pinning).
20
+
21
+ An applet without `attempt@1` is an *app*: no run, no completion, everyone always on the current
22
+ version.
23
+
24
+ <a name="the-lifecycle"></a>
25
+ ## The Lifecycle
26
+
27
+ ```
28
+ launch → in_progress → …work… → complete() → completed → (relaunch lands on the final screen)
29
+ ```
30
+
31
+ ```ts
32
+ const launch = await Rack.init({ slug: 'my-applet' });
33
+
34
+ Rack.attempt.id; // number | null
35
+ Rack.attempt.status; // 'in_progress' | 'completed' | null
36
+ Rack.attempt.state(); // your blob, exactly as last saved
37
+ Rack.attempt.segment(); // Record<string, string> | null
38
+ ```
39
+
40
+ Every applet declaring `attempt@1` owes the participant this at boot:
41
+
42
+ ```ts
43
+ if (Rack.attempt.status === 'completed') {
44
+ return renderFinalScreen();
45
+ }
46
+
47
+ const saved = Rack.attempt.state<State>() ?? initialState();
48
+ renderStep(saved.step);
49
+ ```
50
+
51
+ <a name="resume-state"></a>
52
+ ## Resume State
53
+
54
+ `state` is a JSON object, capped at **64 KB encoded** (`ziprack-attempt.max_state_bytes`). It is for
55
+ resuming position, plus anything cheap that makes a return visit feel continuous — a draft in a
56
+ textarea, a scroll offset, which cards are flipped.
57
+
58
+ ```ts
59
+ void Rack.attempt.setState({ step: 4, draft: textarea.value });
60
+ ```
61
+
62
+ Call it after every transition. You do not need to debounce: the shell coalesces pending
63
+ `attempt:state` messages so only the latest value is sent in each batch. You do not need to await
64
+ it either — the local copy updates immediately, so `Rack.attempt.state()` reflects your write even
65
+ before the ack lands.
66
+
67
+ **State is not data capture.** An answer the participant committed belongs in a
68
+ [statement](activity.md); if it exists only in `state`, it will not appear in any export, and a
69
+ completed attempt will freeze it out of reach. The test: if someone would want it in a CSV, it is a
70
+ statement.
71
+
72
+ <a name="segments"></a>
73
+ ## Segments
74
+
75
+ Segments are the dimensions admins filter and group by — track, cohort, region. Declare them:
76
+
77
+ ```json
78
+ "segments": [
79
+ {
80
+ "key": "track",
81
+ "label": "Track",
82
+ "values": [
83
+ { "key": "rd", "label": "R&D" },
84
+ { "key": "commercial", "label": "Commercial" }
85
+ ]
86
+ }
87
+ ]
88
+ ```
89
+
90
+ and set them as soon as they are known:
91
+
92
+ ```ts
93
+ await Rack.attempt.setSegment({ track: 'rd' });
94
+ ```
95
+
96
+ At most 20 keys, string values up to 100 characters. The admin UI filters by the **first** declared
97
+ segment, so put the one people will actually slice by first.
98
+
99
+ Segments also drive activity scoping: an activity declaring `"segment": "track"` is understood to
100
+ exist only for participants in that track, which keeps export columns honest.
101
+
102
+ <a name="preview-runs"></a>
103
+ ## Preview Runs
104
+
105
+ A participant gets exactly one attempt per applet, which is correct for them and useless for the
106
+ person who has to check the thing works. Staff get a second track:
107
+
108
+ ```
109
+ GET /a/{slug}?preview=1 resume my open preview run
110
+ GET /a/{slug}?preview=new start a fresh one
111
+ ```
112
+
113
+ Both require the `ziprack.admin` ability. For anyone else the parameter is **ignored rather than
114
+ refused**, so a link pasted into a chat never dead-ends a participant on an error page.
115
+
116
+ A preview run is a real attempt — same pinning, same statements, same records, same AI budget — but
117
+ it carries a Rack-owned `is_preview` flag, and that flag is not the applet's to set. `attempt@1`
118
+ gives you `setSegment()`; it does not give you a way to declare yourself a preview. An applet cannot
119
+ tell the difference and should not try.
120
+
121
+ Reporting hides them. The attempts table, the overview counts, the metric cards and the CSV export
122
+ all default to live attempts only:
123
+
124
+ ```http
125
+ GET /ziprack/admin/applets/{applet}/attempts live runs (the default)
126
+ GET /ziprack/admin/applets/{applet}/attempts?preview=preview
127
+ GET /ziprack/admin/applets/{applet}/attempts?preview=all
128
+ ```
129
+
130
+ Rows carry `is_preview` so a UI showing `preview` or `all` can badge them.
131
+
132
+ The live attempt keeps `attempt_number` 1 for the lifetime of the applet; preview runs take the
133
+ numbers above it. Starting a fresh preview never touches the live run, so you can hammer the preview
134
+ button on an applet you have already completed for real without disturbing your own record of it.
135
+
136
+ What preview does **not** do is partition capability storage. Statements and records belong to their
137
+ attempt and follow it, but per-applet aggregates — [`ai@1`](ai.md) spend, the
138
+ [`storage@1`](storage.md) gallery — count preview activity like any other. Budget accordingly on a
139
+ Rack where people preview a lot.
140
+
141
+ <a name="completion"></a>
142
+ ## Completion
143
+
144
+ ```ts
145
+ await Rack.activity.answer('outro.confidence', 5); // confirm the last answer first
146
+ await Rack.attempt.complete({ score: 42 }); // optional summary
147
+ ```
148
+
149
+ `complete()` is **idempotent** — calling it twice is not an error, and the second call returns the
150
+ same result. After it:
151
+
152
+ - `setState()` and `setSegment()` are refused with `completed`.
153
+ - Attempt-scoped [records](database.md) freeze.
154
+ - The Rack flips its own completion chrome, and a relaunch lands on the final screen rather than
155
+ restarting the experience.
156
+
157
+ That last point is a contract, not a courtesy: build a final screen worth landing on.
158
+
159
+ Which is why the Rack's completion card is **dismissible**. It announces the moment once, over the
160
+ top of whatever you just rendered; "Keep looking around" (or Escape) shrinks it to a corner toast
161
+ that keeps the way out without covering your screen. Nothing reopens it, and a relaunch of a
162
+ completed attempt shows no card at all — so the last thing the participant sees is yours, not the
163
+ Rack's.
164
+
165
+ <a name="version-pinning"></a>
166
+ ## Version Pinning
167
+
168
+ > **Requesting `attempt@1` implies pinned. Its absence implies rolling.**
169
+
170
+ One rule, derived rather than declared, with no manifest field to get wrong.
171
+
172
+ **Why they are opposed.** An attempt must stay on the version it started on: activity ids cannot
173
+ shift mid-run without corrupting the export. An app must roll: every participant on the current
174
+ version, always, or people are stranded on stale builds.
175
+
176
+ Consequences worth knowing before you ship:
177
+
178
+ - An applet that adds `attempt@1` in a later version changes its own update semantics. The
179
+ [capability diff](publishing.md#capability-diff-review) flags this prominently, and it requires
180
+ re-approval.
181
+ - In-progress attempts continue to pin to their starting version after you promote a new one. Two
182
+ versions run side by side until the last one drains.
183
+ - Changing an activity `id` between versions splits export columns. Changing a `label` only changes
184
+ the header.
@@ -0,0 +1,106 @@
1
+ # Authentication — `auth@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Attributes](#declaring-attributes)
5
+ - [Reading the Participant](#reading-the-participant)
6
+ - [Degrading Gracefully](#degrading-gracefully)
7
+ - [Rack Side](#rack-side)
8
+
9
+ <a name="introduction"></a>
10
+ ## Introduction
11
+
12
+ `auth@1` answers one question for the applet — *who is this, and what am I allowed to know about
13
+ them* — and one question for the deployment: *what can applets see about our people?* The second is
14
+ the question a client's procurement team will ask, and this capability is where it has a single
15
+ answer.
16
+
17
+ Profile attributes are **Rack-owned and applet-requested**. Never applet-written. There is no
18
+ `Rack.auth.set()`, and there never will be within `auth@1`.
19
+
20
+ This capability has no runtime messages at all: the released profile travels in the launch payload.
21
+
22
+ <a name="declaring-attributes"></a>
23
+ ## Declaring Attributes
24
+
25
+ ```json
26
+ "requires": {
27
+ "capabilities": ["auth@1"],
28
+ "profile": ["display_name", "business_unit", "locale"]
29
+ }
30
+ ```
31
+
32
+ Keys match `^[a-z][a-z0-9_]*$`. Requesting an attribute is not obtaining it: the deployment's
33
+ `ziprack-auth.php` lists what may ever be released, and anything outside that list is withheld
34
+ silently.
35
+
36
+ ```php
37
+ // config/ziprack-auth.php
38
+ 'releasable' => ['display_name', 'email', 'locale'], // ZIPRACK_AUTH_RELEASABLE
39
+
40
+ 'claim_map' => [
41
+ 'display_name' => 'name',
42
+ 'email' => 'email',
43
+ ],
44
+ ```
45
+
46
+ `display_name` is always released. Values stored through a roster import or `ziprack:profile:set`
47
+ override the claim map, which is how attributes that do not exist on the user model — cohort,
48
+ business unit, region — reach an applet.
49
+
50
+ <a name="reading-the-participant"></a>
51
+ ## Reading the Participant
52
+
53
+ ```ts
54
+ const launch = await Rack.init({ slug: 'my-applet' });
55
+
56
+ launch.actor; // { id, name } — always present, no capability needed
57
+ Rack.auth.user(); // { id, display_name, …released attributes } | null
58
+
59
+ Rack.auth.attribute('business_unit'); // unknown → null
60
+ Rack.auth.attribute('locale', 'en'); // unknown → 'en'
61
+ Rack.auth.attribute<string[]>('regions', []); // typed
62
+ ```
63
+
64
+ Both are synchronous reads of launch data, so they are free to call in a render path.
65
+
66
+ `actor` is the participant the Rack resolved: a logged-in user, or a cookie-keyed guest when the
67
+ deployment enables guests. `Rack.auth.user()` is the *profile*, and exists only when `auth@1` is
68
+ declared.
69
+
70
+ <a name="degrading-gracefully"></a>
71
+ ## Degrading Gracefully
72
+
73
+ An attribute you requested may be absent because the deployment does not release it, because nobody
74
+ imported it, or because this participant is a guest. All three look the same to you, and all three
75
+ must work.
76
+
77
+ ```ts
78
+ const unit = Rack.auth.attribute<string>('business_unit');
79
+
80
+ const intro = unit
81
+ ? `Here is what the ${unit} team said last quarter.`
82
+ : `Here is what your colleagues said last quarter.`;
83
+ ```
84
+
85
+ Design the withheld path first and treat personalisation as an enhancement. An applet that renders
86
+ `Hello, undefined` when an attribute is missing has failed validation of a kind the Rack cannot
87
+ perform for you.
88
+
89
+ <a name="rack-side"></a>
90
+ ## Rack Side
91
+
92
+ ```sh
93
+ php artisan ziprack:profile:set ada@example.com business_unit "R&D"
94
+ php artisan ziprack:roster:import people.csv # email column + one column per attribute
95
+ ```
96
+
97
+ Admin endpoints:
98
+
99
+ | Route | Purpose |
100
+ |---|---|
101
+ | `GET /ziprack/admin/participants/{participant}/profile` | Current attributes and their sources. |
102
+ | `PUT /ziprack/admin/participants/{participant}/profile` | Set attributes for one person. |
103
+
104
+ To source attributes from SSO claims instead, bind
105
+ `ZipRack\Auth\Contracts\ProfileAttributeSource`; `ClaimMapSource` is the default implementation and
106
+ a reasonable starting point.
@@ -0,0 +1,226 @@
1
+ # Broadcast — `broadcast@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Rooms](#rooms)
5
+ - [Declaring Channels and Facilitator Events](#declaring-channels-and-facilitator-events)
6
+ - [Joining and Publishing](#joining-and-publishing)
7
+ - [Inviting People In](#inviting-people-in)
8
+ - [Presence](#presence)
9
+ - [Shared State](#shared-state)
10
+ - [The Facilitator Role](#the-facilitator-role)
11
+ - [Developing Against Rooms](#developing-against-rooms)
12
+
13
+ <a name="introduction"></a>
14
+ ## Introduction
15
+
16
+ `broadcast@1` is what makes group work repeatable: a climb with base camps, teams on separate
17
+ boards, a live trivia round, a facilitated card game. It gives an applet rooms, presence, published
18
+ events, a facilitator role and a shared key/value state.
19
+
20
+ **The Rack holds the WebSocket, not the applet.** You publish and subscribe over `postMessage`; the
21
+ Rack owns the socket and its authentication. An applet can never obtain or leak a channel token,
22
+ and the trust model is identical to every other capability.
23
+
24
+ Transport is Laravel Reverb. That is a deployment concern: nothing in the applet API mentions it.
25
+
26
+ <a name="rooms"></a>
27
+ ## Rooms
28
+
29
+ A **room** is an applet version plus its members. Every member has exactly one of two roles —
30
+ `participant` or `facilitator` — and membership is orthogonal to attempts: a room may contain
31
+ attempts (each person running their own inside a shared session) or none at all.
32
+
33
+ Rooms are created by an admin, or from the console:
34
+
35
+ ```sh
36
+ php artisan ziprack:room:create team-summit --name="Boston cohort" --mode=first_in
37
+ # → join code: 7FQK2M → https://rack.example.com/r/7FQK2M
38
+ ```
39
+
40
+ Participants arrive by code or QR, which is how CN already runs events.
41
+
42
+ | Mode | Facilitator assignment |
43
+ |---|---|
44
+ | `opt_in` | Anyone in the room may claim the role. |
45
+ | `first_in` | Granted automatically to the first joiner. |
46
+ | `roster` | A preset list of people, resolved through `auth@1`. |
47
+
48
+ Only `roster` depends on `auth@1`, so light group activities can run without an identity source at
49
+ all — see [guests](installation.md#participants-and-guests).
50
+
51
+ The room arrives in launch data:
52
+
53
+ ```ts
54
+ Rack.broadcast.room; // { id, code, name, status, mode } | null
55
+ Rack.broadcast.role; // 'participant' | 'facilitator' | null
56
+ Rack.broadcast.isFacilitator;
57
+ Rack.broadcast.members(); // RoomMember[]
58
+ ```
59
+
60
+ `room` is `null` when the applet was launched outside a room. Handle it: the same applet is often
61
+ launched both ways.
62
+
63
+ <a name="declaring-channels-and-facilitator-events"></a>
64
+ ## Declaring Channels and Facilitator Events
65
+
66
+ ```json
67
+ "broadcast": {
68
+ "channels": ["main", "board"],
69
+ "facilitator_events": ["advance", "lock", "reveal"]
70
+ }
71
+ ```
72
+
73
+ Channels default to `["main"]` if you declare none, cap at 20, and are namespaced to the room — an
74
+ applet cannot address another room's channel. `$room` is reserved for the Rack.
75
+
76
+ **`facilitator_events` is enforcement, not documentation.** An event named there is refused with
77
+ `forbidden` unless the sender currently holds the facilitator role. Facilitator control is therefore
78
+ not a separate capability: advancing, locking and revealing are ordinary events the Rack polices,
79
+ and the applet decides what they mean.
80
+
81
+ <a name="joining-and-publishing"></a>
82
+ ## Joining and Publishing
83
+
84
+ ```ts
85
+ const main = await Rack.broadcast.join('main');
86
+
87
+ const stop = main.listen<{ step: number }>('advance', (payload, from) => {
88
+ goToStep(payload.step); // from.participant_id, from.role
89
+ });
90
+
91
+ main.listen('*', ({ event, payload }) => log(event, payload));
92
+
93
+ if (Rack.broadcast.isFacilitator) {
94
+ await main.publish('advance', { step: 3 });
95
+ }
96
+
97
+ stop(); // unsubscribe one listener
98
+ await main.leave(); // leave the channel
99
+ ```
100
+
101
+ Payloads cap at 16 KB (`ziprack-broadcast.max_event_bytes`). Every published event is persisted, so
102
+ the admin can replay a session's facilitator timeline afterwards.
103
+
104
+ Events are delivered to everyone in the room, including the sender. Design handlers to be idempotent
105
+ and to tolerate arriving out of order relative to your own optimistic updates.
106
+
107
+ <a name="inviting-people-in"></a>
108
+ ## Inviting People In
109
+
110
+ An applet on a projector often *is* the way people get into the room. Ask the Rack where to
111
+ point them:
112
+
113
+ ```ts
114
+ const { ok, data } = await Rack.broadcast.invite();
115
+ // data → { code: '7FQK2M', url: 'https://rack.example.com/r/7FQK2M' }
116
+ ```
117
+
118
+ The Rack holds the URL because a sandboxed applet cannot see the Rack's host, and the join
119
+ prefix is a deployment setting (`ZIPRACK_ROOM_PREFIX`). What you do with it is yours: print the
120
+ code on a slide, render the URL as a QR with whatever library you already bundle, or both.
121
+
122
+ ```ts
123
+ if (ok) {
124
+ board.append(qr(data.url)); // your encoder, your styling
125
+ code.textContent = data.code;
126
+ }
127
+ ```
128
+
129
+ There is no facilitator gate. The room code is already in every member's launch payload and can
130
+ be read aloud in two seconds, so refusing the URL would protect nothing and would break the rule
131
+ that an applet stays usable while nobody holds the role.
132
+
133
+ The call is **refused with `conflict` once the room is locked or closed**, because `/r/{code}`
134
+ turns those scanners away. Handle it — that is the signal to take a stale QR off the screen:
135
+
136
+ ```ts
137
+ Rack.broadcast.on('status', async ({ status }) => {
138
+ board.hidden = status !== 'open';
139
+ });
140
+ ```
141
+
142
+ Non-members get `forbidden`, as they do for `presence`. In mock mode the invite is the dev
143
+ server page itself, so scanning it opens the second tab that forms the room.
144
+
145
+ <a name="presence"></a>
146
+ ## Presence
147
+
148
+ ```ts
149
+ const { data } = await Rack.broadcast.presence();
150
+ data.members; // [{ id, name, role, present }]
151
+
152
+ Rack.broadcast.on('presence', ({ members }) => renderRoster(members));
153
+ ```
154
+
155
+ A member counts as present when seen within 45 s (`ziprack-broadcast.presence_window_seconds`); the
156
+ shell heartbeats for you. Treat presence as approximate — it is a roster, not a lock.
157
+
158
+ Room-level events you can subscribe to:
159
+
160
+ | Event | Payload |
161
+ |---|---|
162
+ | `presence` | `{ members }` |
163
+ | `state` | `{ key, value, version }` |
164
+ | `role` | `{ role, participant_id }` |
165
+ | `status` | `{ status }` — `open`, `locked`, `closed` |
166
+
167
+ <a name="shared-state"></a>
168
+ ## Shared State
169
+
170
+ Server-authoritative, last write wins **per key**, with the Rack stamping a version.
171
+
172
+ ```ts
173
+ await Rack.broadcast.state.set('phase', 'voting');
174
+
175
+ const { data } = await Rack.broadcast.state.get(['phase', 'timer']);
176
+ Rack.broadcast.state.snapshot(); // launch snapshot + everything pushed since, synchronously
177
+
178
+ Rack.broadcast.on('state', ({ key, value, version }) => apply(key, value));
179
+ ```
180
+
181
+ Up to 200 keys, 16 KB per value. Use it for the small facts everyone must agree on — the current
182
+ phase, which card is face up, whether submissions are open — and use [`db@1`](database.md) room
183
+ collections for the content itself.
184
+
185
+ This is deliberately not a CRDT. If two people write the same key at the same moment, one of them
186
+ wins and everyone converges on that value. Design keys so that matters as little as possible:
187
+ one writer per key is the intended shape, and a facilitator-only key is the easiest way to get it.
188
+
189
+ <a name="the-facilitator-role"></a>
190
+ ## The Facilitator Role
191
+
192
+ ```ts
193
+ await Rack.broadcast.claim(); // opt_in rooms
194
+ await Rack.broadcast.transfer(participantId); // hand it on
195
+
196
+ Rack.broadcast.on('role', ({ role, participant_id }) => {
197
+ if (participant_id === launch.actor.id) renderFacilitatorControls(role === 'facilitator');
198
+ });
199
+ ```
200
+
201
+ **The grant must survive a dropped connection.** A facilitator unseen for 60 s
202
+ (`ziprack-broadcast.facilitator_grace_seconds`) is vacated by `ziprack:rooms:tick`, and in a
203
+ `first_in` room the role is reassigned automatically. Schedule that command every minute, or a
204
+ closed laptop strands a room.
205
+
206
+ Build the participant view first and layer facilitator controls on top of it. Every applet using
207
+ rooms must be usable in the window where nobody holds the role.
208
+
209
+ <a name="developing-against-rooms"></a>
210
+ ## Developing Against Rooms
211
+
212
+ Mock mode implements rooms over `BroadcastChannel`, so **two tabs of your dev server form a room**.
213
+ Presence, published events, shared state and role transfer all work between them; the first tab to
214
+ open takes the facilitator role by default.
215
+
216
+ ```json
217
+ "broadcast": { "channels": ["main"], "facilitator_events": ["advance"], "mock_mode": "opt_in" }
218
+ ```
219
+
220
+ `mock_mode` is read only by the mock and lets you rehearse `opt_in` claiming locally. For automated
221
+ tests, drive the other side explicitly:
222
+
223
+ ```ts
224
+ host.push({ event: 'advance', payload: { step: 2 }, channel: 'main',
225
+ from: { participant_id: 99, role: 'facilitator' } });
226
+ ```
@@ -0,0 +1,102 @@
1
+ # Conformance
2
+
3
+ - [Why It Exists](#why-it-exists)
4
+ - [Running the Suite](#running-the-suite)
5
+ - [What It Checks](#what-it-checks)
6
+ - [In CI](#in-ci)
7
+ - [Fleet Visibility](#fleet-visibility)
8
+
9
+ <a name="why-it-exists"></a>
10
+ ## Why It Exists
11
+
12
+ zipRACK's central promise is that an applet built against one Rack runs on another. Without
13
+ something that verifies it, "standalone Racks united by a common format" is an aspiration.
14
+
15
+ `ziprack/conformance` is a dev dependency every Rack installs and runs in CI. It asserts the
16
+ deployment still honours `ziprack/1` — including a Rack someone has customised, which is exactly the
17
+ case where you want proof rather than good intentions.
18
+
19
+ <a name="running-the-suite"></a>
20
+ ## Running the Suite
21
+
22
+ ```sh
23
+ php artisan ziprack:conformance
24
+ php artisan ziprack:conformance --json
25
+ ```
26
+
27
+ The suite runs inside a transaction that is rolled back, so it is safe against a live database. Each
28
+ check builds its own fixture — a temporary applet, a version, a package — and cleans up after itself.
29
+
30
+ ```
31
+ ✓ manifest requires schema
32
+ ✓ installer rejects unsafe zips
33
+ ✓ unimplemented capability is refused
34
+ ✓ undeclared capability is refused
35
+ ✓ csp is tightened
36
+ ✓ retired version is gone
37
+ ✓ promotion requires review
38
+ ✓ completed attempt is frozen
39
+ ✓ attempts are pinned
40
+ ✓ statements are append-only
41
+ ✓ records are scoped and validated
42
+ ```
43
+
44
+ <a name="what-it-checks"></a>
45
+ ## What It Checks
46
+
47
+ Core checks run on every Rack:
48
+
49
+ | Check | Asserts |
50
+ |---|---|
51
+ | `ManifestRequiresSchema` | A manifest missing or misstating `schema` fails validation with the expected error. |
52
+ | `InstallerRejectsUnsafeZips` | A zip containing `..`, an absolute path or a symlink is refused. |
53
+ | `UnimplementedCapabilityIsRefused` | An applet requesting a capability this Rack lacks is refused at install. |
54
+ | `UndeclaredCapabilityIsRefused` | A runtime call the manifest did not declare acks `undeclared_capability`. |
55
+ | `CspIsTightened` | The response CSP contains the required directives, and `img-src` does not contain `https:`. |
56
+ | `RetiredVersionIsGone` | A retired version can no longer be launched. |
57
+ | `PromotionRequiresReview` | A version adding a capability cannot be promoted unapproved. |
58
+
59
+ Each installed capability contributes its own, so the suite grows with the Rack:
60
+
61
+ | Check | Capability |
62
+ |---|---|
63
+ | `CompletedAttemptIsFrozen`, `AttemptsArePinned` | `attempt` |
64
+ | `StatementsAreAppendOnly` | `activity` |
65
+ | `RecordsAreScopedAndValidated` | `db` |
66
+ | `UploadsAreScopedAndTyped` | `storage` |
67
+ | `AiBudgetsAreEnforced` | `ai` |
68
+ | `FacilitatorEventsAreEnforced` | `broadcast` |
69
+
70
+ <a name="in-ci"></a>
71
+ ## In CI
72
+
73
+ Publish a test that runs the suite inside the deployment's own Pest or PHPUnit run:
74
+
75
+ ```sh
76
+ php artisan ziprack:conformance:install
77
+ ```
78
+
79
+ which writes `tests/Feature/ZipRackConformanceTest.php`. From then on the deployment's normal test
80
+ command is also its conformance gate, and a customisation that breaks the contract fails the build
81
+ rather than a client's security review.
82
+
83
+ <a name="fleet-visibility"></a>
84
+ ## Fleet Visibility
85
+
86
+ ```sh
87
+ php artisan ziprack:status
88
+ php artisan ziprack:status --json
89
+ ```
90
+
91
+ ```http
92
+ GET /ziprack/health # Authorization: Bearer {ZIPRACK_HEALTH_TOKEN}
93
+ ```
94
+
95
+ Both report the format string, the core version, every installed capability package with the
96
+ capability versions it implements, and any warnings a capability raises — for example
97
+ `storage@1` running the null scanner, or a CSP host configuration that would reopen an exfiltration
98
+ channel.
99
+
100
+ The question this answers is **"which deployments still need the fix?"** without SSH-ing into eight
101
+ machines. That question gets asked during a client security review, usually with a deadline
102
+ attached.