@pouchy_ai/world-sdk 0.8.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,142 @@
1
+ # @pouchy_ai/world-sdk
2
+
3
+ ## 0.8.0
4
+
5
+ - `WorldDeliveryOpsRow` gained `cleanupRetryCount` and `cleanupStalledAt`.
6
+ - These surface the Batch 7.2 atomic receipt cleanup: when a settled row and
7
+ its two delivery receipts are purged, all three go in one transaction, so a
8
+ refused delete now leaves the row intact with a retry scheduled instead of
9
+ orphaning a receipt whose routing was just deleted. `cleanupRetryCount` is how
10
+ many times that cleanup has failed; `cleanupStalledAt` is stamped once it
11
+ crosses the retry threshold and becomes an operator signal. Both are absent on
12
+ an ordinary row, and neither is a delivery status — a row carrying them is
13
+ still `delivered`.
14
+ - No method changed shape.
15
+
16
+ ## 0.7.1
17
+
18
+ - `WorldInstanceMetrics.delivery` gained `receiptCount` and
19
+ `eligibleForCleanup`. Delivery receipts are now collected when the outbox row
20
+ they proved is purged — the moment the last legal path back into delivery
21
+ closes — so a world running for months no longer accumulates them without
22
+ bound.
23
+ - No method changed shape.
24
+
25
+ ## 0.7.0
26
+
27
+ Additive: the dead-letter recovery terminal states.
28
+
29
+ - `rehydrateDelivery`, `resolveDeliveryGap`, `describeDeliveryResolution`, and
30
+ the `WorldRehydrateFailure` type.
31
+ - `WorldDeliveryOpsRow.status` gained `rehydrating`, `unrecoverable` and
32
+ `resolved_gap`, plus `rehydratedAt` / `rehydratedFrom` /
33
+ `unrecoverableReason` / `resolvedGapAt` / `replacesDeliveryId`.
34
+ - Closes a loop 0.4.0 left open: a dead letter blocks its session, its text is
35
+ redacted after 30 days, and a redacted row refuses requeue. `rehydrateDelivery`
36
+ rebuilds it from the committed ledger (or the verified archive) and accepts the
37
+ result only if it re-derives the same delivery id and content digest;
38
+ `resolveDeliveryGap` is the last resort and takes no text.
39
+ - `requeueDelivery` now refuses `unrecoverable` and `rehydrating` explicitly
40
+ rather than reporting them as already-queued — they block, and saying
41
+ otherwise would tell you the stream is moving when it is stopped.
42
+ - No existing method changed shape.
43
+
44
+ ## 0.6.0
45
+
46
+ Additive: the production hand-off.
47
+
48
+ - `createApprovedExport`, `listApprovedExports`, `deriveStoryPackageCandidate`,
49
+ and the `ApprovedScriptExportRow` type.
50
+ - An export carries lineage from the original story package through the world
51
+ instance, ledger range, evidence draft and editorial draft to the reviewer who
52
+ approved it. It is idempotent on content: the same approved review exports to
53
+ the same id forever, so replaying one is a no-op rather than a second script.
54
+ - `notify` sends `world.script_approved` to the project's webhooks with lineage
55
+ and identifiers — never the script body, which a webhook payload should not
56
+ scatter across logs and proxies. Fetch the body over the authenticated route.
57
+ - `deriveStoryPackageCandidate` returns a VALIDATED candidate and publishes
58
+ nothing. Only evidence-origin material becomes canon.
59
+ - No existing method changed shape.
60
+
61
+ ## 0.5.0
62
+
63
+ Additive: the refusal taxonomy.
64
+
65
+ - `WorldRejectionCode` and `isDefectRejection`. `rejectedEffects[].code` now
66
+ travels on `WorldTurnResult`, and `WorldInstanceMetrics.turns` carries
67
+ `rejectionsByCode` plus the per-code rates.
68
+ - Only `narrative_conflict` describes content — two characters wanted
69
+ incompatible things and the story rule settled it. Every other code is a
70
+ defect in the story package, the prompt or the integration. Treat the
71
+ aggregate `effectRejectionRate` as a mixed number and read the split instead.
72
+ - `code` is absent on turns committed before this version. Read that as unknown,
73
+ never as narrative.
74
+ - No existing method changed shape.
75
+
76
+ ## 0.4.0
77
+
78
+ Additive: the dead-letter operating loop.
79
+
80
+ - `listDeliveries`, `getDelivery`, `drainDeliveries`, `requeueDelivery`, and the
81
+ `describeDelivery` helper — plus `WorldDeliveryOpsRow`, `WorldDeliveryPage`,
82
+ `WorldDeliveryDetail` and `WorldDrainReport`.
83
+ - `requeueDelivery` takes no payload parameter, on purpose: the beat was
84
+ committed by the coordinator, and supplying a different line here would be
85
+ authoring world history through the delivery plane. It is idempotent, refuses
86
+ a delivered row, and refuses one whose payload has aged out (30 days).
87
+ - There is no discard method, and there is not going to be one.
88
+ - `getDelivery({ includePayload: true })` writes an audit row naming the reader.
89
+ - No existing method changed shape.
90
+
91
+ ## 0.3.1
92
+
93
+ - `WorldInstanceMetrics.delivery` gained `blockedSessions`: delivery is now
94
+ ordered per target session, so a stuck line holds the ones behind it in that
95
+ session (and only that session). This is the count of readers currently
96
+ missing a beat.
97
+ - No method changed shape.
98
+
99
+ ## 0.3.0
100
+
101
+ Additive: operations.
102
+
103
+ - `getWorldMetrics` — delivery and turn metric families for one instance, with
104
+ the `WorldInstanceMetrics` type.
105
+ - `evaluateWorld` — run the `drama` or `npc` quality suite over an instance's
106
+ committed history, with the `WorldEvalReport` type. Deterministic: no model
107
+ judges the output.
108
+ - `archiveLedger` — ledger archival, defaulting to the dry-run `plan`.
109
+ `execute` copies into immutable hash-chained segments and deletes nothing;
110
+ `prune` is the only call in this client that destroys a row, and it refuses
111
+ without `confirm` and again unless the archive verifies and has outlived its
112
+ retention.
113
+ - No existing method changed shape.
114
+
115
+ ## 0.2.0
116
+
117
+ Additive: the EDITORIAL layer over script drafts.
118
+
119
+ - `createEditorialDraft`, `getEditorialDraft`, `listEditorialDrafts`,
120
+ `setEditorialStatus`, `decideEditorialScene`, `exportEditorialDraft`, and the
121
+ `EditorialDraftRow` type.
122
+ - The deterministic script draft is unchanged and is never rewritten by this
123
+ layer; an editorial draft is stored beneath its evidence draft. Lines the
124
+ model cannot be shown to have taken from the committed record are recorded as
125
+ suggestions, and suggestions never enter world canon.
126
+ - No existing method changed shape.
127
+
128
+ ## 0.1.0
129
+
130
+ First release. Server-side client for the Pouchy World plane:
131
+
132
+ - authoring: story packages and world definitions (both immutable-versioned,
133
+ both idempotent on content bytes);
134
+ - running: world sessions, coordinated turns, trusted events — the machine lane
135
+ with a Secret Key plus a POUCHY-SOURCE-V1 signature over the exact body;
136
+ - reading: world state (user projection), committed turn read-back, bounded
137
+ resumable ledger replay;
138
+ - content return: script draft generation, review and export;
139
+ - helpers: request signing, turn-id idempotency, typed errors with a
140
+ `retryable` verdict, and `describeTurn` for reading a result without
141
+ guessing which of execution and delivery a status refers to;
142
+ - `conformance.mjs`: a runnable check of an integration end to end.
package/LICENSE ADDED
@@ -0,0 +1,41 @@
1
+ Pouchy Companion SDK License
2
+ Copyright (c) 2026 Pouchy.ai. All Rights Reserved.
3
+
4
+ This license governs use of the "@pouchy_ai/companion-sdk" software package (the
5
+ "SDK") as published to the npm registry. The SDK is proprietary software owned
6
+ by Pouchy.ai. By installing, copying, or using the SDK you agree to these terms.
7
+
8
+ 1. GRANT. Subject to these terms, Pouchy.ai grants you a worldwide,
9
+ non-exclusive, non-transferable, royalty-free, revocable license to install
10
+ and use the SDK, as distributed, solely to build and operate applications
11
+ that integrate with Pouchy.ai's official services and APIs.
12
+
13
+ 2. RESTRICTIONS. Except to the extent a restriction below is prohibited by
14
+ applicable law, you may not:
15
+ (a) modify, adapt, or create derivative works of the SDK's source for
16
+ redistribution;
17
+ (b) redistribute, sublicense, sell, rent, or lease the SDK as a standalone
18
+ product, or republish it under a different name;
19
+ (c) reverse engineer, decompile, or disassemble the SDK except to the extent
20
+ necessary for interoperability and expressly permitted by law;
21
+ (d) use the SDK to build or operate a service that competes with Pouchy.ai's
22
+ services, or to access Pouchy.ai's services in violation of their terms;
23
+ (e) remove or alter any copyright, trademark, or other proprietary notices.
24
+
25
+ 3. RESERVATION OF RIGHTS. All rights not expressly granted are reserved by
26
+ Pouchy.ai. No rights are granted in Pouchy.ai's source repositories,
27
+ trademarks, or services beyond what is needed to use the SDK as described.
28
+
29
+ 4. TERMINATION. This license terminates automatically if you breach it. On
30
+ termination you must stop using and delete all copies of the SDK.
31
+
32
+ 5. NO WARRANTY. THE SDK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
33
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
34
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
35
+
36
+ 6. LIMITATION OF LIABILITY. IN NO EVENT SHALL POUCHY.AI BE LIABLE FOR ANY CLAIM,
37
+ DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR
38
+ OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SDK OR THE USE OR
39
+ OTHER DEALINGS IN THE SDK.
40
+
41
+ For any other use, or for written permission, contact legal@pouchy.ai.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # @pouchy_ai/world-sdk
2
+
3
+ Server-side TypeScript client for **Pouchy World** — build an interactive short
4
+ drama, a game's NPC cast, or an interactive novel on top of Pouchy, without an
5
+ official app.
6
+
7
+ ```bash
8
+ npm i @pouchy_ai/world-sdk
9
+ ```
10
+
11
+ Node ≥ 18, zero dependencies.
12
+
13
+ ## Node only. Really.
14
+
15
+ This client holds a project **Secret Key** (`pchy_sk_…`) and an event-source
16
+ **signing secret** (`pesk_…`). They never belong in a browser or a mobile app.
17
+ The world's machine lane requires BOTH — a key proves the project and its
18
+ test/live axis, a signature proves the provider — and a leaked pair is a world
19
+ anyone can drive.
20
+
21
+ What your users' devices get is the **session token** this client mints for
22
+ them, which is scoped to one world instance and one role and carries no project
23
+ credential. Drive that with `@pouchy_ai/companion-sdk`.
24
+
25
+ ## The two lanes
26
+
27
+ | Lane | Credential | What it does |
28
+ |---|---|---|
29
+ | owner | a signed-in project admin's ID token | author story packages and worlds; read state, turns, drafts; run replay |
30
+ | machine | Secret Key **and** `X-Pouchy-Source-Signature` | mint sessions, drive turns, send trusted events |
31
+
32
+ ```ts
33
+ import { PouchyWorldClient, newTurnId, describeTurn } from '@pouchy_ai/world-sdk';
34
+
35
+ const world = new PouchyWorldClient({
36
+ projectId: process.env.POUCHY_PROJECT_ID!,
37
+ adminToken: process.env.POUCHY_ADMIN_TOKEN!,
38
+ secretKey: process.env.POUCHY_SECRET_KEY!,
39
+ signing: { source: 'drama-backend', keyId: process.env.KID!, secret: process.env.SECRET! }
40
+ });
41
+ ```
42
+
43
+ ## What it covers
44
+
45
+ **Authoring** — `createStoryPackage`, `publishStoryPackage`, `getStoryPackage`,
46
+ `listStoryPackages`, `createWorld`, `publishWorld`, `getWorld`, `listWorlds`.
47
+
48
+ **Running** — `createWorldSession`, `runTurn`, `sendEvent`.
49
+
50
+ **Reading** — `getWorldState`, `getTurn`, `replayLedger`, `replayLedgerToEnd`.
51
+
52
+ **Content return** — `createScriptDraft`, `getScriptDraft`, `listScriptDrafts`,
53
+ `reviewScriptDraft`, `exportScriptDraft`.
54
+
55
+ **Delivery ops** — `listDeliveries`, `getDelivery`, `drainDeliveries`,
56
+ `requeueDelivery`, `describeDelivery`. Deliveries land in ledger order per
57
+ session, so a stuck line holds the ones behind it in that session (and only
58
+ that session) — `blocking` on a row is a reader who has stopped receiving the
59
+ story. `requeueDelivery` is the way out; it takes no payload, because the beat
60
+ was committed by the coordinator and re-writing it here would make the delivery
61
+ plane a second authoring path. There is no discard.
62
+
63
+ **Stuck deliveries** — `rehydrateDelivery`, `resolveDeliveryGap`,
64
+ `describeDeliveryResolution`. Three verbs in the order to try them: re-send what
65
+ is still there, rebuild it from the committed record, or tell the reader the
66
+ beat is gone. None of them accepts replacement text, and none of them skips a
67
+ beat silently — `resolved_gap` means the reader was told, and it is the only
68
+ non-delivered state a session moves past.
69
+
70
+ **Operations** — `getWorldMetrics`, `evaluateWorld`, `archiveLedger`,
71
+ `replayLedger` / `replayLedgerToEnd`. Metrics keep delivery and turn health as
72
+ separate families; the eval suites are deterministic, so a score can be
73
+ regressed. `archiveLedger` defaults to a dry-run plan, `execute` only ever
74
+ copies, and `prune` — the one call here that deletes — refuses without
75
+ `confirm`, and again unless the archive verifies and has outlived retention.
76
+
77
+ **Production hand-off** — `createApprovedExport`, `listApprovedExports`,
78
+ `deriveStoryPackageCandidate`. An approved editorial draft becomes a versioned
79
+ `ApprovedScriptExportV1` carrying the whole chain: original story package →
80
+ world instance → ledger range → evidence draft → editorial draft → reviewer.
81
+ Idempotent on content, so replaying an export is a no-op. The Story Package
82
+ candidate is validated and *returned*; publishing it is a separate act by a
83
+ person, and only evidence-origin material becomes canon.
84
+
85
+ **Editorial layer** — `createEditorialDraft`, `getEditorialDraft`,
86
+ `listEditorialDrafts`, `setEditorialStatus`, `decideEditorialScene`,
87
+ `exportEditorialDraft`. The script draft is deterministic: it is assembled from
88
+ committed ledger entries and nothing else, and nothing rewrites it. The
89
+ editorial draft is a model's *reading* of that draft — scenes, ordering,
90
+ connective prose — stored underneath it. Every line the model presents as
91
+ something a character said is re-checked against the committed record on the
92
+ server; anything that does not match is stored as `origin: "suggestion"`,
93
+ whatever the model labelled it. Suggestions never reach world canon, and only
94
+ an `approved` editorial can be exported.
95
+
96
+ **Helpers** — `signSourceRequest` (the exact canonical the server verifies),
97
+ `newTurnId` / `isReservedTurnId` (idempotency), `describeTurn` (read a result
98
+ without guessing), `WorldApiError` with typed codes and `.retryable`.
99
+
100
+ ## The three things integrators get wrong
101
+
102
+ **1. Turn ids are the idempotency key.** Mint one per BEAT and re-send the same
103
+ one to retry. A new id is a new beat: it will run the models again and commit
104
+ again. `newTurnId()` exists so this is a deliberate choice rather than a habit.
105
+
106
+ **2. Sign the bytes you send.** `signSourceRequest` hashes the exact body
107
+ string. Serializing twice — once to sign, once to send — signs bytes you did
108
+ not send, and the server will (correctly) refuse them. This client always signs
109
+ the string it is about to write.
110
+
111
+ **3. Execution and delivery are different questions.** `executionStatus` says
112
+ whether the world moved; `deliveryStatus` says whether the audience has heard
113
+ about it yet. A `pending` delivery is not a failed turn — the intent was
114
+ written inside the commit and a durable outbox is retrying it.
115
+
116
+ ```ts
117
+ const beat = await world.runTurn({ environmentId, worldInstanceId, text: '…' });
118
+ const read = describeTurn(beat);
119
+ if (read.shouldRetrySameTurn) { /* conflict — re-send the SAME turnId */ }
120
+ if (read.needsDifferentRequest) { /* rejected/refused — fix it, use a NEW id */ }
121
+ ```
122
+
123
+ ## Conformance
124
+
125
+ ```bash
126
+ node node_modules/@pouchy_ai/world-sdk/conformance.mjs
127
+ ```
128
+
129
+ Checks your credentials, signing, world resolution, role bindings, a real turn,
130
+ turn recovery, state read, replay and draft generation against your OWN
131
+ project — before you write product code. It prints a pass/fail line per check
132
+ and exits non-zero on the first structural failure.
133
+
134
+ ## Quickstarts
135
+
136
+ - `docs/world-quickstart-drama.md` — screenplay → world → new screenplay draft
137
+ - `docs/world-quickstart-npc.md` — one town, three NPCs, one shared state
138
+
139
+ ## License
140
+
141
+ See LICENSE.
@@ -0,0 +1,279 @@
1
+ #!/usr/bin/env node
2
+ // Pouchy World — conformance runner.
3
+ //
4
+ // Checks an integration END TO END against a real project, before any product
5
+ // code exists: credentials, the exact signing canonical, world resolution, role
6
+ // bindings, capability intersection, a real coordinated turn, turn recovery,
7
+ // state reads, replay verification and draft generation.
8
+ //
9
+ // It is deliberately a script rather than a test suite: an integrator running
10
+ // this has no repository, no vitest and no fixtures — they have a terminal, a
11
+ // project, and a question about whether their setup is right.
12
+ //
13
+ // Nothing here is destructive: it creates a story package, a world and one
14
+ // session in YOUR project (all versioned, all disable-able), drives one beat,
15
+ // and reads. It never deletes and never publishes anything to end users.
16
+ //
17
+ // Two scenarios, because the two products fail differently and an integrator
18
+ // should check the one they are building. `--scenario=drama` casts two
19
+ // characters who both speak in a beat; `--scenario=npc` casts a merchant who
20
+ // has to answer AND touch world state, which is the failure mode that matters
21
+ // there (an NPC that chats without the world moving looks fine and is not).
22
+ //
23
+ // POUCHY_PROJECT_ID=… POUCHY_ADMIN_TOKEN=… POUCHY_SECRET_KEY=… \
24
+ // POUCHY_SOURCE=… POUCHY_SOURCE_KID=… POUCHY_SOURCE_SECRET=… \
25
+ // POUCHY_AGENT_A=… POUCHY_AGENT_B=… node conformance.mjs [--scenario=drama|npc]
26
+
27
+ import { PouchyWorldClient, newTurnId, describeTurn, WorldApiError } from './dist/index.js';
28
+
29
+ const env = (name, required = true) => {
30
+ const value = process.env[name];
31
+ if (!value && required) {
32
+ console.error(`MISSING ${name}`);
33
+ process.exit(2);
34
+ }
35
+ return value;
36
+ };
37
+
38
+ /** The two shapes an integrator can be building. Same plane, different cast,
39
+ * and a different question at the end of the beat. */
40
+ const SCENARIOS = {
41
+ drama: {
42
+ label: 'short drama',
43
+ roles: [
44
+ { storyRoleId: 'heroine', worldRoleId: 'heroine', effectsAllow: ['set_flag'] },
45
+ { storyRoleId: 'rival', worldRoleId: 'rival', effectsAllow: ['set_flag'] }
46
+ ],
47
+ scenes: [{ sceneId: 'docks', title: 'Docks', description: 'After rain, before dawn.' }],
48
+ nodes: [
49
+ { nodeId: 'n1', sceneId: 'docks', title: 'Meet', objective: 'they meet', prerequisites: [] }
50
+ ],
51
+ flags: [{ key: 'tension', kind: 'number', initial: 0 }],
52
+ leadRole: 'heroine',
53
+ prompt: 'Conformance check: you two have just met at the docks. One short line each.',
54
+ // A drama is wrong when only one voice comes back.
55
+ expect: (beat) => {
56
+ if ((beat.roleMessages ?? []).length < 2) {
57
+ throw new Error(`a drama beat returned ${(beat.roleMessages ?? []).length} line(s); both roles should speak`);
58
+ }
59
+ return `${beat.roleMessages.length} roles spoke`;
60
+ }
61
+ },
62
+ npc: {
63
+ label: 'game NPC',
64
+ roles: [
65
+ { storyRoleId: 'merchant', worldRoleId: 'merchant', effectsAllow: ['set_flag'] },
66
+ { storyRoleId: 'guard', worldRoleId: 'guard' }
67
+ ],
68
+ scenes: [{ sceneId: 'market', title: 'Market', description: 'A stall at the gate.' }],
69
+ nodes: [
70
+ { nodeId: 'n1', sceneId: 'market', title: 'Trade', objective: 'a price is agreed', prerequisites: [] }
71
+ ],
72
+ flags: [{ key: 'stock', kind: 'number', initial: 3 }],
73
+ leadRole: 'merchant',
74
+ prompt: 'Conformance check: a player asks your price for one lamp. Answer briefly.',
75
+ // An NPC is wrong when it talks and the world does not move.
76
+ expect: (beat) => {
77
+ if (!(beat.roleMessages ?? []).length) throw new Error('no NPC answered');
78
+ const moved = (beat.committedStateDiff ?? []).length > 0;
79
+ return moved
80
+ ? `${beat.roleMessages.length} answered, ${beat.committedStateDiff.length} state op(s)`
81
+ : `${beat.roleMessages.length} answered, world state unchanged (check effectsAllow if this is unexpected)`;
82
+ }
83
+ }
84
+ };
85
+ const scenarioName = (process.argv.find((a) => a.startsWith('--scenario=')) ?? '--scenario=drama')
86
+ .split('=')[1];
87
+ const scenario = SCENARIOS[scenarioName];
88
+ if (!scenario) {
89
+ console.error(`unknown --scenario=${scenarioName}; expected one of: ${Object.keys(SCENARIOS).join(', ')}`);
90
+ process.exit(2);
91
+ }
92
+ console.log(`Pouchy World conformance — ${scenario.label} scenario\n`);
93
+
94
+ const results = [];
95
+ let failed = false;
96
+
97
+ async function check(name, fn) {
98
+ try {
99
+ const detail = await fn();
100
+ results.push({ name, ok: true, detail });
101
+ console.log(`PASS ${name}${detail ? ` — ${detail}` : ''}`);
102
+ } catch (err) {
103
+ failed = true;
104
+ const detail =
105
+ err instanceof WorldApiError
106
+ ? `${err.code} ${err.status}${err.detail ? `: ${err.detail}` : ''}`
107
+ : err instanceof Error
108
+ ? err.message
109
+ : String(err);
110
+ results.push({ name, ok: false, detail });
111
+ console.error(`FAIL ${name} — ${detail}`);
112
+ }
113
+ }
114
+
115
+ const world = new PouchyWorldClient({
116
+ projectId: env('POUCHY_PROJECT_ID'),
117
+ adminToken: env('POUCHY_ADMIN_TOKEN'),
118
+ secretKey: env('POUCHY_SECRET_KEY'),
119
+ signing: {
120
+ source: env('POUCHY_SOURCE'),
121
+ keyId: env('POUCHY_SOURCE_KID'),
122
+ secret: env('POUCHY_SOURCE_SECRET')
123
+ },
124
+ ...(process.env.POUCHY_BASE_URL ? { baseUrl: process.env.POUCHY_BASE_URL } : {})
125
+ });
126
+ const agentA = env('POUCHY_AGENT_A');
127
+ const agentB = env('POUCHY_AGENT_B');
128
+ const stamp = Date.now().toString(36);
129
+
130
+ let pkg;
131
+ let created;
132
+ let instanceId;
133
+ let turnId;
134
+
135
+ await check('owner credential resolves the project', async () => {
136
+ const list = await world.listStoryPackages();
137
+ return `${(list.packages ?? []).length} story package(s) visible`;
138
+ });
139
+
140
+ await check('story package imports and pins', async () => {
141
+ pkg = await world.createStoryPackage({
142
+ name: `Conformance ${scenarioName} ${stamp}`,
143
+ summary: `Generated by the Pouchy World conformance runner (${scenario.label}).`,
144
+ roles: scenario.roles,
145
+ scenes: scenario.scenes,
146
+ nodes: scenario.nodes,
147
+ stateSchema: { flags: scenario.flags }
148
+ });
149
+ return `${pkg.packageId} rev ${pkg.revision}`;
150
+ });
151
+
152
+ await check('world publishes with an EXPLICIT coordinated runtime', async () => {
153
+ created = await world.createWorld({
154
+ name: `Conformance World ${stamp}`,
155
+ providerRef: env('POUCHY_SOURCE'),
156
+ runtimeMode: 'coordinated',
157
+ storyPackageRef: {
158
+ packageId: pkg.packageId,
159
+ revision: pkg.revision,
160
+ contentHash: pkg.contentHash
161
+ },
162
+ capabilityAllow: { views: [], actions: [], events: [] },
163
+ roles: [
164
+ { roleId: scenario.roles[0].worldRoleId, agentId: agentA },
165
+ { roleId: scenario.roles[1].worldRoleId, agentId: agentB }
166
+ ]
167
+ });
168
+ return `${created.environmentId} rev ${created.environmentRevision}`;
169
+ });
170
+
171
+ await check('session mint binds one user to one role (machine lane)', async () => {
172
+ const session = await world.createWorldSession({
173
+ environment: created.environmentId,
174
+ role: scenario.leadRole,
175
+ externalUserId: `conformance-${scenarioName}-${stamp}`
176
+ });
177
+ instanceId = session?.world?.instance ?? session?.world?.world_instance;
178
+ if (!instanceId) throw new Error('no world instance in the session payload');
179
+ if (!session.token) throw new Error('no session token minted');
180
+ return instanceId;
181
+ });
182
+
183
+ await check('a signed turn runs a coordinated beat', async () => {
184
+ turnId = newTurnId('conformance');
185
+ const beat = await world.runTurn({
186
+ environmentId: created.environmentId,
187
+ worldInstanceId: instanceId,
188
+ turnId,
189
+ text: scenario.prompt
190
+ });
191
+ const read = describeTurn(beat);
192
+ if (!beat.selectedRoles?.length && beat.completionStatus === 'refused') {
193
+ throw new Error(`refused: ${beat.rejectedEffects?.[0]?.reason ?? 'unknown'}`);
194
+ }
195
+ // The scenario's own question — a drama that returns one voice and an NPC
196
+ // that never touches the world both pass every generic check above.
197
+ return `${read.summary}; ${scenario.expect(beat)}`;
198
+ });
199
+
200
+ await check('the SAME turnId is idempotent (no second beat)', async () => {
201
+ const again = await world.runTurn({
202
+ environmentId: created.environmentId,
203
+ worldInstanceId: instanceId,
204
+ turnId,
205
+ text: 'Conformance check: say one short line in character.'
206
+ });
207
+ if (again.completionStatus !== 'duplicate') {
208
+ throw new Error(`expected duplicate, got ${again.completionStatus}`);
209
+ }
210
+ return 'duplicate, nothing advanced';
211
+ });
212
+
213
+ await check('a committed turn is recoverable by id', async () => {
214
+ const recovered = await world.getTurn(created.environmentId, instanceId, turnId);
215
+ if (recovered.turnId !== turnId) throw new Error('turn read back with a different id');
216
+ return `${(recovered.roleMessages ?? []).length} line(s) on the record`;
217
+ });
218
+
219
+ await check('world state reads back (user projection, no private layer)', async () => {
220
+ const { state } = await world.getWorldState(created.environmentId, instanceId);
221
+ if (typeof state.stateRevision !== 'number') throw new Error('no stateRevision');
222
+ if ('rolePrivate' in state) throw new Error('the user projection leaked the private layer');
223
+ return `revision ${state.stateRevision}`;
224
+ });
225
+
226
+ await check('replay verifies the ledger against the served state', async () => {
227
+ const report = await world.replayLedgerToEnd(created.environmentId, instanceId);
228
+ if (report.verdict !== 'consistent') {
229
+ throw new Error(`${report.verdict}${report.detail ? `: ${report.detail}` : ''}`);
230
+ }
231
+ if (report.dryRun !== true) throw new Error('replay did not report itself as a dry run');
232
+ return `consistent over ${report.entriesScanned} entr(ies)`;
233
+ });
234
+
235
+ await check('a script draft generates and refuses export before review', async () => {
236
+ const draft = await world.createScriptDraft(created.environmentId, instanceId);
237
+ if (draft.content?.humanReviewRequired !== true) {
238
+ throw new Error('draft did not carry humanReviewRequired');
239
+ }
240
+ try {
241
+ await world.exportScriptDraft(created.environmentId, instanceId, draft.draftId);
242
+ throw new Error('export succeeded WITHOUT a human review');
243
+ } catch (err) {
244
+ if (!(err instanceof WorldApiError) || err.status !== 409) throw err;
245
+ }
246
+ return `${draft.draftId} (export correctly refused)`;
247
+ });
248
+
249
+ await check('a WRONG signature is refused (the second proof is real)', async () => {
250
+ const bad = new PouchyWorldClient({
251
+ projectId: env('POUCHY_PROJECT_ID'),
252
+ secretKey: env('POUCHY_SECRET_KEY'),
253
+ signing: {
254
+ source: env('POUCHY_SOURCE'),
255
+ keyId: env('POUCHY_SOURCE_KID'),
256
+ secret: 'pesk_not_the_real_secret'
257
+ },
258
+ ...(process.env.POUCHY_BASE_URL ? { baseUrl: process.env.POUCHY_BASE_URL } : {})
259
+ });
260
+ try {
261
+ await bad.runTurn({
262
+ environmentId: created.environmentId,
263
+ worldInstanceId: instanceId,
264
+ text: 'this must not run'
265
+ });
266
+ throw new Error('a bad signature was ACCEPTED');
267
+ } catch (err) {
268
+ if (!(err instanceof WorldApiError) || err.status !== 403) throw err;
269
+ }
270
+ return 'refused with 403, as it must be';
271
+ });
272
+
273
+ console.log('');
274
+ console.log(`${results.filter((r) => r.ok).length}/${results.length} checks passed`);
275
+ if (failed) {
276
+ console.error('Conformance FAILED — fix the first failure above before writing product code.');
277
+ process.exit(1);
278
+ }
279
+ console.log('Conformance passed. The world plane is wired correctly for this project.');