@pithy-sh/core 0.3.0 → 0.3.1

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,28 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ import type { KitErrorCode } from "./payload";
4
+ /**
5
+ * The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
6
+ * locale may write as a `{placeholder}` in that code's sentence.
7
+ *
8
+ * **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
9
+ * client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
10
+ * placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
11
+ * making a claim about a *call site*, and a call is not a declaration — there was nothing in this
12
+ * repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
13
+ * placeholders. This is that missing half: one table, in `core`, that both sides import.
14
+ *
15
+ * **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
16
+ * pass it on **every** path, including the degraded one — a Cloudflare call that never reached
17
+ * Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
18
+ * sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
19
+ * nothing; it simply may not be written into a sentence, because the sentence has to render on the
20
+ * path where it is missing.
21
+ *
22
+ * A code absent from this table guarantees nothing, which is the honest default and the state of all
23
+ * but one of them.
24
+ */
25
+ export declare const GUARANTEED_ERROR_PARAMS: Partial<Record<KitErrorCode, readonly string[]>>;
26
+ /** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
27
+ export declare function guaranteedErrorParams(code: string): readonly string[];
28
+ //# sourceMappingURL=messageParams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"messageParams.d.ts","sourceRoot":"","sources":["../../src/error/messageParams.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAE9C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,uBAAuB,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC,CAMpF,CAAC;AAEF,sGAAsG;AACtG,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAErE"}
@@ -0,0 +1,31 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+ //#region src/error/messageParams.ts
4
+ /**
5
+ * The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
6
+ * locale may write as a `{placeholder}` in that code's sentence.
7
+ *
8
+ * **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
9
+ * client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
10
+ * placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
11
+ * making a claim about a *call site*, and a call is not a declaration — there was nothing in this
12
+ * repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
13
+ * placeholders. This is that missing half: one table, in `core`, that both sides import.
14
+ *
15
+ * **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
16
+ * pass it on **every** path, including the degraded one — a Cloudflare call that never reached
17
+ * Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
18
+ * sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
19
+ * nothing; it simply may not be written into a sentence, because the sentence has to render on the
20
+ * path where it is missing.
21
+ *
22
+ * A code absent from this table guarantees nothing, which is the honest default and the state of all
23
+ * but one of them.
24
+ */
25
+ const GUARANTEED_ERROR_PARAMS = { "cloudflare/request_failed": ["apiAnswer"] };
26
+ /** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
27
+ function guaranteedErrorParams(code) {
28
+ return GUARANTEED_ERROR_PARAMS[code] ?? [];
29
+ }
30
+ //#endregion
31
+ export { GUARANTEED_ERROR_PARAMS, guaranteedErrorParams };
@@ -3,11 +3,22 @@
3
3
  import type { ErrorPayload } from "./payload";
4
4
  import { PublicErrorPayload } from "./payload";
5
5
  /**
6
- * Render an error for the terminal: the public `message` becomes the problem line and the
7
- * optional `action` the action line — the two-line brand-voice shape `docs/CLI.md` §3.3 specifies.
8
- * This unifies the CLI's error output into the one `PithyError` family: the CLI catches a
9
- * `PithyError`, colorizes the first line via its `style.ts`, and prints this. `detail` is internal
10
- * and never rendered. The same payload that encodes to HTTP renders here — one error, two surfaces.
6
+ * Render an error for the terminal: the public `message` on top and the optional `action` last — the
7
+ * brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
8
+ * `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
9
+ * `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
10
+ * error, two surfaces.
11
+ *
12
+ * **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
13
+ * the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
14
+ * beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
15
+ * two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
16
+ * terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
17
+ * step was declined whole and the operator got the platform's prose about durable execution instead of
18
+ * the refusal. And here, the action line is the one line after the message, so a `message` carrying a
19
+ * break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
20
+ * write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
21
+ * word survives a step record, a `--json` line and a browser.
11
22
  */
12
23
  export declare function renderTerminal(payload: ErrorPayload): string;
13
24
  /** An error as an operator's machine surface states it: the wire fields, plus the remedy. */
