@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,52 @@
1
+ # zipRACK Documentation
2
+
3
+ zipRACK is a micro app framework: a Laravel host — **the Rack** — that runs sandboxed static web
4
+ apps — **applets** — and services their capability requests over `postMessage`. An applet owns
5
+ everything the participant sees. The Rack owns login, persistence, AI access, real-time coordination
6
+ and reporting.
7
+
8
+ These docs cover both halves. If you are building an applet, start with
9
+ [Applet Packages](applet-packages.md). If you are standing up or maintaining a Rack, start with
10
+ [Installation](installation.md).
11
+
12
+ ## Prologue
13
+
14
+ - [Introduction](introduction.md) — the trust boundary, the core model, the objects
15
+ - [Versioning](versioning.md) — the three version axes and why they never couple
16
+
17
+ ## Building an applet
18
+
19
+ - [Applet Packages](applet-packages.md) — the zip, the installer rules, the starter
20
+ - [The Manifest](manifest.md) — every field, every rule the validator enforces
21
+ - [The JavaScript SDK](sdk.md) — `Rack.init()`, acks, errors, mock mode, testing
22
+ - [zipRACK Studio](studio.md) — the visual builder, the derived manifest, the MCP server
23
+ - [The Bridge Protocol](protocol.md) — the envelope, for anyone not using the SDK
24
+
25
+ ## Capabilities
26
+
27
+ - [Authentication](auth.md) — `auth@1`
28
+ - [Attempts](attempts.md) — `attempt@1`
29
+ - [Activity](activity.md) — `activity@1`
30
+ - [Database](database.md) — `db@1`
31
+ - [Storage](storage.md) — `storage@1`
32
+ - [AI](ai.md) — `ai@1`
33
+ - [Broadcast](broadcast.md) — `broadcast@1`
34
+ - [Media](media.md) — declared embeds, no capability required
35
+
36
+ ## Running a Rack
37
+
38
+ - [Installation](installation.md) — composing a Rack from capability packages
39
+ - [Security](security.md) — the sandbox, the CSP, the threats it does not cover
40
+ - [Publishing](publishing.md) — install, capability diff, review queue, promotion, kill switch
41
+ - [Reporting](reporting.md) — the admin API, CSV export, metrics
42
+ - [Conformance](conformance.md) — proving a deployment is still a Rack
43
+
44
+ ## Extending
45
+
46
+ - [Writing a Capability](extending.md) — the `Capability` interface, end to end
47
+
48
+ ---
49
+
50
+ Also in this repository: [`ziprack-1-authoring.md`](ziprack-1-authoring.md), a one-page condensed
51
+ brief suitable for dropping into an applet project as `CLAUDE.md`. The same content, expanded, is
52
+ published to the CN IDEAS skills library as an instruction set.
@@ -0,0 +1,140 @@
1
+ # Activity — `activity@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Activities](#declaring-activities)
5
+ - [Emitting Statements](#emitting-statements)
6
+ - [Activity Types](#activity-types)
7
+ - [Metrics](#metrics)
8
+ - [Designing for the Export](#designing-for-the-export)
9
+
10
+ <a name="introduction"></a>
11
+ ## Introduction
12
+
13
+ A **statement** is an append-only record that *an actor did a verb on an activity with a result*.
14
+ It is the audit trail, and because every statement has the same shape, it is also the one export
15
+ that works identically for every applet on every Rack.
16
+
17
+ Statements are never updated and never deleted. Re-answering is normal: reporting reads the **last**
18
+ `answered` statement per activity per attempt.
19
+
20
+ `db@1` does not replace this. Statements are what happened; [records](database.md) are what is
21
+ currently true.
22
+
23
+ <a name="declaring-activities"></a>
24
+ ## Declaring Activities
25
+
26
+ Every question you report on must be declared. At least one activity is required.
27
+
28
+ ```json
29
+ "activities": [
30
+ { "id": "intro.connection", "type": "rating", "label": "How connected do you feel to patients?",
31
+ "scale": { "min": 1, "max": 6 }, "sequence": 1 },
32
+ { "id": "rd.corine.reaction", "type": "text", "label": "What stood out to you about Corine's story?",
33
+ "segment": "track", "sequence": 2 }
34
+ ]
35
+ ```
36
+
37
+ | Field | Rules |
38
+ |---|---|
39
+ | `id` | Required, max 150, unique. **This is the data contract.** |
40
+ | `type` | Required, max 100. Drives how the admin renders and exports the value. |
41
+ | `label` | Required, max 1000. Becomes the CSV header and the analysis heading. |
42
+ | `scale` | Optional object, for `rating` and `scale` types. |
43
+ | `segment` | Optional. Must name a declared [segment](attempts.md#segments) key, or be `null` for shared. |
44
+ | `sequence` | Optional integer ≥ 0. Controls export column order; defaults to array position. |
45
+
46
+ Use stable, dotted, lowercase ids: `{phase}.{topic}.{question}`. An undeclared id still records, but
47
+ it exports unlabeled and appended after every declared column — useful as a safety net, never as a
48
+ plan.
49
+
50
+ <a name="emitting-statements"></a>
51
+ ## Emitting Statements
52
+
53
+ ```ts
54
+ // The common case: a committed answer.
55
+ await Rack.activity.answer('intro.connection', 5);
56
+
57
+ // Navigation and progress. Fire and forget.
58
+ void Rack.activity.progress('rd.chapter_2');
59
+
60
+ // Anything else.
61
+ void Rack.activity.statement({
62
+ verb: 'viewed',
63
+ activity: { id: 'rd.corine.video', type: 'media' },
64
+ result: { duration_seconds: 184 },
65
+ context: { autoplay: false },
66
+ });
67
+ ```
68
+
69
+ `answer()` emits `verb: 'answered'` with the value in `result.response` — **the only field reporting
70
+ reads** — and fills `activity.type` from your manifest declaration. `progress()` emits
71
+ `verb: 'progressed'` with `activity.type: 'step'`.
72
+
73
+ Verbs match `^[a-z][a-z0-9_.:-]*$`. `occurred_at` defaults to now, in ISO 8601; pass it yourself only
74
+ when replaying something that genuinely happened earlier.
75
+
76
+ Read your own declarations when you need them:
77
+
78
+ ```ts
79
+ Rack.activity.activities(); // LaunchActivity[]
80
+ Rack.activity.activity('intro.connection'); // { id, type, label, scale, … } | null
81
+ ```
82
+
83
+ Rendering a question from its declared `label` and `scale` keeps the participant's wording and the
84
+ CSV header identical by construction — a small discipline that pays for itself the first time
85
+ someone edits a question.
86
+
87
+ <a name="activity-types"></a>
88
+ ## Activity Types
89
+
90
+ `type` is free text, but the admin understands these:
91
+
92
+ | `type` | `result.response` | Used for |
93
+ |---|---|---|
94
+ | `text` | string | Free text. Feeds admin AI response analysis. |
95
+ | `rating` | number within `scale` | Likert or self-rating. Eligible for metrics. |
96
+ | `scale` | number within `scale` | Semantic alias of `rating`. |
97
+ | `choice` | string key, or array of keys | Single or multi-select. |
98
+ | `custom` | any JSON | Anything else. Exported JSON-encoded. |
99
+
100
+ <a name="metrics"></a>
101
+ ## Metrics
102
+
103
+ A metric pairs a pre and a post rating of the same thing, and gives the admin a shift card rather
104
+ than two columns to eyeball.
105
+
106
+ ```json
107
+ "reporting": {
108
+ "metrics": [
109
+ { "key": "connection", "label": "Connection",
110
+ "pre": "intro.connection", "post": "outro.connection" }
111
+ ]
112
+ }
113
+ ```
114
+
115
+ `pre` and `post` must be declared activity ids, and metrics require `attempt@1`, because they are
116
+ computed over completed attempts. Both activities should use the same `scale`, or the shift means
117
+ nothing.
118
+
119
+ <a name="designing-for-the-export"></a>
120
+ ## Designing for the Export
121
+
122
+ The CSV an admin downloads is:
123
+
124
+ ```
125
+ ID, Name, Email, {first segment label}, Status, Started At, Completed At,
126
+ {declared activities in sequence order, headed by label},
127
+ {undeclared activity ids, appended}
128
+ ```
129
+
130
+ Each cell is the last `result.response` for that activity in that attempt; non-scalars are
131
+ JSON-encoded. Which yields three rules worth writing on the wall:
132
+
133
+ 1. **`id` is the data contract.** Renaming one between versions splits a column: old attempts keep
134
+ the old column, new attempts fill a new one.
135
+ 2. **`label` is the header.** Renaming one is free, and changes only how the column reads.
136
+ 3. **`sequence` is the column order.** Set it deliberately; the export reads top to bottom the way
137
+ the experience does.
138
+
139
+ Write labels as full sentences, exactly as the participant sees them. An admin reading a
140
+ spreadsheet six months later has no other copy of the question.
@@ -0,0 +1,159 @@
1
+ # AI — `ai@1`
2
+
3
+ - [Introduction](#introduction)
4
+ - [Declaring Prompts](#declaring-prompts)
5
+ - [Analysis](#analysis)
6
+ - [Chat](#chat)
7
+ - [Budgets](#budgets)
8
+ - [Untrusted Variables](#untrusted-variables)
9
+ - [Not This Capability](#not-this-capability)
10
+
11
+ <a name="introduction"></a>
12
+ ## Introduction
13
+
14
+ **The applet never sees a key and never sends a prompt.** It calls a prompt template the manifest
15
+ declared and the Rack stores, supplying *variables*, never instructions.
16
+
17
+ ```ts
18
+ const { data } = await Rack.ai.ask('score_pitch', { pitch, track });
19
+ ```
20
+
21
+ Four things fall out of that design at once:
22
+
23
+ - Prompt injection *from the applet* is structurally impossible — there is no instruction channel.
24
+ - Model, system prompt and output schema are Rack-owned, so they can be tuned or swapped without
25
+ republishing the applet.
26
+ - Cost is governable: hard caps per prompt, per attempt, per participant, per applet, enforced
27
+ server-side.
28
+ - **No CSP change is required.** The call travels as a `postMessage`, not a `fetch`.
29
+
30
+ <a name="declaring-prompts"></a>
31
+ ## Declaring Prompts
32
+
33
+ ```json
34
+ "prompts": [
35
+ {
36
+ "id": "score_pitch",
37
+ "mode": "analysis",
38
+ "template": "Score this team pitch for clarity and specificity.\n\nPitch: {{pitch}}\nTrack: {{track}}",
39
+ "vars": {
40
+ "type": "object",
41
+ "properties": { "pitch": { "type": "string" }, "track": { "type": "string" } },
42
+ "required": ["pitch"]
43
+ },
44
+ "output_schema": {
45
+ "type": "object",
46
+ "properties": {
47
+ "clarity": { "type": "integer", "minimum": 1, "maximum": 5 },
48
+ "feedback": { "type": "string" }
49
+ },
50
+ "required": ["clarity", "feedback"]
51
+ },
52
+ "budget": { "max_calls_per_attempt": 3, "max_output_tokens": 400 }
53
+ }
54
+ ]
55
+ ```
56
+
57
+ | Field | Rules |
58
+ |---|---|
59
+ | `id` | Required. `^[a-z][a-z0-9_.-]*$`, max 100, unique. |
60
+ | `mode` | Required. `analysis` or `chat`. |
61
+ | `template` | Required, max 20 000. `{{placeholder}}` for each variable. |
62
+ | `vars` | Optional JSON Schema (`type: object`). **If present, every placeholder must be declared in it.** |
63
+ | `output_schema` | Required for `analysis`, `type: object`. |
64
+ | `budget` | Optional. `max_calls_per_attempt`, `max_calls_per_participant`, `max_output_tokens`. |
65
+ | `model` | Optional hint. Honoured only if the deployment allowlists it. |
66
+
67
+ <a name="analysis"></a>
68
+ ## Analysis
69
+
70
+ Structured output, conforming to the declared `output_schema`. For scoring a response, summarising
71
+ back to the participant, or branching on what someone wrote.
72
+
73
+ ```ts
74
+ interface Score { clarity: number; feedback: string }
75
+
76
+ const result = await Rack.ai.ask<Score>('score_pitch', { pitch, track: 'rd' });
77
+
78
+ if (! result.ok) {
79
+ return result.error === 'budget_exceeded'
80
+ ? showGenericFeedback()
81
+ : showRetry();
82
+ }
83
+
84
+ render(result.data.output.feedback, result.data.output.clarity);
85
+ result.data.usage; // { input_tokens, output_tokens }
86
+ ```
87
+
88
+ The Rack validates the model's output against your schema before it acks, so `output` either matches
89
+ the schema or the call fails. You do not need defensive parsing — you do need a path for the failure
90
+ case, because a model can be unavailable.
91
+
92
+ Always write the non-AI fallback. An applet whose experience stalls when a call fails has made an
93
+ LLM into a dependency the framework was designed to keep optional.
94
+
95
+ <a name="chat"></a>
96
+ ## Chat
97
+
98
+ Conversational, with history held by the Rack and scoped to the attempt or room. The applet never
99
+ holds the transcript.
100
+
101
+ ```ts
102
+ const chat = Rack.ai.chat('coach');
103
+
104
+ const reply = await chat.send(message, (delta, soFar) => {
105
+ bubble.textContent = soFar; // streams as it arrives
106
+ });
107
+
108
+ if (reply.ok) {
109
+ bubble.textContent = reply.data.text;
110
+ reply.data.message_id;
111
+ }
112
+
113
+ await chat.reset(); // start a fresh conversation
114
+ ```
115
+
116
+ Streaming works through chunked acks: partials carry `delta`, the terminal ack carries the full
117
+ `text` and `usage`. Each chunk resets the ack timeout, and the whole call allows 60 s.
118
+
119
+ Messages cap at 4000 characters and history at 40 messages (`ziprack-ai.max_message_length`,
120
+ `history_messages`); older turns fall off rather than failing the call.
121
+
122
+ <a name="budgets"></a>
123
+ ## Budgets
124
+
125
+ Every layer is enforced server-side, and the applet cannot raise any of them:
126
+
127
+ | Limit | Declared in | Default | Ceiling |
128
+ |---|---|---|---|
129
+ | Calls per attempt | `budget.max_calls_per_attempt` | 20 | — |
130
+ | Calls per participant | `budget.max_calls_per_participant` | 200 | — |
131
+ | Output tokens | `budget.max_output_tokens` | 800 | 2000 (`ziprack-ai.budget.max_output_tokens_cap`) |
132
+ | Calls per applet per day | deployment | 5000 | — |
133
+ | Calls per Rack per month | deployment | 100 000 | — |
134
+ | Variable payload | deployment | 16 KB | — |
135
+
136
+ Exceeding any of them returns `budget_exceeded`. Declare the budget your experience actually needs:
137
+ a lower cap is a smaller incident, and the number is visible to whoever reviews the capability diff.
138
+
139
+ Spend is reported per prompt in the admin API (`/ziprack/admin/applets/{applet}/ai/spend`).
140
+
141
+ <a name="untrusted-variables"></a>
142
+ ## Untrusted Variables
143
+
144
+ Variables still reach a model. Assume a participant will type adversarial text into any field you
145
+ pass through, and design the template so that it would not matter:
146
+
147
+ - Put participant text **after** the instructions, clearly delimited, and say what it is.
148
+ - Never ask the model to follow instructions found inside a variable.
149
+ - Prefer `analysis` with a tight `output_schema` over free-form text you will parse yourself — a
150
+ schema-constrained answer is a much smaller blast radius.
151
+ - Show model output as model output. Do not render it as if it were Rack-authored guidance.
152
+
153
+ <a name="not-this-capability"></a>
154
+ ## Not This Capability
155
+
156
+ The admin console's **AI Response Analysis** — summaries of free-text answers across attempts — is a
157
+ Rack feature operating on collected [statements](activity.md). It works for any applet declaring
158
+ `activity@1`, whether or not that applet uses `ai@1` at all, and it is configured and budgeted
159
+ separately. See [Reporting](reporting.md).
@@ -0,0 +1,118 @@
1
+ # Applet Packages
2
+
3
+ - [The Zip](#the-zip)
4
+ - [Installer Rules](#installer-rules)
5
+ - [The Starter](#the-starter)
6
+ - [Relative URLs](#relative-urls)
7
+ - [The SBOM](#the-sbom)
8
+ - [Building and Packaging](#building-and-packaging)
9
+
10
+ <a name="the-zip"></a>
11
+ ## The Zip
12
+
13
+ An applet ships as a single zip, named for the applet and the version it contains:
14
+
15
+ ```
16
+ {slug}-{version}.zip
17
+ ├── manifest.json required, at the zip root
18
+ ├── sbom.json required — CycloneDX, or package-lock.json
19
+ ├── index.html the entry (configurable via manifest.entry)
20
+ └── assets/… everything the entry references, by RELATIVE path
21
+ ```
22
+
23
+ Nothing else is special. The Rack extracts the archive, keeps the file tree intact, and serves it
24
+ through a token-prefixed path. There is no build step on the server, no server-side templating, no
25
+ framework to satisfy — whatever your bundler emits is what runs.
26
+
27
+ <a name="installer-rules"></a>
28
+ ## Installer Rules
29
+
30
+ `PackageInstaller` is security code, identical on every Rack, and it refuses the whole upload rather
31
+ than skipping a bad file:
32
+
33
+ - **Static extensions only.** `html js mjs css map json wasm txt vtt svg png jpg jpeg gif webp ico
34
+ woff woff2 ttf mp3 mp4 wav webm ogg pdf`. A `.php` in your `dist/` rejects the package.
35
+ - **No `..`, no absolute paths, no symlinks, no NUL bytes.** Dotfiles and `__MACOSX/` are skipped
36
+ silently, so a zip built on macOS is fine.
37
+ - **50 MB by default** (`ziprack.max_upload_mb`).
38
+ - **`manifest.entry` must exist** in the zip and end in `.html`.
39
+ - **The slug must match** the applet you are uploading to, and it is immutable once created.
40
+ - **A `version` string uploads once per applet.** Bump it every time, including during testing.
41
+
42
+ Installation runs inside a transaction: the version row is created, every declared capability's
43
+ `onInstall()` hook runs — this is where `db@1` creates the collection tables and their generated
44
+ columns — and only then are files extracted. A failure anywhere rolls the whole thing back and
45
+ deletes the extracted directory.
46
+
47
+ <a name="the-starter"></a>
48
+ ## The Starter
49
+
50
+ `starter/` is a minimal, working `ziprack/1` applet: plain Vite and TypeScript, `@ziprack/sdk`, one
51
+ question, one statement, one completion.
52
+
53
+ ```sh
54
+ npm install
55
+ npm run dev # mock mode: the whole experience runs standalone
56
+ npm run package # dist-zip/{slug}-{version}.zip, ready to upload
57
+ ```
58
+
59
+ Edit `public/manifest.json` first — `slug`, `name`, `version`, and the capabilities you intend to
60
+ call. Everything else follows from there.
61
+
62
+ <a name="relative-urls"></a>
63
+ ## Relative URLs
64
+
65
+ The Rack serves your package from `/ziprack/pkg/{token}/…`, where `{token}` is a signed, expiring
66
+ value. A root-relative URL like `/assets/app.js` therefore 404s in production while working
67
+ perfectly on your dev server — the single most common packaging mistake.
68
+
69
+ With Vite, one line prevents it:
70
+
71
+ ```ts
72
+ export default defineConfig({
73
+ base: './', // required
74
+ build: { outDir: 'dist', emptyOutDir: true },
75
+ });
76
+ ```
77
+
78
+ The same applies to anything you hand-write: `./assets/logo.svg`, never `/assets/logo.svg`.
79
+
80
+ <a name="the-sbom"></a>
81
+ ## The SBOM
82
+
83
+ Every package carries a `sbom.json` (CycloneDX) or a `package-lock.json` at its root. An applet is
84
+ *built output*, so there is nothing to audit unless the lockfile comes with it — the same reason
85
+ `npm audit` reads a lockfile rather than code.
86
+
87
+ On install the Rack extracts the purl list and batches it against OSV.dev. Findings land in the
88
+ [review queue](publishing.md) rather than blocking the upload.
89
+
90
+ Be clear about the limit: the SBOM is self-attested. A hostile publisher can ship an accurate
91
+ lockfile alongside a tampered bundle. This catches negligence, not malice — which is still worth
92
+ having, because negligence is the common case.
93
+
94
+ <a name="building-and-packaging"></a>
95
+ ## Building and Packaging
96
+
97
+ `npm run package` in the starter does the whole job:
98
+
99
+ ```js
100
+ // scripts/package.js — abbreviated
101
+ const manifest = JSON.parse(readFileSync('dist/manifest.json', 'utf8'));
102
+
103
+ execSync('npm sbom --sbom-format cyclonedx --omit dev', { cwd: lockDir }) // → dist/sbom.json
104
+ execSync(`zip -r -X "${zipPath}" . -x ".*" -x "__MACOSX*"`, { cwd: 'dist' });
105
+ ```
106
+
107
+ Two details worth keeping if you write your own: `-X` drops extra file attributes, and excluding
108
+ dotfiles keeps `.DS_Store` out of an archive the installer would otherwise skip anyway.
109
+
110
+ Upload the result through the admin API, or from the deployment:
111
+
112
+ ```sh
113
+ php artisan ziprack:install dist-zip/my-applet-1.0.0.zip
114
+ php artisan ziprack:promote my-applet 1.0.0
115
+ ```
116
+
117
+ Uploads land **unpublished**. Promotion is always a separate, deliberate action — see
118
+ [Publishing](publishing.md).
@@ -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.