@pouchy_ai/world-sdk 0.23.0 → 0.25.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 CHANGED
@@ -1,5 +1,47 @@
1
1
  # @pouchy_ai/world-sdk
2
2
 
3
+ ## 0.25.0
4
+
5
+ - **`WorldApiError.rejectedEffects`** — a rejected turn (422) now arrives with
6
+ the per-op reasons the server computed, instead of a bare status. The turns
7
+ door answers a refused effect batch with the FULL turn result rather than
8
+ `{ error }`, so `detail` was undefined for exactly the refusal that has the
9
+ most to say, and an integrator got `POST … → 422` and nothing else.
10
+ - This is load-bearing rather than cosmetic because patch validation is
11
+ ALL-OR-NOTHING: one bad op voids the batch, the turn moves nothing, and
12
+ "which of my ops was it" is the only useful question. Measured cost of not
13
+ having it: two rounds of field testing spent guessing which of five ops was
14
+ bad while the server had already written the answer (`node "node.hear-amara"
15
+ requires "node.warning" to be completed first`).
16
+ - Each entry carries `index`, `reason`, and optionally `code` / `roleId` /
17
+ `kind`. Read defensively from an untrusted body: entries without a string
18
+ `reason` are dropped, and the field is ABSENT (never `[]`) when the server
19
+ named nothing — "said nothing" and "refused nothing" stay distinguishable.
20
+ - Purely additive; `code`, `status`, `detail`, `retryAfterSec` and `.retryable`
21
+ are unchanged, and knowing why a batch was refused does not make a 422
22
+ retryable.
23
+ - See `docs/world-sdk-errors.md` → “`rejectedEffects`” for the two readings
24
+ that matter (an undeclared id means a stale pinned revision; an unmet
25
+ prerequisite means an earlier beat never committed).
26
+
27
+ ## 0.24.0
28
+
29
+ - **`startNextEpisode` now rides the MACHINE lane** (Secret Key + provider
30
+ signature, id namespace `epn:`) — the same two proofs as `runTurn` — so an
31
+ unattended serial backend can advance its own player's worldline. Before
32
+ this it required the expiring owner token, which made serial UX impossible
33
+ for an integrator: the backend that drives every beat of the story could
34
+ not move it to the next episode (#2998).
35
+ - The method signature is unchanged (`startNextEpisode(environmentId,
36
+ worldInstanceId)`); an optional `{ requestId }` third argument pins the
37
+ signature id slot for callers that want to supply their own.
38
+ - The server still accepts the owner lane, and still exposes no `/admin`
39
+ mirror — an admin key proves the project, never the Provider.
40
+ - Idempotence is untouched: the run id stays server-derived from the
41
+ worldline, the episode and the revision it starts at; `requestId` is a
42
+ signature slot, not an idempotency key.
43
+ - Server-side this is world API 1.30.0.
44
+
3
45
  ## 0.23.0
4
46
 
5
47
  - **`runTurn` gains `focusRoles?: string[]`** — narrow one beat to the named
package/README.md CHANGED
@@ -45,7 +45,10 @@ const world = new PouchyWorldClient({
45
45
  **Authoring** — `createStoryPackage`, `publishStoryPackage`, `getStoryPackage`,
46
46
  `listStoryPackages`, `createWorld`, `publishWorld`, `getWorld`, `listWorlds`.
47
47
 
48
- **Running** — `createWorldSession`, `runTurn`, `sendEvent`.
48
+ **Running** — `createWorldSession`, `runTurn`, `sendEvent`,
49
+ `startNextEpisode` (machine lane since 0.24.0 — an unattended serial backend
50
+ can advance its own player's worldline to the next episode and receive the
51
+ carryover).
49
52
 
50
53
  **Deliberating** — `deliberate`, `selectCandidate`. Ask for a couple of public
51
54
  directions a beat could take, then commit the one the player picked. Off unless
@@ -237,7 +240,9 @@ an `approved` editorial can be exported.
237
240
 
238
241
  **Helpers** — `signSourceRequest` (the exact canonical the server verifies),
239
242
  `newTurnId` / `isReservedTurnId` (idempotency), `describeTurn` (read a result
240
- without guessing), `WorldApiError` with typed codes and `.retryable`.
243
+ without guessing), `WorldApiError` with typed codes, `.retryable`, and
244
+ `rejectedEffects` (which of YOUR proposed ops the world refused, and why —
245
+ validation is all-or-nothing, so one bad op moves nothing).
241
246
 
242
247
  ## When a signed door refuses you
243
248
 
package/dist/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * a project is running — which is exactly the field you reach for when a
5
5
  * customer's integration behaves like an older SDK than they say they have.
6
6
  * It sat at '0.1.0' for eight releases before anything compared the two. */
7
- export declare const WORLD_SDK_VERSION = "0.23.0";
7
+ export declare const WORLD_SDK_VERSION = "0.25.0";
8
8
  export declare const DEFAULT_BASE_URL = "https://pouchy.ai/v1";
9
9
  /** One direction a beat could take. The WHOLE of what a deliberation shows a
10
10
  * player: no reasoning, no role secrets, no simulated effects, no scores. */
@@ -41,12 +41,41 @@ export declare class WorldApiError extends Error {
41
41
  /** The server's own message, when it sent one. */
42
42
  readonly detail?: string;
43
43
  readonly retryAfterSec?: number;
44
+ /** Why the world refused the effects you proposed — per op, with the
45
+ * server's own reason and code.
46
+ *
47
+ * A rejected turn (422) does NOT answer with `{ error }` like every other
48
+ * refusal: its body is the full turn result, and the reasons live in
49
+ * `rejectedEffects`. So `detail` was undefined for exactly the refusal
50
+ * that has the most to say, and an integrator got `POST … → 422` and
51
+ * nothing else. Measured cost: two rounds of field testing spent guessing
52
+ * which of five ops in a batch was the bad one, while the server had
53
+ * computed the answer (`node "node.hear-amara" requires "node.warning" to
54
+ * be completed first`) and thrown it away at this line.
55
+ *
56
+ * Patch validation is ALL-OR-NOTHING, which is what makes this
57
+ * load-bearing rather than nice-to-have: one bad op voids the batch, so
58
+ * "which one" is the only question worth asking. */
59
+ readonly rejectedEffects?: readonly {
60
+ readonly index: number;
61
+ readonly reason: string;
62
+ readonly roleId?: string;
63
+ readonly kind?: string;
64
+ readonly code?: string;
65
+ }[];
44
66
  constructor(input: {
45
67
  code: WorldErrorCode;
46
68
  status: number;
47
69
  message: string;
48
70
  detail?: string;
49
71
  retryAfterSec?: number;
72
+ rejectedEffects?: readonly {
73
+ readonly index: number;
74
+ readonly reason: string;
75
+ readonly roleId?: string;
76
+ readonly kind?: string;
77
+ readonly code?: string;
78
+ }[];
50
79
  });
51
80
  /** Worth trying again with the SAME idempotency key. A 409 is not: it means
52
81
  * the world disagreed with the request, and repeating it will disagree
@@ -1032,10 +1061,17 @@ export declare class PouchyWorldClient {
1032
1061
  * revision it starts at, so a retried call lands on the same run and
1033
1062
  * answers `created: false` rather than forking the story.
1034
1063
  *
1035
- * OWNER TOKEN ONLY no `/admin` mirror, on the same reasoning as
1036
- * `preflightWorld`: the mirror carries reads, and this is the write that
1037
- * decides a story moves on. */
1038
- startNextEpisode(environmentId: string, worldInstanceId: string): Promise<WorldNextEpisodeResponse>;
1064
+ * MACHINE LANE since 0.24.0 (#2998, world API 1.30.0): a Secret Key plus a
1065
+ * provider signature in the `epn:` namespace the same two proofs as
1066
+ * `runTurn`, because the party trusted to author every beat, including the
1067
+ * one that ends an episode, is the party deciding the story moves on. So
1068
+ * an unattended serial backend can now advance its own player's worldline.
1069
+ * (Before 0.24.0 this rode the owner token; the server still accepts that
1070
+ * lane, and still exposes no `/admin` mirror — an admin key proves the
1071
+ * project, never the Provider.) */
1072
+ startNextEpisode(environmentId: string, worldInstanceId: string, opts?: {
1073
+ requestId?: string;
1074
+ }): Promise<WorldNextEpisodeResponse>;
1039
1075
  /** Publish the next world revision. Existing world INSTANCES keep the
1040
1076
  * revision they were created on — a published change reaches new instances
1041
1077
  * only, which is what keeps a running story from changing runtime or rules
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import { createHash, createHmac, randomUUID } from 'node:crypto';
23
23
  * a project is running — which is exactly the field you reach for when a
24
24
  * customer's integration behaves like an older SDK than they say they have.
25
25
  * It sat at '0.1.0' for eight releases before anything compared the two. */
26
- export const WORLD_SDK_VERSION = '0.23.0';
26
+ export const WORLD_SDK_VERSION = '0.25.0';
27
27
  export const DEFAULT_BASE_URL = 'https://pouchy.ai/v1';
28
28
  /** Read the commit turn id out of an envelope, so the signature covers the id
29
29
  * the server will commit under. The payload half is base64url JSON; the
@@ -61,6 +61,22 @@ export class WorldApiError extends Error {
61
61
  /** The server's own message, when it sent one. */
62
62
  detail;
63
63
  retryAfterSec;
64
+ /** Why the world refused the effects you proposed — per op, with the
65
+ * server's own reason and code.
66
+ *
67
+ * A rejected turn (422) does NOT answer with `{ error }` like every other
68
+ * refusal: its body is the full turn result, and the reasons live in
69
+ * `rejectedEffects`. So `detail` was undefined for exactly the refusal
70
+ * that has the most to say, and an integrator got `POST … → 422` and
71
+ * nothing else. Measured cost: two rounds of field testing spent guessing
72
+ * which of five ops in a batch was the bad one, while the server had
73
+ * computed the answer (`node "node.hear-amara" requires "node.warning" to
74
+ * be completed first`) and thrown it away at this line.
75
+ *
76
+ * Patch validation is ALL-OR-NOTHING, which is what makes this
77
+ * load-bearing rather than nice-to-have: one bad op voids the batch, so
78
+ * "which one" is the only question worth asking. */
79
+ rejectedEffects;
64
80
  constructor(input) {
65
81
  super(input.message);
66
82
  this.name = 'WorldApiError';
@@ -70,6 +86,8 @@ export class WorldApiError extends Error {
70
86
  this.detail = input.detail;
71
87
  if (input.retryAfterSec !== undefined)
72
88
  this.retryAfterSec = input.retryAfterSec;
89
+ if (input.rejectedEffects !== undefined)
90
+ this.rejectedEffects = input.rejectedEffects;
73
91
  }
74
92
  /** Worth trying again with the SAME idempotency key. A 409 is not: it means
75
93
  * the world disagreed with the request, and repeating it will disagree
@@ -84,6 +102,38 @@ export class WorldApiError extends Error {
84
102
  * delta-seconds number, then the HTTP-date form proxies and CDNs emit.
85
103
  * Anything that is not a non-negative finite number is `undefined` — no
86
104
  * guessing. */
105
+ /** The per-op refusal reasons a rejected turn carries, defensively read.
106
+ *
107
+ * Shaped from an untrusted body, so every field is checked rather than cast:
108
+ * a client that promises `rejectedEffects[].reason` is a string and hands the
109
+ * caller `undefined` has moved the failure one frame later, into the
110
+ * integrator's own logging. Entries that are not a plausible refusal are
111
+ * dropped, and an empty result is `undefined` — absent, not `[]`, so "the
112
+ * server said nothing" and "the server refused nothing" stay different
113
+ * readings. */
114
+ function rejectedEffectsFrom(body) {
115
+ if (!body || typeof body !== 'object')
116
+ return undefined;
117
+ const raw = body.rejectedEffects;
118
+ if (!Array.isArray(raw))
119
+ return undefined;
120
+ const out = [];
121
+ for (const e of raw.slice(0, 64)) {
122
+ if (!e || typeof e !== 'object')
123
+ continue;
124
+ const r = e;
125
+ if (typeof r.reason !== 'string' || !r.reason)
126
+ continue;
127
+ out.push({
128
+ index: typeof r.index === 'number' ? r.index : -1,
129
+ reason: r.reason.slice(0, 500),
130
+ ...(typeof r.roleId === 'string' ? { roleId: r.roleId } : {}),
131
+ ...(typeof r.kind === 'string' ? { kind: r.kind } : {}),
132
+ ...(typeof r.code === 'string' ? { code: r.code } : {})
133
+ });
134
+ }
135
+ return out.length ? out : undefined;
136
+ }
87
137
  function retryAfterFrom(res, body) {
88
138
  for (const k of ['retryAfterSec', 'retryAfter']) {
89
139
  const v = body?.[k];
@@ -356,11 +406,20 @@ export class PouchyWorldClient {
356
406
  * revision it starts at, so a retried call lands on the same run and
357
407
  * answers `created: false` rather than forking the story.
358
408
  *
359
- * OWNER TOKEN ONLY no `/admin` mirror, on the same reasoning as
360
- * `preflightWorld`: the mirror carries reads, and this is the write that
361
- * decides a story moves on. */
362
- startNextEpisode(environmentId, worldInstanceId) {
363
- return this.owner('POST', `/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/episodes/next`, {});
409
+ * MACHINE LANE since 0.24.0 (#2998, world API 1.30.0): a Secret Key plus a
410
+ * provider signature in the `epn:` namespace the same two proofs as
411
+ * `runTurn`, because the party trusted to author every beat, including the
412
+ * one that ends an episode, is the party deciding the story moves on. So
413
+ * an unattended serial backend can now advance its own player's worldline.
414
+ * (Before 0.24.0 this rode the owner token; the server still accepts that
415
+ * lane, and still exposes no `/admin` mirror — an admin key proves the
416
+ * project, never the Provider.) */
417
+ startNextEpisode(environmentId, worldInstanceId, opts = {}) {
418
+ const requestId = opts.requestId ?? newTurnId('epn-req');
419
+ return this.signed(`/projects/${this.projectId}/environments/${environmentId}/instances/${worldInstanceId}/episodes/next`, { requestId },
420
+ // Namespaced id slot: an episode-start signature can never be
421
+ // replayed onto the turn door, the events ingress or a deliberation.
422
+ `epn:${requestId}`);
364
423
  }
365
424
  /** Publish the next world revision. Existing world INSTANCES keep the
366
425
  * revision they were created on — a published change reaches new instances
@@ -861,12 +920,14 @@ export class PouchyWorldClient {
861
920
  ? String(parsed.error)
862
921
  : undefined;
863
922
  const retryAfterSec = retryAfterFrom(response, parsed && typeof parsed === 'object' ? parsed : null);
923
+ const rejectedEffects = rejectedEffectsFrom(parsed);
864
924
  throw new WorldApiError({
865
925
  code: codeForStatus(response.status),
866
926
  status: response.status,
867
927
  message: `${method} ${path} → ${response.status}`,
868
928
  ...(detail !== undefined ? { detail } : {}),
869
- ...(retryAfterSec !== undefined ? { retryAfterSec } : {})
929
+ ...(retryAfterSec !== undefined ? { retryAfterSec } : {}),
930
+ ...(rejectedEffects !== undefined ? { rejectedEffects } : {})
870
931
  });
871
932
  }
872
933
  return parsed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/world-sdk",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Server-side TypeScript client for Pouchy World \u2014 story packages, world definitions, world sessions, coordinated turns, trusted events, replay verification and script drafts. Node only: it holds a project Secret Key and a source signing key, which never belong in a browser or a mobile app.",
5
5
  "type": "module",
6
6
  "license": "SEE LICENSE IN LICENSE",