@@ -1 +1 @@
1
- {"version":3,"file":"terminal.d.ts","sourceRoot":"","sources":["../../src/error/terminal.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAE5D;AAED,6FAA6F;AAC7F,MAAM,MAAM,aAAa,GAAG,kBAAkB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CAGlE"}
1
+ {"version":3,"file":"terminal.d.ts","sourceRoot":"","sources":["../../src/error/terminal.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AAC9C,OAAO,EAAE,kBAAkB,EAAE,MAAM,WAAW,CAAC;AAE/C;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAE5D;AAED,6FAA6F;AAC7F,MAAM,MAAM,aAAa,GAAG,kBAAkB,GAAG;IAAE,MAAM,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAErE;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,YAAY,GAAG,aAAa,CAGlE"}
@@ -3,11 +3,22 @@
3
3
  import { PublicErrorPayload } from "./payload.js";
4
4
  //#region src/error/terminal.ts
5
5
  /**
6
- * Render an error for the terminal: the public `message` becomes the problem line and the
7
- * optional `action` the action line — the two-line brand-voice shape `docs/CLI.md` §3.3 specifies.
8
- * This unifies the CLI's error output into the one `PithyError` family: the CLI catches a
9
- * `PithyError`, colorizes the first line via its `style.ts`, and prints this. `detail` is internal
10
- * and never rendered. The same payload that encodes to HTTP renders here — one error, two surfaces.
6
+ * Render an error for the terminal: the public `message` on top and the optional `action` last — the
7
+ * brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
8
+ * `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
9
+ * `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
10
+ * error, two surfaces.
11
+ *
12
+ * **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
13
+ * the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
14
+ * beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
15
+ * two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
16
+ * terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
17
+ * step was declined whole and the operator got the platform's prose about durable execution instead of
18
+ * the refusal. And here, the action line is the one line after the message, so a `message` carrying a
19
+ * break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
20
+ * write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
21
+ * word survives a step record, a `--json` line and a browser.
11
22
  */
12
23
  function renderTerminal(payload) {
13
24
  return payload.action ? `${payload.message}\n${payload.action}` : payload.message;
@@ -3,5 +3,5 @@
3
3
  /** This package's npm name — the join key against a release feed. */
4
4
  export declare const PACKAGE_NAME = "@pithy-sh/core";
5
5
  /** This package's version, stamped from its own package.json at generation time. */
6
- export declare const PACKAGE_VERSION = "0.3.0";
6
+ export declare const PACKAGE_VERSION = "0.3.1";
7
7
  //# sourceMappingURL=version.generated.d.ts.map
@@ -4,6 +4,6 @@
4
4
  /** This package's npm name — the join key against a release feed. */
5
5
  const PACKAGE_NAME = "@pithy-sh/core";
6
6
  /** This package's version, stamped from its own package.json at generation time. */
7
- const PACKAGE_VERSION = "0.3.0";
7
+ const PACKAGE_VERSION = "0.3.1";
8
8
  //#endregion
9
9
  export { PACKAGE_NAME, PACKAGE_VERSION };
@@ -68,6 +68,21 @@
68
68
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
69
69
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
70
70
  * includes it. This stops losing a field that was always meant for this reader.
71
+ *
72
+ * ## The encoder owns producing something the reader accepts
73
+ *
74
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
75
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
76
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
77
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
78
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
79
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
80
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
81
+ *
82
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
83
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
84
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
85
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
71
86
  */
72
87
  /**
73
88
  * The separator between the step's sentence and its action. One newline, stated once.
@@ -107,9 +122,19 @@ export declare function splitWorkflowStepCode(text: string): {
107
122
  /**
108
123
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
109
124
  *
110
- * An action is appended only when there is one to append and it is a single line. An action carrying a
111
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
112
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
125
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
126
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
127
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
128
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
129
+ * is a field the reader would have to decline.
130
+ *
131
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
132
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
133
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
134
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
135
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
136
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
137
+ * `defineErrorPayload` is checked against the same shape.
113
138
  */
114
139
  export declare function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string;
115
140
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoEG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAc1C,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAInF;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAK7E;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,IAAI,CAYlF"}
1
+ {"version":3,"file":"stepMessage.d.ts","sourceRoot":"","sources":["../../src/workflow/stepMessage.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;;;GAMG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAc1C,uGAAuG;AACvG,MAAM,WAAW,mBAAmB;IAClC,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,OAAO,EAAE,MAAM,CAAC;IAChB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IAAE,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAInF;AAuBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,mBAAmB,GAAG,MAAM,CAK7E;AAED;;;;;;;GAOG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,GAAG,IAAI,CAYlF"}
@@ -69,6 +69,21 @@
69
69
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
70
70
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
71
71
  * includes it. This stops losing a field that was always meant for this reader.
72
+ *
73
+ * ## The encoder owns producing something the reader accepts
74
+ *
75
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
76
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
77
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
78
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
79
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
80
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
81
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
82
+ *
83
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
84
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
85
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
86
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
72
87
  */
73
88
  /**
74
89
  * The separator between the step's sentence and its action. One newline, stated once.
@@ -111,16 +126,43 @@ function splitWorkflowStepCode(text) {
111
126
  };
112
127
  }
113
128
  /**
129
+ * One half as this encoding can carry it: a single line, within the bound.
130
+ *
131
+ * Both reshapes exist because the alternative is losing the field, and the two ways a half can be
132
+ * unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
133
+ * reader declines — the sentence would take the remedy down with it, or the remedy would forge a
134
+ * second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
135
+ * upstream's own text before it lands in `message`. Length is the same argument with a different
136
+ * cause: half a sentence naming its code and its remedy beats the platform's prose about durable
137
+ * execution, so the tail is spent rather than the field. The whole of it was never here anyway —
138
+ * `detail` holds the raw text, on the far side of a boundary it does not cross.
139
+ */
140
+ function encodable(text) {
141
+ const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
142
+ if (flattened.length <= 512) return flattened;
143
+ return `${flattened.slice(0, 511).trimEnd()}…`;
144
+ }
145
+ /**
114
146
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
115
147
  *
116
- * An action is appended only when there is one to append and it is a single line. An action carrying a
117
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
118
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
148
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
149
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
150
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
151
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
152
+ * is a field the reader would have to decline.
153
+ *
154
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
155
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
156
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
157
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
158
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
159
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
160
+ * `defineErrorPayload` is checked against the same shape.
119
161
  */
120
162
  function encodeWorkflowStepMessage(fields) {
121
- const stated = `${fields.code}: ${fields.message}`;
122
- const action = fields.action?.trim();
123
- if (action === void 0 || action === "" || LINE_BREAK.test(action)) return stated;
163
+ const stated = `${fields.code}: ${encodable(fields.message)}`;
164
+ const action = encodable(fields.action ?? "");
165
+ if (action === "") return stated;
124
166
  return `${stated}
125
167
  ${action}`;
126
168
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pithy-sh/core",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,38 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { KitErrorCode } from "./payload";
5
+
6
+ /**
7
+ * The `params` a throw site **guarantees** for a kit error code — and therefore the only names a
8
+ * locale may write as a `{placeholder}` in that code's sentence.
9
+ *
10
+ * **Why a declaration exists at all.** `params` crosses the wire with `message` so a translating
11
+ * client can render its own wording for a code (CLAUDE.md §Errors), and `interpolate` leaves a
12
+ * placeholder nobody supplied written out as `{apiAnswer}`. So a locale writing a placeholder is
13
+ * making a claim about a *call site*, and a call is not a declaration — there was nothing in this
14
+ * repository for a catalog test to read, which is why `@pithy-sh/i18n` could only pin the absence of
15
+ * placeholders. This is that missing half: one table, in `core`, that both sides import.
16
+ *
17
+ * **Guaranteed means every throw, not most.** A name belongs here only when the code's throw sites
18
+ * pass it on **every** path, including the degraded one — a Cloudflare call that never reached
19
+ * Cloudflare still supplies `apiAnswer`, as the empty string. A param a throw site passes only
20
+ * sometimes is still useful to a client reading `payload.params` directly, and is still stripped from
21
+ * nothing; it simply may not be written into a sentence, because the sentence has to render on the
22
+ * path where it is missing.
23
+ *
24
+ * A code absent from this table guarantees nothing, which is the honest default and the state of all
25
+ * but one of them.
26
+ */
27
+ export const GUARANTEED_ERROR_PARAMS: Partial<Record<KitErrorCode, readonly string[]>> = {
28
+ // `@pithy-sh/cloudflare`'s `cloudflareRefusal` composes every Cloudflare refusal and always passes
29
+ // `apiAnswer`: Cloudflare's own code and sentence, punctuated to append (`": 10000 Authentication
30
+ // error"`), or `""` when the call failed without an answer. The value carries its own separator so
31
+ // one sentence renders correctly both ways — a locale cannot write an `if`.
32
+ "cloudflare/request_failed": ["apiAnswer"],
33
+ };
34
+
35
+ /** The names a locale may interpolate into `code`'s sentence. Empty when the code guarantees none. */
36
+ export function guaranteedErrorParams(code: string): readonly string[] {
37
+ return GUARANTEED_ERROR_PARAMS[code as KitErrorCode] ?? [];
38
+ }
@@ -5,11 +5,22 @@ import type { ErrorPayload } from "./payload";
5
5
  import { PublicErrorPayload } from "./payload";
6
6
 
7
7
  /**
8
- * Render an error for the terminal: the public `message` becomes the problem line and the
9
- * optional `action` the action line — the two-line brand-voice shape `docs/CLI.md` §3.3 specifies.
10
- * This unifies the CLI's error output into the one `PithyError` family: the CLI catches a
11
- * `PithyError`, colorizes the first line via its `style.ts`, and prints this. `detail` is internal
12
- * and never rendered. The same payload that encodes to HTTP renders here — one error, two surfaces.
8
+ * Render an error for the terminal: the public `message` on top and the optional `action` last — the
9
+ * brand-voice shape `docs/CLI.md` §3.3 specifies. This unifies the CLI's error output into the one
10
+ * `PithyError` family: the CLI catches a `PithyError`, colorizes via its `style.ts`, and prints this.
11
+ * `detail` is internal and never rendered. The same payload that encodes to HTTP renders here — one
12
+ * error, two surfaces.
13
+ *
14
+ * **`message` is one line, and the newline this adds is the only one in the rendering.** #534 tried
15
+ * the other way — a problem line with an upstream's `errors[]` indented beneath it, which reads
16
+ * beautifully in a terminal and is unencodable everywhere else. The newline is a *field separator* in
17
+ * two places that matter more than the indent does. `../workflow/stepMessage` uses it to carry a
18
+ * terminal step's remedy across a durable boundary, so a multi-line `message` raised inside a Workflow
19
+ * step was declined whole and the operator got the platform's prose about durable execution instead of
20
+ * the refusal. And here, the action line is the one line after the message, so a `message` carrying a
21
+ * break puts upstream text where the remedy goes — a forged action line, from text a Worker we did not
22
+ * write can choose. Upstream words are marked with a word (`Cloudflare said:`), never with a line: a
23
+ * word survives a step record, a `--json` line and a browser.
13
24
  */
14
25
  export function renderTerminal(payload: ErrorPayload): string {
15
26
  return payload.action ? `${payload.message}\n${payload.action}` : payload.message;
@@ -13,4 +13,4 @@
13
13
  export const PACKAGE_NAME = "@pithy-sh/core";
14
14
 
15
15
  /** This package's version, stamped from its own package.json at generation time. */
16
- export const PACKAGE_VERSION = "0.3.0";
16
+ export const PACKAGE_VERSION = "0.3.1";
@@ -69,6 +69,21 @@
69
69
  * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
70
70
  * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
71
71
  * includes it. This stops losing a field that was always meant for this reader.
72
+ *
73
+ * ## The encoder owns producing something the reader accepts
74
+ *
75
+ * The reader is strict, and it has to be: text the kit did not write must not become an operator's
76
+ * sentence. The consequence is that a *writer* handing it a shape it declines loses the whole channel —
77
+ * the code, the sentence and the remedy at once — and the operator is handed `core/workflow_failed` 500
78
+ * with the platform's prose about durable execution. That is not a hypothetical. #534 put an upstream's
79
+ * `errors[]` into a multi-line `message`, and every Cloudflare refusal raised inside a `classifiedSteps`
80
+ * step stopped crossing this boundary at all: the first newline read as the code/action separator and
81
+ * the second as a shape this never writes, so the read returned `null` and the refusal died at the step.
82
+ *
83
+ * So {@link encodeWorkflowStepMessage} is **total**: whatever fields it is handed, what it writes decodes
84
+ * back. Each half is flattened to one line and, past the bound, truncated with an ellipsis rather than
85
+ * dropped. The asymmetry with the reader is deliberate and is the whole point — the writer owns text the
86
+ * kit authored and may reshape it; the reader is handed text from a Worker we did not write and may not.
72
87
  */
73
88
 
74
89
  /**
@@ -123,17 +138,48 @@ export function splitWorkflowStepCode(text: string): { code?: string; rest: stri
123
138
  return { code: matched[1], rest: text.slice(matched[0].length) };
124
139
  }
125
140
 
141
+ /**
142
+ * One half as this encoding can carry it: a single line, within the bound.
143
+ *
144
+ * Both reshapes exist because the alternative is losing the field, and the two ways a half can be
145
+ * unreadable are the two ways a writer actually gets it wrong. A line break would encode a shape the
146
+ * reader declines — the sentence would take the remedy down with it, or the remedy would forge a
147
+ * second field — so every break collapses to a space, exactly as `@pithy-sh/cloudflare` flattens an
148
+ * upstream's own text before it lands in `message`. Length is the same argument with a different
149
+ * cause: half a sentence naming its code and its remedy beats the platform's prose about durable
150
+ * execution, so the tail is spent rather than the field. The whole of it was never here anyway —
151
+ * `detail` holds the raw text, on the far side of a boundary it does not cross.
152
+ */
153
+ function encodable(text: string): string {
154
+ // The break and the whitespace that dressed it: an indent under a line is structure, and structure
155
+ // that survives as run-on spaces reads as a typo. Whitespace elsewhere in the half is left alone —
156
+ // this reshapes what the line break did, not what the sentence says.
157
+ const flattened = text.replace(/\s*[\n\r]+\s*/g, " ").trim();
158
+ if (flattened.length <= MAX_WORKFLOW_STEP_TEXT) return flattened;
159
+ return `${flattened.slice(0, MAX_WORKFLOW_STEP_TEXT - 1).trimEnd()}…`;
160
+ }
161
+
126
162
  /**
127
163
  * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
128
164
  *
129
- * An action is appended only when there is one to append and it is a single line. An action carrying a
130
- * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
131
- * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
165
+ * **What this writes, that reads back — for a well-formed code and a sentence there is one.** Each half
166
+ * goes through {@link encodable}, so a multi-line sentence is flattened rather than fatal and an overlong
167
+ * one is truncated rather than declined. An action is appended only when there is one left after that —
168
+ * a remedy that was empty or only whitespace leaves no trailing separator, because a dangling separator
169
+ * is a field the reader would have to decline.
170
+ *
171
+ * **Two inputs it deliberately cannot represent, so "total" is not the whole word for it.** An empty
172
+ * `message` encodes to text {@link decodeWorkflowStepMessage} declines, and so does a `code` that does
173
+ * not match `CODE_PREFIX`. Both refusals are the boundary working: a step that failed without a sentence
174
+ * has none to promote — the caller's own `fallbackMessage` is what the operator should see — and a code
175
+ * the reader cannot recognize is exactly the foreign text this encoding exists to keep out of an
176
+ * operator's sentence. Neither is reachable from a kit throw site; an adopter code declared through
177
+ * `defineErrorPayload` is checked against the same shape.
132
178
  */
133
179
  export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
134
- const stated = `${fields.code}: ${fields.message}`;
135
- const action = fields.action?.trim();
136
- if (action === undefined || action === "" || LINE_BREAK.test(action)) return stated;
180
+ const stated = `${fields.code}: ${encodable(fields.message)}`;
181
+ const action = encodable(fields.action ?? "");
182
+ if (action === "") return stated;
137
183
  return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
138
184
  }
139
185