@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,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.
@@ -0,0 +1,176 @@
1
+ # Database — `db@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Collections](#declaring-collections)
5
+ - [Scopes](#scopes)
6
+ - [Reading and Writing](#reading-and-writing)
7
+ - [Queries](#queries)
8
+ - [Permissions](#permissions)
9
+ - [Quotas](#quotas)
10
+ - [How It Is Stored](#how-it-is-stored)
11
+
12
+ <a name="introduction"></a>
13
+ ## Introduction
14
+
15
+ `db@1` gives an applet CRUD over records in collections it declared, at a scope it declared. This is
16
+ working state: ideas on a board, a team roster, a saved layout, a shared catalog.
17
+
18
+ Records are mutable and arbitrarily shaped, which is exactly why **a JSON Schema is required, not
19
+ optional**. The schema is what the Rack validates writes against, what the admin renders and exports
20
+ records from, and what the installer derives indexes from. Without it, `db@1` would trade the
21
+ uniform export that makes [statements](activity.md) valuable for a pile of untyped JSON.
22
+
23
+ <a name="declaring-collections"></a>
24
+ ## Declaring Collections
25
+
26
+ ```json
27
+ "collections": [
28
+ {
29
+ "name": "ideas",
30
+ "scope": "room",
31
+ "schema": {
32
+ "type": "object",
33
+ "properties": {
34
+ "title": { "type": "string", "maxLength": 120 },
35
+ "body": { "type": "string", "maxLength": 2000 },
36
+ "status": { "type": "string", "enum": ["draft", "posted", "picked"] },
37
+ "votes": { "type": "integer", "minimum": 0 }
38
+ },
39
+ "required": ["title", "status"]
40
+ },
41
+ "indexed": ["status", "votes"],
42
+ "quota": { "max_records": 500, "max_bytes": 16384 },
43
+ "permissions": { "update": "owner", "delete": "facilitator" }
44
+ }
45
+ ]
46
+ ```
47
+
48
+ | Field | Rules |
49
+ |---|---|
50
+ | `name` | Required. `^[a-z][a-z0-9_]*$`, max 40, unique. |
51
+ | `scope` | Required. `attempt`, `user`, `room` or `applet`. |
52
+ | `schema` | Required. JSON Schema, `type: "object"`. |
53
+ | `indexed` | Optional, max 8 paths. Each must be a scalar property declared in the schema. Dotted paths allowed. |
54
+ | `quota` | Optional. `max_records`, `max_bytes` — ceilings you may lower, never raise. |
55
+ | `permissions` | Optional. `update` and `delete`: `owner` (default), `members`, `facilitator`. |
56
+
57
+ <a name="scopes"></a>
58
+ ## Scopes
59
+
60
+ The scope answers, at declaration time, the otherwise ambiguous question of who shares a record and
61
+ what happens to it when an attempt ends.
62
+
63
+ | Scope | Shared by | Lifetime | Requires |
64
+ |---|---|---|---|
65
+ | `attempt` | One attempt | **Frozen when the attempt completes**, alongside `state` | `attempt@1` |
66
+ | `user` | One participant | Outlives any attempt | — |
67
+ | `room` | Everyone in the room | The room | `broadcast@1` |
68
+ | `applet` | Everyone on this applet version | The version. Read-mostly: seed data, shared catalogs | — |
69
+
70
+ A session that has no attempt or no room cannot touch a collection scoped to one; the call is
71
+ refused with `forbidden` rather than silently falling back to something else.
72
+
73
+ <a name="reading-and-writing"></a>
74
+ ## Reading and Writing
75
+
76
+ ```ts
77
+ const ideas = Rack.db.collection<Idea>('ideas');
78
+
79
+ const created = await ideas.create({ title, status: 'draft', votes: 0 });
80
+ const idea = await ideas.get(created.data.record.id);
81
+
82
+ await ideas.update(id, { status: 'posted' }); // merges by default
83
+ await ideas.update(id, next, { merge: false }); // replaces the document
84
+ await ideas.delete(id);
85
+ ```
86
+
87
+ Every call resolves to an `AckResult`. A record comes back as:
88
+
89
+ ```ts
90
+ interface RecordRow<T> {
91
+ id: string; // uuid
92
+ data: T;
93
+ owner_id: number; // the participant who created it
94
+ created_at: string;
95
+ updated_at: string;
96
+ }
97
+ ```
98
+
99
+ Writes are validated against the declared schema **after** the merge, so a partial update that would
100
+ produce an invalid document is refused with `validation` and `details` naming the offending path.
101
+
102
+ <a name="queries"></a>
103
+ ## Queries
104
+
105
+ ```ts
106
+ const { data } = await ideas.list({
107
+ where: [['status', '=', 'posted'], ['votes', '>=', 3]],
108
+ orderBy: 'votes',
109
+ direction: 'desc',
110
+ limit: 50,
111
+ });
112
+
113
+ data.records; // RecordRow<Idea>[]
114
+ data.nextCursor; // pass back as { cursor } for the next page
115
+ ```
116
+
117
+ ```ts
118
+ const { data } = await ideas.count({ where: [['status', '=', 'draft']] });
119
+ ```
120
+
121
+ Operators: `=`, `!=`, `<`, `<=`, `>`, `>=`, `in`.
122
+
123
+ **Filtering and ordering work only on `indexed` paths.** That is not a limitation the SDK could hide
124
+ — it reflects how the data is actually stored (see below). Decide your query paths when you declare
125
+ the collection, and if you find yourself wanting a ninth index, the shape is probably wrong.
126
+
127
+ The page size caps at 100 (`ziprack-db.list_limit`). Lists are always scoped: you cannot read
128
+ another room's records, another participant's `user` records, or another attempt's records, because
129
+ the scope id is resolved from the session, never from your payload.
130
+
131
+ <a name="permissions"></a>
132
+ ## Permissions
133
+
134
+ Reads are scoped; writes are also owned. By default only the creator may update or delete a record.
135
+
136
+ | Value | Who may |
137
+ |---|---|
138
+ | `owner` | The participant who created it. The default. |
139
+ | `members` | Anyone who can see it — everyone in the room, for a `room` collection. |
140
+ | `facilitator` | The owner, plus whoever currently holds the facilitator role. |
141
+
142
+ Moderation of a shared board is `"delete": "facilitator"`. A genuinely collaborative document is
143
+ `"update": "members"`. Anything stricter than `owner` does not exist, because scope already decides
144
+ visibility.
145
+
146
+ <a name="quotas"></a>
147
+ ## Quotas
148
+
149
+ Declared quotas are enforced server-side regardless of what the applet believes:
150
+
151
+ | Limit | Default | Hard ceiling |
152
+ |---|---|---|
153
+ | `max_records` per scope | 500 | 5000 (`ziprack-db.max_records_per_scope`) |
154
+ | `max_bytes` per record | 16 KB | 64 KB (`ziprack-db.max_record_bytes`) |
155
+
156
+ Over quota, `create()` returns `quota_exceeded`. Handle it — a shared board hitting its cap during a
157
+ live session is a real event, not a theoretical one.
158
+
159
+ <a name="how-it-is-stored"></a>
160
+ ## How It Is Stored
161
+
162
+ Worth knowing, because it explains the constraints above.
163
+
164
+ Each collection gets its own MySQL table, created when the applet version installs. The document
165
+ lives in a JSON column; every path in `indexed` becomes a **generated column with an index on it**.
166
+ The declared schema is therefore also the index plan, which is what makes MySQL a good fit here
167
+ rather than a compromise — the queryable paths are known at install time, so there is no need for
168
+ containment queries over undeclared keys.
169
+
170
+ **Installing a version is a schema-bearing operation.** Adding a collection creates a table; adding
171
+ an indexed path adds a generated column; removing one drops it. Collections that no current manifest
172
+ declares any more are left in place until you run:
173
+
174
+ ```sh
175
+ php artisan ziprack:db:prune
176
+ ```