@pouchy_ai/world-sdk 0.24.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,29 @@
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
+
3
27
  ## 0.24.0
4
28
 
5
29
  - **`startNextEpisode` now rides the MACHINE lane** (Secret Key + provider
package/README.md CHANGED
@@ -240,7 +240,9 @@ an `approved` editorial can be exported.
240
240
 
241
241
  **Helpers** — `signSourceRequest` (the exact canonical the server verifies),
242
242
  `newTurnId` / `isReservedTurnId` (idempotency), `describeTurn` (read a result
243
- 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).
244
246
 
245
247
  ## When a signed door refuses you
246
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.24.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
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.24.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];
@@ -870,12 +920,14 @@ export class PouchyWorldClient {
870
920
  ? String(parsed.error)
871
921
  : undefined;
872
922
  const retryAfterSec = retryAfterFrom(response, parsed && typeof parsed === 'object' ? parsed : null);
923
+ const rejectedEffects = rejectedEffectsFrom(parsed);
873
924
  throw new WorldApiError({
874
925
  code: codeForStatus(response.status),
875
926
  status: response.status,
876
927
  message: `${method} ${path} → ${response.status}`,
877
928
  ...(detail !== undefined ? { detail } : {}),
878
- ...(retryAfterSec !== undefined ? { retryAfterSec } : {})
929
+ ...(retryAfterSec !== undefined ? { retryAfterSec } : {}),
930
+ ...(rejectedEffects !== undefined ? { rejectedEffects } : {})
879
931
  });
880
932
  }
881
933
  return parsed;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pouchy_ai/world-sdk",
3
- "version": "0.24.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",