From 5f3417528b207f30ba6192188b741b86e665362b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 16:56:36 +0000 Subject: [PATCH 1/2] refactor(service-settings)!: settings error bodies use the declared `details` slot (#4224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four `/api/settings/*` error branches spread ad-hoc context as SIBLINGS of `code` and `message` inside `error` — `namespace`, `key`, `reason`, `fields`. `ApiErrorSchema` declares none of them. The bodies passed every gate anyway: the schema is a plain `z.object`, so unknown keys were STRIPPED rather than rejected, and `envelopeViolations` inspects only the body's top level. They were conformant by stripping, not by declaration — and the same module already used `error.details` correctly one branch over (`SETTINGS_ACTION_FAILED`), so this was one file speaking two dialects. All four move into `details`, values unchanged: SETTINGS_FORBIDDEN error.namespace -> error.details.namespace UNKNOWN_KEY error.namespace/.key -> error.details.{namespace,key} SETTINGS_LOCKED error.namespace/.key/.reason -> error.details.{namespace,key,reason} SETTINGS_VALIDATION error.namespace/.fields -> error.details.{namespace,fields} `SETTINGS_VALIDATION.fields` also changes shape, which is the decision the issue asked for: `fields` is the name ADR-0114 (#3977) closed for `FieldError[]`, so keeping a `Record` under it would leave one spelling meaning two shapes. It becomes the declared array — `code` is `required` or `invalid_format` from the closed field-level catalog, `label` and `constraint.pattern` carry what the message interpolates. The constraint kind is knowable at the throw site and nowhere after it, so the exported `SettingsValidationError.fields` changes with it rather than being reconstructed from prose at the route. It stays `details.fields` rather than a top-level `error.fields` because `fields` is declared on `EnhancedApiErrorSchema`, not on the base `ApiErrorSchema` these routes emit — the same reason `validation-failure.ts` puts the dispatcher's array there. `sendError`'s last parameter is tightened from `Record` to `ApiError`'s own optional fields, and `code` from `string` to the closed ADR-0112 `ErrorCode` union. That is what keeps it fixed: an undeclared sibling is now a compile error at the call site instead of a key that evaporates at the schema boundary. The conformance suite gains the assertion neither existing gate could make — every key of `body.error` must be one `ApiErrorSchema.shape` declares, derived from the schema rather than restated, so it tracks the contract both ways. Both new pins were verified to fail against the pre-#4224 shape. Consumer note: `error.key` had one live reader that the issue's `packages/` + `examples/` grep could not see — objectui's `SettingsView` save handler. Its tolerant dual-read ships in objectui#3079 and should land first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tv2NYMBTrzVVRWiWWuyec5 --- .../settings-error-details-declared-slot.md | 58 +++++++ .../src/envelope.conformance.test.ts | 154 +++++++++++++++++- .../service-settings/src/settings-routes.ts | 53 ++++-- .../src/settings-service.test.ts | 29 +++- .../service-settings/src/settings-service.ts | 26 ++- .../src/settings-service.types.ts | 21 ++- 6 files changed, 314 insertions(+), 27 deletions(-) create mode 100644 .changeset/settings-error-details-declared-slot.md diff --git a/.changeset/settings-error-details-declared-slot.md b/.changeset/settings-error-details-declared-slot.md new file mode 100644 index 0000000000..000fe80374 --- /dev/null +++ b/.changeset/settings-error-details-declared-slot.md @@ -0,0 +1,58 @@ +--- +"@objectstack/service-settings": minor +--- + +refactor!: settings error bodies stop hanging undeclared keys beside `code`/`message` (#4224) + +Four `/api/settings/*` error branches spread ad-hoc context as SIBLINGS of `code` +and `message` inside `error`. `ApiErrorSchema` declares `code`, `message`, +`category?`, `httpStatus?`, `details?`, `requestId?` — and none of `namespace`, +`key`, `reason`, `fields`. The bodies passed every gate anyway: `ApiErrorSchema` +is a plain `z.object`, so unknown keys were **stripped** rather than rejected, +and `envelopeViolations` inspects only the body's top level. They were conformant +*by stripping*, not by declaration. The same module already used the declared +slot correctly one branch over (`SETTINGS_ACTION_FAILED` → `error.details`), so +this is one file speaking two dialects, not a missing capability. + +**Wire change — FROM → TO.** In every case the values are unchanged; only their +position moves, into the `details` slot the contract declares: + +| Code | HTTP | FROM | TO | +|---|---|---|---| +| `SETTINGS_FORBIDDEN` | 403 | `error.namespace` | `error.details.namespace` | +| `UNKNOWN_KEY` | 400 | `error.namespace`, `error.key` | `error.details.namespace`, `error.details.key` | +| `SETTINGS_LOCKED` | 409 | `error.namespace`, `error.key`, `error.reason` | `error.details.namespace`, `error.details.key`, `error.details.reason` | +| `SETTINGS_VALIDATION` | 400 | `error.namespace`, `error.fields` | `error.details.namespace`, `error.details.fields` | + +**One-line fix for a consumer:** read `error.details.` where you read +`error.`, or `error.details?. ?? error.` if you support servers on +both sides of the change. The console's own fix (objectui#3078) is the tolerant +form. + +**`SETTINGS_VALIDATION.fields` also changes shape**, because `fields` is the name +ADR-0114 (#3977) closed for `FieldError[]` and keeping a map under it would leave +one spelling meaning two shapes: + +- **FROM** `{ [key]: message }` — a `Record`, the constraint named + only in the prose of the message. +- **TO** `FieldError[]` — `{ field, code, message, label, constraint? }`, where + `code` is a member of the closed field-level catalog: `required` for an empty + required specifier, `invalid_format` for a value that misses its declared + `pattern` (which travels as `constraint.pattern`). + +A consumer that rendered the map's values reads `f.message` per entry instead; +one that wants to branch on *why* a value was rejected can now read `f.code` +rather than substring-matching English. objectui's `extractFieldErrors` already +reads `details.fields`, so settings validation failures become renderable +per-field there with no further change. + +**The exported `SettingsValidationError.fields` changes with it** — same +`Record` → `FieldError[]` mapping — since the route only relays +what the service throws, and the constraint kind is knowable at the throw site +and nowhere after it. + +`sendError`'s last parameter is tightened from `extra?: Record` +to `ApiError`'s own optional fields, and its `code` from `string` to the closed +ADR-0112 `ErrorCode` union. That is what keeps this fixed: an undeclared sibling +is now a compile error at the call site rather than a key that quietly evaporates +at the schema boundary. diff --git a/packages/services/service-settings/src/envelope.conformance.test.ts b/packages/services/service-settings/src/envelope.conformance.test.ts index 71f90b0f33..007a25fb5b 100644 --- a/packages/services/service-settings/src/envelope.conformance.test.ts +++ b/packages/services/service-settings/src/envelope.conformance.test.ts @@ -32,7 +32,7 @@ */ import { describe, expect, it, vi } from 'vitest'; -import { BaseResponseSchema, envelopeViolations } from '@objectstack/spec/api'; +import { ApiErrorSchema, BaseResponseSchema, FieldErrorSchema, envelopeViolations } from '@objectstack/spec/api'; import { SettingsNamespacePayloadSchema } from '@objectstack/spec/system'; import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service'; @@ -288,10 +288,162 @@ describe('settings envelope (#3843) — error bodies', () => { // bare-string dialect the sibling modules carried cannot land quietly. expect(typeof body.error).not.toBe('string'); expect(body.code).toBeUndefined(); + + // [#4224] No key inside `error` that `ApiErrorSchema` does not declare. + // + // This is the assertion neither gate above can make. `safeParse` STRIPS + // unknown keys (`ApiErrorSchema` is a plain `z.object`), so it passed + // `error.namespace` / `.key` / `.reason` / `.fields` for as long as this + // module emitted them; `envelopeViolations` deliberately inspects only the + // body's top level. Between them a body could carry four undeclared keys + // and read as fully conformant — conformant *by stripping*, which is not + // the same claim as conformant by declaration. + // + // Derived from `ApiErrorSchema.shape` rather than a hand-written list, so + // a field added to the contract is allowed here the moment it is declared, + // and one removed stops being allowed — the failure mode of a restated + // list is that it silently keeps blessing a retired key. + expect( + Object.keys(body.error).filter((k) => !(k in (ApiErrorSchema as any).shape)), + `error carries keys ApiErrorSchema does not declare: ${JSON.stringify(body.error)}`, + ).toEqual([]); }); } }); +describe('settings envelope (#4224) — the four ad-hoc keys travel in the declared slot', () => { + /** + * Each of these branches used to spread its context as SIBLINGS of `code` and + * `message`. They now use `error.details`, the slot `ApiErrorSchema` declares + * for exactly this and the one `SETTINGS_ACTION_FAILED` was already using one + * branch over — so the module speaks one dialect rather than two. + * + * Both directions are asserted per case: the value is under `details`, AND the + * old top-level spelling is gone. Asserting only the first would pass a body + * that emitted both, which is how a "migration" quietly becomes a permanent + * dual-write. + */ + const CASES: Array<{ + name: string; + status: number; + code: string; + details: Record; + gone: string[]; + run: () => Promise; + }> = [ + { + name: 'SETTINGS_FORBIDDEN carries its namespace', + status: 403, + code: 'SETTINGS_FORBIDDEN', + details: { namespace: 'branding' }, + gone: ['namespace'], + run: async () => { + const { http } = mount(anon); + return drive(http, 'GET /api/settings/:namespace', { params: { namespace: 'branding' } }); + }, + }, + { + name: 'UNKNOWN_KEY carries its namespace and key', + status: 400, + code: 'UNKNOWN_KEY', + details: { namespace: 'branding', key: 'not_a_key' }, + gone: ['namespace', 'key'], + run: async () => { + const { http } = mount(); + return drive(http, 'PUT /api/settings/:namespace', { + params: { namespace: 'branding' }, + body: { not_a_key: 1 }, + }); + }, + }, + { + name: 'SETTINGS_LOCKED carries its namespace, key and reason', + status: 409, + code: 'SETTINGS_LOCKED', + details: { namespace: 'branding', key: 'workspace_name', reason: 'locked-by-env' }, + gone: ['namespace', 'key', 'reason'], + run: async () => { + // An `OS_BRANDING_WORKSPACE_NAME` in the environment locks the key, so a + // write to it is refused — the one branch that needs a locked namespace. + const { http } = mount(admin, { OS_BRANDING_WORKSPACE_NAME: 'Locked Co' }); + return drive(http, 'PUT /api/settings/:namespace', { + params: { namespace: 'branding' }, + body: { workspace_name: 'Acme' }, + }); + }, + }, + ]; + + for (const c of CASES) { + it(`${c.name} under error.details, not beside code/message`, async () => { + const { status, body } = await c.run(); + expect(status).toBe(c.status); + expect(body.error.code).toBe(c.code); + expect(body.error.details).toMatchObject(c.details); + for (const k of c.gone) { + expect(body.error[k], `error.${k} is still a sibling of code/message`).toBeUndefined(); + } + }); + } +}); + +describe('settings envelope (#4224) — SETTINGS_VALIDATION speaks the field-level vocabulary', () => { + /** + * The decision #4224 asked for: `fields` was a `Record` hung + * beside `code`, and `fields` is the name ADR-0114 (#3977) closed for + * `FieldError[]`. Keeping the map under that name would have left one spelling + * meaning two shapes — so it became the declared array, in the declared slot. + */ + const lockedPattern = () => { + const { http, service } = mount(); + service.registerManifest({ + namespace: 'validated', + label: 'Validated', + writePermission: 'setup.write', + readPermission: 'setup.access', + specifiers: [ + { key: 'model', type: 'text', label: 'Model', pattern: '^[a-z]+/[a-z]+$', description: 'Use provider/model.' }, + { key: 'token', type: 'text', label: 'API token', required: true }, + ], + } as any); + return http; + }; + + it('every entry parses as the declared FieldError', async () => { + const http = lockedPattern(); + const { status, body } = await drive(http, 'PUT /api/settings/:namespace', { + params: { namespace: 'validated' }, + body: { model: 'gpt-4o', token: '' }, + }); + expect(status).toBe(400); + expect(body.error.code).toBe('SETTINGS_VALIDATION'); + + const fields = body.error.details?.fields; + expect(Array.isArray(fields), `details.fields is not an array: ${JSON.stringify(body.error)}`).toBe(true); + expect(fields.length).toBeGreaterThan(0); + for (const f of fields) { + const parsed = FieldErrorSchema.safeParse(f); + expect(parsed.success, `not a FieldError: ${JSON.stringify(parsed.error ?? f)}`).toBe(true); + } + // The codes come from the closed ADR-0114 catalog, so a consumer can branch + // on the constraint instead of substring-matching the message. + expect(fields.map((f: any) => f.code).sort()).toEqual(['invalid_format', 'required']); + }); + + it('the pre-#4224 map is gone from both of its old spellings', async () => { + const http = lockedPattern(); + const { body } = await drive(http, 'PUT /api/settings/:namespace', { + params: { namespace: 'validated' }, + body: { token: '' }, + }); + // Not a sibling of code/message any more … + expect(body.error.fields).toBeUndefined(); + expect(body.error.namespace).toBeUndefined(); + // … and not the `key → message` object under its new home either. + expect(Array.isArray(body.error.details.fields)).toBe(true); + }); +}); + describe('settings envelope (#3843) — a reported action failure keeps its detail', () => { it('carries the whole SettingsActionResult under error.details', async () => { const { http } = mount(); diff --git a/packages/services/service-settings/src/settings-routes.ts b/packages/services/service-settings/src/settings-routes.ts index 2209f54b29..761112c3be 100644 --- a/packages/services/service-settings/src/settings-routes.ts +++ b/packages/services/service-settings/src/settings-routes.ts @@ -13,6 +13,7 @@ * `SettingsService`. */ +import type { ApiError, ErrorCode } from '@objectstack/spec/api'; import type { IHttpServer, IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts'; import { SettingsService } from './settings-service.js'; import { @@ -57,8 +58,31 @@ const defaultContext = (_req: IHttpRequest): SettingsContext => ({ enforced: tru * response, success and error alike, and a caller keying on `success` (as * `ObjectStackClient.unwrapResponse` does) could not tell these routes' bodies * apart from an already-unwrapped payload. + * + * ## Why the last parameter is the declared fields, not a `Record` (#4224) + * + * It used to be `extra?: Record`, spread straight into `error`, + * and four branches used it to hang `namespace` / `key` / `reason` / `fields` + * beside `code` and `message`. `ApiErrorSchema` declares none of those. The + * bodies still passed every gate — `ApiErrorSchema` is a plain `z.object`, so + * unknown keys were STRIPPED rather than rejected, and `envelopeViolations` + * inspects only the body's top level — which made them conformant *by + * stripping* rather than by declaration, and made a `safeParse` pass mean less + * than it looked like it meant. + * + * Typing the parameter as `ApiError`'s own optional fields is what stops that + * coming back: an undeclared sibling is now a compile error at the call site + * instead of a key that quietly evaporates at the schema boundary. `code` is + * likewise the closed ADR-0112 `ErrorCode` union rather than `string`, so an + * unregistered code fails to typecheck rather than to parse. */ -function sendError(res: IHttpResponse, status: number, code: string, message: string, extra?: Record) { +function sendError( + res: IHttpResponse, + status: number, + code: ErrorCode, + message: string, + extra?: Pick, +) { res.status(status).json({ success: false, error: { code, message, ...extra } }); } @@ -91,7 +115,7 @@ export function registerSettingsRoutes( sendOk(res, { manifests }); } catch (err: any) { if (err instanceof SettingsForbiddenError) { - sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace }); + sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to list manifests'); } @@ -106,7 +130,7 @@ export function registerSettingsRoutes( sendOk(res, payload); } catch (err: any) { if (err instanceof SettingsForbiddenError) { - sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace }); + sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message); } else { @@ -144,21 +168,28 @@ export function registerSettingsRoutes( sendOk(res, { values: result }); } catch (err: any) { if (err instanceof SettingsForbiddenError) { - sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace }); + sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof SettingsLockedError) { sendError(res, 409, 'SETTINGS_LOCKED', err.message, { - namespace: err.namespace, - key: err.key, - reason: err.reason, + details: { namespace: err.namespace, key: err.key, reason: err.reason }, }); } else if (err instanceof UnknownNamespaceError) { sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message); } else if (err instanceof UnknownKeyError) { - sendError(res, 400, 'UNKNOWN_KEY', err.message, { namespace: err.namespace, key: err.key }); + sendError(res, 400, 'UNKNOWN_KEY', err.message, { + details: { namespace: err.namespace, key: err.key }, + }); } else if (err instanceof SettingsValidationError) { + // `details.fields` is `FieldError[]` (ADR-0114) — the same array shape + // the record validators and the dispatcher's validation exit carry, so + // the console's field-error extractor reads this one with no per-surface + // special case. It is `details.fields` rather than a top-level + // `error.fields` because `fields` is declared on + // `EnhancedApiErrorSchema`, not on the base `ApiErrorSchema` these + // routes emit; `validation-failure.ts` puts the array in the same place + // for the same reason. sendError(res, 400, 'SETTINGS_VALIDATION', err.message, { - namespace: err.namespace, - fields: err.fields, + details: { namespace: err.namespace, fields: err.fields }, }); } else { sendError(res, 500, 'INTERNAL_ERROR', err?.message ?? 'Failed to write namespace'); @@ -190,7 +221,7 @@ export function registerSettingsRoutes( } } catch (err: any) { if (err instanceof SettingsForbiddenError) { - sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { namespace: err.namespace }); + sendError(res, 403, 'SETTINGS_FORBIDDEN', err.message, { details: { namespace: err.namespace } }); } else if (err instanceof UnknownNamespaceError) { sendError(res, 404, 'UNKNOWN_NAMESPACE', err.message); } else { diff --git a/packages/services/service-settings/src/settings-service.test.ts b/packages/services/service-settings/src/settings-service.test.ts index cc2111be0e..a8de884e82 100644 --- a/packages/services/service-settings/src/settings-service.test.ts +++ b/packages/services/service-settings/src/settings-service.test.ts @@ -311,7 +311,15 @@ describe('SettingsService — save-time validation (required/visible/pattern)', }), ).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION', - fields: { cloudflare_api_key: expect.stringContaining('required') }, + // `FieldError[]` since #4224 — the constraint is named by `code`, not + // only described in the prose of `message` (ADR-0114). + fields: [ + { + field: 'cloudflare_api_key', + code: 'required', + message: expect.stringContaining('required'), + }, + ], }); // Nothing was persisted — the batch is atomic. expect((await svc.get('ai', 'provider')).source).toBe('default'); @@ -321,10 +329,10 @@ describe('SettingsService — save-time validation (required/visible/pattern)', const svc = aiService(); await expect(svc.setMany('ai', { provider: 'cloudflare' })).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION', - fields: { - cloudflare_account_id: expect.any(String), - cloudflare_api_key: expect.any(String), - }, + fields: expect.arrayContaining([ + expect.objectContaining({ field: 'cloudflare_account_id', code: 'required' }), + expect.objectContaining({ field: 'cloudflare_api_key', code: 'required' }), + ]), }); }); @@ -357,7 +365,16 @@ describe('SettingsService — save-time validation (required/visible/pattern)', svc.setMany('ai', { provider: 'gateway', gateway_model: 'gpt-4o' }), ).rejects.toMatchObject({ code: 'SETTINGS_VALIDATION', - fields: { gateway_model: expect.stringContaining('format') }, + // A pattern miss is `invalid_format`, and the pattern it missed travels + // as a discrete `constraint` rather than only inside the sentence. + fields: [ + { + field: 'gateway_model', + code: 'invalid_format', + message: expect.stringContaining('format'), + constraint: { pattern: expect.any(String) }, + }, + ], }); await expect( svc.setMany('ai', { provider: 'gateway', gateway_model: 'anthropic/claude-sonnet-4.6' }), diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 2d9ef4773a..1a5f07913f 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -1,5 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import type { FieldError } from '@objectstack/spec/api'; import type { SettingsManifest, ResolvedSettingValue, @@ -639,7 +640,11 @@ export class SettingsService { } const patchKeys = new Set(Object.keys(patch)); - const errors: Record = {}; + // One `FieldError` per offending key (ADR-0114). The constraint kind is + // known HERE and nowhere later — the route that serves this used to receive + // a `key → message` map and could only re-emit the prose — so the code is + // stamped at the point the check fails rather than inferred from the text. + const errors: FieldError[] = []; for (const spec of (reg.manifest.specifiers ?? []) as Array>) { const key = spec.key as string | undefined; @@ -666,7 +671,12 @@ export class SettingsService { (typeof value === 'string' && value.trim() === ''); if (spec.required === true && empty) { - errors[key] = `${label} is required for this configuration.`; + errors.push({ + field: key, + code: 'required', + message: `${label} is required for this configuration.`, + label, + }); continue; } if (!empty && typeof spec.pattern === 'string' && typeof value === 'string') { @@ -678,12 +688,20 @@ export class SettingsService { } if (re && !re.test(value)) { const hint = typeof spec.description === 'string' ? ` ${spec.description}` : ''; - errors[key] = `${label} does not match the expected format.${hint}`; + errors.push({ + field: key, + code: 'invalid_format', + message: `${label} does not match the expected format.${hint}`, + label, + // The declared pattern, so a client can format its own message + // rather than parsing ours (`FieldError.constraint`, ADR-0114). + constraint: { pattern: spec.pattern }, + }); } } } - if (Object.keys(errors).length > 0) { + if (errors.length > 0) { throw new SettingsValidationError(namespace, errors); } } diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 0b0a9ee3f8..c2f851d34d 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -23,6 +23,7 @@ * plugin wires those pieces up. */ +import type { FieldError } from '@objectstack/spec/api'; import type { SettingsActionResult, SpecifierScope } from '@objectstack/spec/system'; import { type CryptoAdapter } from './crypto-adapter.js'; @@ -267,19 +268,29 @@ export class SettingsForbiddenError extends Error { /** * Thrown when a write would leave the namespace in an invalid state — * a `required` field that is visible under the post-write values is - * empty (e.g. provider=cloudflare saved without an API key). The whole - * batch is rejected; `fields` maps each offending key to a message the - * UI can render inline. + * empty (e.g. provider=cloudflare saved without an API key), or a value + * that does not match its specifier's declared `pattern`. The whole + * batch is rejected; `fields` carries one entry per offending key, which + * the UI can render inline against the input it addresses. + * + * `fields` is `FieldError[]` — the field-level vocabulary ADR-0114 closed + * (#3977) — rather than the `Record` map it was until #4224. + * The map predated that catalog and named the constraint only in prose, so + * a consumer could render the sentence but not branch on *which* constraint + * failed; `code` (`required` / `invalid_format`) now says it in the one + * spelling every other validator in the platform uses. `label` and + * `constraint` carry what the message interpolates, so a form can compose + * its own text instead of parsing ours. */ export class SettingsValidationError extends Error { readonly code = 'SETTINGS_VALIDATION' as const; constructor( readonly namespace: string, - readonly fields: Record, + readonly fields: FieldError[], ) { super( `Settings for '${namespace}' are incomplete: ` + - Object.entries(fields).map(([k, msg]) => `${k} — ${msg}`).join('; '), + fields.map((f) => `${f.field} — ${f.message}`).join('; '), ); } } From 73faae84780c3ded153d458d6221caeef64b0742 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 17:00:54 +0000 Subject: [PATCH 2/2] docs(settings-service): state the shape of `SettingsValidationError.fields` and where errors carry context (#4224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs-drift check flagged this page against the #4224 change. Re-verified: it documents error CODES, not wire shapes, so nothing on it had become false. Two lines added where the change is load-bearing rather than left implicit — that `fields` is now the declared `FieldError[]` with `code` naming the constraint, and that the REST surface carries per-branch context in `error.details` rather than beside `error.code` / `error.message`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Tv2NYMBTrzVVRWiWWuyec5 --- content/docs/kernel/runtime-services/settings-service.mdx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/kernel/runtime-services/settings-service.mdx b/content/docs/kernel/runtime-services/settings-service.mdx index 7ce9dc042a..0d0eadebb3 100644 --- a/content/docs/kernel/runtime-services/settings-service.mdx +++ b/content/docs/kernel/runtime-services/settings-service.mdx @@ -26,7 +26,9 @@ services.settings.subscribe(namespace: string | undefined, handler: SettingsChan - `SETTINGS_LOCKED` (`SettingsLockedError`) - `SETTINGS_UNKNOWN_NAMESPACE` (`UnknownNamespaceError`) - `SETTINGS_UNKNOWN_KEY` (`UnknownKeyError`) -- `SETTINGS_VALIDATION` (`SettingsValidationError`) — thrown by `set` / `setMany` when a write would leave a visible `required` specifier empty or violate its `pattern` +- `SETTINGS_VALIDATION` (`SettingsValidationError`) — thrown by `set` / `setMany` when a write would leave a visible `required` specifier empty or violate its `pattern`. Its `fields` is a `FieldError[]` (ADR-0114), one entry per offending key, with `code` naming the constraint: `required` or `invalid_format`. + +Over REST, each of these puts its context in the declared `error.details` slot — `{ namespace, key, reason, fields }` as the branch carries them — never as siblings of `error.code` / `error.message` (#4224). ## Notes