diff --git a/.changeset/16245-bracketed-tag-refusal-openers.md b/.changeset/16245-bracketed-tag-refusal-openers.md new file mode 100644 index 00000000000..f8075f0ef7c --- /dev/null +++ b/.changeset/16245-bracketed-tag-refusal-openers.md @@ -0,0 +1,29 @@ +--- +'@objectstack/metadata-protocol': minor +'@objectstack/spec': minor +--- + +`ObjectStackProtocolImplementation` and `SysMetadataRepository` no longer open their refusal messages with a bracketed tag restating the `code` the same throw declares — `error` carries the human sentence, `code` carries the machine token, and the token is no longer duplicated onto the prose axis. + +Clause-②: yes + +Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` raised opened with a lowercase `[tag]` that was the restatement of the `code` that very throw declared: `[no_draft]` in front of `NO_DRAFT`, `[item_locked]` in front of `ITEM_LOCKED`, and so on for 38 throw sites across the two producers. They were not invisible. `withoutDeclaredCodePrefix` strips a leading restatement only when the message opens with the declared code followed by a colon (`INVALID_REQUEST: …`); the bracketed lowercase spelling matches neither the casing nor the separator, so it was never stripped and reached the caller in `error.message`. The repo's own de-duplication mechanism existed and did not fire here. + +The maintainer ruling of 2026-08-29 on the `/data` door shipping `FORBIDDEN:` in front of a localized refusal is ONE envelope semantics — `error` is HUMAN LANGUAGE, `code` is the MACHINE TOKEN — and a prefix is removed *because* the same fact already rides the `code` axis. All 38 met that condition by construction. + +## FROM → TO + +| before | now | +| --- | --- | +| `error: "[no_draft] No pending draft exists for view/task_list."` | `error: "No pending draft exists for view/task_list."` | +| `error: "[item_locked] view/task_list is locked (_lock=…)."` | `error: "view/task_list is locked (_lock=…)."` | +| `error: "[NOT_OVERRIDABLE] 'action' is not allowOrgOverride…"` | `error: "'action' is not allowOrgOverride…"` | + +**`code` is unchanged on every one of them**, and it is where the token always also was — `NO_DRAFT`, `ITEM_LOCKED`, `NOT_OVERRIDABLE`, and the 14 others. A reader matching `error.message` for a bracketed tag reads `error.code` for that tag, upper-cased, instead; a reader already using `code` needs no change. The HTTP `status` is untouched. + +- **Measured, not assumed, before it was removed**: 37 literal openers plus one written as `` `[${code}]` `` from the same variable the throw assigns to `err.code` three lines down — that one spelled by interpolation, so it was invisible to every grep for a literal tag and is absent from the card's own inventory. +- **Nothing consumed the tag.** The only consumers found anywhere are strippers: `@object-ui/react`'s `extractWriteErrorMessage` and two `plugin-detail` call sites each remove a leading bracketed prefix before showing the sentence to a user, next to the `SCREAMING_SNAKE:` strip. They confirm the tag was arriving and they cannot break on its absence — the regex simply matches nothing. +- **Two bracketed vocabularies are deliberately kept**: the `path [zod code]` locators inside a validation headline and the `[rule]` locators the author-time gate composes. Neither restates a declared `code` — they name WHICH finding, a fact the envelope carries nowhere else. +- **The published docs that quoted the openers are corrected in the same change.** `ProtocolSchema`'s promotion `describe()` said the lookup 「answers 404 `[no_draft]`」 and now names `NO_DRAFT`, the axis that still carries it; `content/docs/references/api/protocol.mdx` is regenerated from it, never hand-edited. The error catalog's two documented `INVALID_REQUEST` payloads showed a `message` opening with the tag beside a `code` field already carrying the token, and now show what the platform emits. +- ⛔ **Three carriers in `content/docs/releases/v17/` are deliberately left**: release pages record what shipped and a code change does not rewrite them. +- **Pinned as an absence**, because nothing else would notice one coming back: a re-introduced tag reds exactly one per-door pin and a newly-written refusal reds none. diff --git a/content/docs/api/error-catalog.mdx b/content/docs/api/error-catalog.mdx index 479c2b4116c..e7fdf496d6a 100644 --- a/content/docs/api/error-catalog.mdx +++ b/content/docs/api/error-catalog.mdx @@ -606,7 +606,7 @@ unchanged returns the same `400`. "success": false, "error": { "code": "INVALID_REQUEST", - "message": "[invalid_request] 'viewes' is not a recognised spelling of metadata type 'view'. Address it as 'view' or 'views'. Refused rather than treated as a plugin-registered type, because forwarding an unrecognised spelling of a declared type would create a second namespace under type='viewes'.", + "message": "'viewes' is not a recognised spelling of metadata type 'view'. Address it as 'view' or 'views'. Refused rather than treated as a plugin-registered type, because forwarding an unrecognised spelling of a declared type would create a second namespace under type='viewes'.", "httpStatus": 400 } } @@ -657,7 +657,7 @@ the type segment, and the exemption below decides whether it fires at all. "success": false, "error": { "code": "INVALID_REQUEST", - "message": "[invalid_request] 'fieldz' is not a metadata type. The platform declares no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one either — so this write would mint a sys_metadata namespace under type='fieldz' that nothing reads and nothing serves. Address a real metadata type; GET /api/v1/meta/types lists the ones this deployment carries.", + "message": "'fieldz' is not a metadata type. The platform declares no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one either — so this write would mint a sys_metadata namespace under type='fieldz' that nothing reads and nothing serves. Address a real metadata type; GET /api/v1/meta/types lists the ones this deployment carries.", "httpStatus": 400 } } diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index ef17f9aa1a1..0acb9c6cd17 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -2274,7 +2274,7 @@ Installed package with runtime lifecycle state | :--- | :--- | :--- | :--- | | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name — lowercase snake_case segments, optionally dot-qualified (`crm_lead`, `crm_lead.pipeline`). The promotion door enforces the same grammar as `saveMetaItem`. | -| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `[no_draft]`. Absent = environment-wide. | +| **organizationId** | `string` | optional | Organization (tenant) scope for the promotion. The implementation resolves the draft through the org partition (ADR-0005), so a draft authored org-scoped must be published under the same scope or the lookup answers 404 `NO_DRAFT`. Absent = environment-wide. | | **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. | | **message** | `string` | optional | Optional human-readable note recorded with the publish history event. | | **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. | diff --git a/packages/cli/src/commands/meta/delete-reset-carriers.test.ts b/packages/cli/src/commands/meta/delete-reset-carriers.test.ts index 8d1ca417520..42b68370b00 100644 --- a/packages/cli/src/commands/meta/delete-reset-carriers.test.ts +++ b/packages/cli/src/commands/meta/delete-reset-carriers.test.ts @@ -468,7 +468,13 @@ describe('[#13024] `os meta delete --if-match` against the real reset door', () // envelope, never merely "something went wrong" — a bare failure assertion // stays green against a command that never sent the header at all. expect(run.exitCode).toBe(1); - expect(run.out).toContain('metadata_conflict'); + // stdout is the HUMAN surface and carries prose: `printError` prints the + // message and nothing else, and the machine token rides `--format json` + // via `errorCodeFields` (the case below). So the operator-facing assertion + // is the sentence that names the conflict — not a token that never + // belonged on this surface. The structural controls below are what keep + // this from degrading into "something went wrong". + expect(run.out).toContain('has been modified since you loaded it'); expect(run.out).toContain('view/race_probe'); // THE point of the pin: the other author's row is still there. @@ -508,7 +514,14 @@ describe('[#13024] `os meta delete --if-match` against the real reset door', () expect(run.exitCode).toBe(1); const payload = JSON.parse(run.out); expect(payload.success).toBe(false); - expect(String(payload.error)).toContain('metadata_conflict'); + // `--format json` IS the machine-readable path, and the envelope carries the + // token on its own axis — measured here, not assumed: + // { success: false, error: '…has been modified…', code: 'METADATA_CONFLICT', httpStatus: 409 } + // so the assertion reads `code`, which a prose match over `error` only ever + // approximated. `error` keeps the human sentence. + expect(payload.code).toBe('METADATA_CONFLICT'); + expect(payload.httpStatus).toBe(409); + expect(String(payload.error)).toContain('has been modified since you loaded it'); expect(await overlayRows(engine, 'json_probe')).toHaveLength(1); }, 60_000); }); diff --git a/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts b/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts index 0fb2299dea2..5020cbefdf4 100644 --- a/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts +++ b/packages/metadata-protocol/src/protocol.batch-verb-driver-code.test.ts @@ -333,7 +333,7 @@ describe('[#8441] [GUARD] a catalogued code reaches the caller unchanged — red const r = await protocol.publishPackageDrafts({ packageId: PKG }); expect(byName(r.failed, 'acct_api').code).toBe('NOT_OVERRIDABLE'); - expect(byName(r.failed, 'acct_api').error).toContain('[not_overridable]'); + expect(byName(r.failed, 'acct_api').error).toContain("Metadata type 'api' is not draftable"); // The sibling declared code on the SAME array — the reason the issue // says this limb must be filtered rather than deleted. expect(byName(r.failed, 'acct_view').code).toBe('BATCH_ABORTED'); @@ -348,7 +348,7 @@ describe('[#8441] [GUARD] a catalogued code reaches the caller unchanged — red const r = await protocol.revertCommit({ commitId: 'c1' }); expect(r.failed[0].code).toBe('VERSION_NOT_FOUND'); - expect(r.failed[0].error).toContain('[version_not_found]'); + expect(r.failed[0].error).toContain('No history row at version 99'); }); /** @@ -523,7 +523,7 @@ describe('[#8441] what replaces an uncatalogued code, and what stays absent', () // disagree only about the vocabulary. That is the honest hard case: the // substitution has to preserve the meaning, not merely erase the code. const refusalWithDialect = () => Object.assign( - new Error('[item_locked] Cannot overlay this item: the package is read-only.'), + new Error('Cannot overlay this item: the package is read-only.'), { code: '42501', status: 403 }, ); const { protocol } = makeKernel({ @@ -536,7 +536,7 @@ describe('[#8441] what replaces an uncatalogued code, and what stays absent', () const r = await protocol.publishPackageDrafts({ packageId: PKG }); // #8333's rule: DECLARED 4xx, so the authored sentence survives whole. - expect(r.failed[0].error).toContain('[item_locked]'); + expect(r.failed[0].error).toContain('the package is read-only'); expect(r.failed[0].error).toContain('the package is read-only.'); // #8441's rule: not a catalog member, so the status's standard code. expect(r.failed[0].code).toBe('PERMISSION_DENIED'); diff --git a/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts b/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts index fea7800e1f9..d23ec0b7b38 100644 --- a/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts +++ b/packages/metadata-protocol/src/protocol.batch-verb-driver-text.test.ts @@ -36,8 +36,8 @@ * below is that reproduction, kept as the pin). * - **Every authored refusal reaching these catches already declares 4xx** — * `NOT_OVERRIDABLE` 403, `INVALID_METADATA` 422, `METADATA_CONFLICT` 409, - * and the repository's `[version_not_found]` 404 / `[item_locked]` 403 / - * `[writable_package_required]` 422 — with ONE exception, P9's, handled at + * and the repository's `VERSION_NOT_FOUND` 404 / `ITEM_LOCKED` 403 / + * `WRITABLE_PACKAGE_REQUIRED` 422 — with ONE exception, P9's, handled at * its producer (section 4). * - **P8's authored population never enters its catch at all.** The real * materializer (plugin-security) reports a refusal by RETURNING @@ -430,7 +430,7 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in const r = await protocol.publishPackageDrafts({ packageId: PKG }); - expect(r.failed[0].error).toContain('[not_overridable]'); + expect(r.failed[0].error).toContain("Metadata type 'api' is not draftable"); expect(r.failed[0].error).toContain('is not draftable'); expect(r.failed[0].code).toBe('NOT_OVERRIDABLE'); }); @@ -489,12 +489,12 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in // The whole #4277 self-correcting sentence, not just the code: it names // the offending key AND how to spell it correctly. - expect(r.failed[0].error).toContain('[invalid_metadata]'); + expect(r.failed[0].error).toContain('failed spec validation'); expect(r.failed[0].error).toContain('Unrecognized key(s) on this view container'); expect(r.failed[0].error).toContain('defineView('); }); - it('P11 keeps the repository’s `[version_not_found]`, with its `code`', async () => { + it('P11 keeps the repository’s `VERSION_NOT_FOUND` sentence, with its `code`', async () => { const { protocol, engine } = makeKernel({ seed: [row({ type: 'view', name: 'acct_view' })], }); @@ -504,12 +504,12 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in const r = await protocol.revertCommit({ commitId: 'c1' }); - expect(r.failed[0].error).toContain('[version_not_found]'); + expect(r.failed[0].error).toContain('No history row at version 99'); expect(r.failed[0].error).toContain('version 99'); expect(r.failed[0].code).toBe('VERSION_NOT_FOUND'); }); - it('P12 keeps `[commit_not_found]`', async () => { + it('P12 keeps `COMMIT_NOT_FOUND`’s sentence', async () => { const { protocol, engine } = makeKernel(); let seen = 0; engine.findOne = async (t: string) => { @@ -523,11 +523,11 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in const r = await protocol.rollbackToPackageCommit({ commitId: 'c1' }); - expect(r.failed[0].error).toContain('[commit_not_found]'); + expect(r.failed[0].error).toContain("No commit 'c1'."); expect(r.failed[0].error).toContain("No commit 'c1'"); }); - it('P13 keeps `[item_locked]`’s remedy', async () => { + it('P13 keeps `ITEM_LOCKED`’s remedy', async () => { const { protocol } = makeKernel({ seed: [row({ type: 'page', name: 'crm_landing', @@ -536,7 +536,7 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in }); protocol.saveMetaItem = async () => { throw declaredRefusal( - "[item_locked] Cannot overlay 'page' in package 'showcase': that package is read-only. " + "Cannot overlay 'page' in package 'showcase': that package is read-only. " + 'Edit the source artifact and redeploy.', 'ITEM_LOCKED', 403, ); @@ -544,7 +544,7 @@ describe('[#8333] [GUARD] a declared 4xx refusal is quoted verbatim — green in const r = await protocol.migrateStoredMetadata({ apply: true }); - expect(r.rows[0].reason).toContain('[item_locked]'); + expect(r.rows[0].reason).toContain('that package is read-only'); expect(r.rows[0].reason).toContain('Edit the source artifact and redeploy.'); }); }); @@ -633,7 +633,7 @@ describe('[#8333] the seed request’s schema rejection DECLARES itself, so the ); expect(r.success).toBe(false); - expect(r.error).toContain('[invalid_metadata]'); + expect(r.error).toContain('failed spec validation'); expect(r.error).toContain('failed spec validation'); // The dotted path an author can act on. The pre-#8333 dump spelled it // as a raw JSON array (`"path": [ "seeds", 0, "mode" ]`) inside a diff --git a/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts new file mode 100644 index 00000000000..20522271f56 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.bracketed-refusal-opener-absence.test.ts @@ -0,0 +1,171 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * THE PIN: no refusal this package raises opens with a bracketed lowercase tag. + * + * Every refusal `ObjectStackProtocolImplementation` and `SysMetadataRepository` + * raised used to open with a `[lower_snake]` tag that was the restatement of the + * `code` the very same throw declared — `[no_draft]` in front of `NO_DRAFT`, + * `[item_locked]` in front of `ITEM_LOCKED`, and so on for the whole family. + * + * They were not invisible. `withoutDeclaredCodePrefix` + * (`packages/rest/src/error-response.ts`) strips a leading restatement only when + * the message opens with the producer's declared code followed by a colon + * (`INVALID_REQUEST: …`). The bracketed lowercase spelling matches neither the + * casing nor the separator, so it was never stripped and reached the caller in + * `error.message` — the repo's own de-duplication mechanism existed and did not + * fire here. + * + * The maintainer ruling of 2026-08-29 on the `/data` door shipping `FORBIDDEN:` + * in front of a localized refusal is ONE envelope semantics: `error` is HUMAN + * LANGUAGE, `code` is the MACHINE TOKEN, and a prefix is removed *because* the + * same fact already rides the `code` axis. Each of these tags met that condition + * by construction, so all of them are gone. + * + * ## Why this pin is written as an ABSENCE, over SOURCE + * + * Nothing else notices the tag coming back. The per-refusal pins elsewhere in + * this package assert the prose a given door answers, so one re-introduced tag + * reds exactly one of them and a newly-written refusal reds none — and a new + * refusal copied from a neighbouring producer is precisely how the idiom spread + * in the first place. Reading the source covers every throw site in both files, + * including ones no test can provoke. + * + * ⚠️ A scan that matches nothing passes for free, so the family floor below is + * part of the pin: the scanner must still be finding refusals to have an opinion + * about. Without it, moving every `throw` out of these files would green this + * file rather than red it. + * + * ⛔ Two bracketed vocabularies in this package are NOT this family and are + * deliberately untouched, because neither restates a declared `code`: + * `path [zod code]` locators inside `metadataIssueHeadline`'s issue list, and + * the `[rule]` locators the author-time gate composes. They name WHICH finding, + * which is a fact the envelope carries nowhere else. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** The two producers the ruling was applied to. */ +const PRODUCERS = ['protocol.ts', 'sys-metadata-repository.ts'] as const; + +/** + * A string literal whose FIRST characters are a bracketed tag — the shape + * `withoutDeclaredCodePrefix` cannot strip, in all three spellings it was + * found in. + * + * ⚠️ The INTERPOLATED arm is not thoroughness for its own sake. One site wrote + * its opener as `[${code}]` from the same variable it assigned to `err.code` a + * few lines down — the most redundant member of the whole family, and the one + * every grep for a literal tag walked straight past. A detector that reads only + * literals would let exactly that shape back in. + */ +const TAGGED_OPENER = /(`|')\[(?:([A-Za-z][A-Za-z0-9_]*)\]|\$\{)/; + +/** + * The bracketed openers that are NOT this family, by name. + * + * Both are LOGGER subsystem prefixes on log lines — they say which component is + * speaking into a shared stream, a fact no envelope carries because a log line + * has no envelope. Neither restates a declared `code`, and neither is addressed + * to a caller. + * + * ⚠️ Declared by name rather than by a "looks like a log" heuristic: a new + * vocabulary should red this pin and be argued, which is the ratchet. Adding a + * refusal tag here instead of removing it is ⛔ the one edit this list must + * never absorb. + */ +const NON_REFUSAL_PREFIXES = new Set(['Protocol', 'SysMetadataRepository']); + +function scan(file: string): { openers: string[]; refusals: number } { + const lines = readFileSync(join(HERE, file), 'utf8').split('\n'); + const openers: string[] = []; + let refusals = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + const trimmed = line.trim(); + refusals += line.split('new Error(').length - 1; + // Prose ABOUT a refusal is not a refusal: comments may name a code freely. + if (trimmed.startsWith('*') || trimmed.startsWith('//') || trimmed.startsWith('/*')) continue; + const m = TAGGED_OPENER.exec(line); + // The bracket must open the literal, not merely appear inside it. + if (!m || line[m.index + 1] !== '[') continue; + if (m[2] !== undefined && NON_REFUSAL_PREFIXES.has(m[2])) continue; + openers.push(`${file}:${i + 1} ${trimmed.slice(0, 100)}`); + } + return { openers, refusals }; +} + +describe('refusal messages open with prose, never with a bracketed restatement of their own code', () => { + it.each(PRODUCERS)('%s raises no message opening with a bracketed lowercase tag', (file) => { + const { openers, refusals } = scan(file); + + // THE FLOOR — the scan has to still be looking at refusals for its silence + // to mean anything. A `new Error(` count, not a total of matched openers: + // the quantity this pin is about is zero, so it can never be its own floor. + expect( + refusals, + `${file} no longer constructs errors here — this pin is scanning the wrong file`, + ).toBeGreaterThanOrEqual(5); + + expect( + openers, + `these refusals open with a tag restating their own declared code:\n${openers.join('\n')}`, + ).toEqual([]); + }); + + it('the whole family is covered — both producers together still raise the refusals this pin is about', () => { + const total = PRODUCERS.reduce((n, f) => n + scan(f).refusals, 0); + expect(total).toBeGreaterThanOrEqual(30); + }); +}); + +describe('the refusal a caller actually receives', () => { + /** The envelope guards below refuse before any engine call, so none is needed. */ + function protocol() { + return new ObjectStackProtocolImplementation({ + registry: { getObject: () => undefined }, + findOne: vi.fn(async () => null), + } as any); + } + + async function refusalFrom(run: () => Promise): Promise { + try { + await run(); + } catch (e) { + return e; + } + throw new Error('expected a refusal, got a resolved call'); + } + + it('carries the token on `code` and opens with the sentence — `INVALID_REQUEST`', async () => { + const err = await refusalFrom(() => (protocol() as any).saveMetaItem({ type: 'view', name: 'task_list' })); + + // The machine axis is unchanged: this change moved nothing off it. + expect(err.code).toBe('INVALID_REQUEST'); + expect(err.status).toBe(400); + + // …and the human axis opens with the human sentence. Asserted as an absence + // AND as the prose that opens instead, so the pin cannot go green by the + // message becoming empty or generic. + expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false); + expect(err.message).not.toContain('[invalid_request]'); + expect(err.message).toContain("saveMetaItem requires an 'item' body for 'view/task_list'"); + }); + + it('carries the token on `code` and opens with the sentence — the rollback envelope guard', async () => { + const err = await refusalFrom( + () => (protocol() as any).rollbackMetaItem({ type: 'view', name: 'task_list', toVersion: 0 }), + ); + + expect(err.code).toBe('INVALID_REQUEST'); + expect(err.status).toBe(400); + expect(err.message.startsWith('['), `message opens with a tag: ${err.message.slice(0, 48)}`).toBe(false); + expect(err.message).toContain("rollbackMetaItem requires a positive integer 'toVersion'"); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts index b514cd9b170..38dfbcee3d3 100644 --- a/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts +++ b/packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts @@ -304,9 +304,13 @@ describe('#7426 — the repository refusal reaches the caller with its code', () expect(err, `${type}: the delete was accepted`).toBeInstanceOf(Error); expect(err.code, type).toBe('NOT_OVERRIDABLE'); expect(err.status, type).toBe(403); - // The prose the operator reads is unchanged — the code is ADDED to - // the envelope, it does not replace the message it was trapped in. - expect(String(err.message), type).toContain('NOT_OVERRIDABLE'); + // The prose the operator reads still names the reason and survives + // the rewrap whole. It no longer RESTATES the code: #7426 promoted + // the token to the envelope, and the bracketed `[NOT_OVERRIDABLE]` + // opener it had been trapped in was the duplicate left behind — + // `error` is human language, `code` is the machine token. + expect(String(err.message), type).toContain('is not allowOrgOverride in the registry'); + expect(String(err.message), type).not.toContain('[NOT_OVERRIDABLE]'); // A refusal that already deleted the row is a log line. expect(rows.size, type).toBe(1); }); diff --git a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts index 0deb85470ef..0eae0342dd1 100644 --- a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts @@ -429,7 +429,7 @@ describe('[#10886] [GUARD] `duplicatePackage`’s `failed[].error` is the SOLE c expect(r.failedCount).toBe(1); expect(r.failed[0]).toMatchObject({ type: 'object', name: 'crm_task' }); - expect(r.failed[0].error).toContain('[destructive_change]'); + expect(r.failed[0].error).toContain('would drop or transform existing data'); }); it('⛔ carries the per-field prose with NO structured channel beside it', async () => { @@ -525,7 +525,7 @@ describe('[#11015] [GUARD] the destructive remedy clause is face-aware', () => { // findings prose stays, because `failed[].error` is its sole carrier on // this face — is untouched, and this is the assertion that says so. expect(r.failed[0].error).toContain(FINDING_PROSE); - expect(r.failed[0].error).toContain('[destructive_change]'); + expect(r.failed[0].error).toContain('would drop or transform existing data'); }); it('the refusal still REFUSES — this is a message repair, not a behaviour one', async () => { @@ -616,7 +616,7 @@ describe('[#11095] [GUARD] the `meta-dispatch` face prescribes a remedy that doo // it was untouched by #11015: only the remedy clause is face-aware, and // the findings the refusal renders stay whole on every face. expect(err.message).toContain(FINDING_PROSE); - expect(err.message).toContain('[destructive_change]'); + expect(err.message).toContain('would drop or transform existing data'); }); it('⛔ the three faces are a SWITCH — repairing one did not move the others', async () => { diff --git a/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts b/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts index c9611ded543..31f0a6e2943 100644 --- a/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts +++ b/packages/metadata-protocol/src/protocol.driver-text-disclosure.test.ts @@ -430,15 +430,15 @@ describe('[#8136] the overlay-delete re-wraps name the operation without quoting describe('[#8136] [GUARD] a declared 4xx refusal is quoted verbatim — green in BOTH directions, red under the over-broad variant', () => { /** * The bound that stops this fix being satisfied by "withhold everything". - * `SysMetadataRepository`'s refusals (`[item_locked]`, - * `[writable_package_required]`, `[no_draft]`, …) name the exact remedy, + * `SysMetadataRepository`'s refusals (`ITEM_LOCKED`, + * `WRITABLE_PACKAGE_REQUIRED`, `NO_DRAFT`, …) name the exact remedy, * and #4277 is the card that exists so they do. Blanking them would trade a * usability regression for no disclosure gain — measured red under the * over-broad variant, see the PR body. */ it('keeps a repository refusal intact through the re-wrap', async () => { const refusal: any = new Error( - "[item_locked] Cannot overlay 'view' in package 'showcase': that package is read-only. " + "Cannot overlay 'view' in package 'showcase': that package is read-only. " + 'Edit the source artifact and redeploy.', ); refusal.code = 'ITEM_LOCKED'; @@ -455,7 +455,7 @@ describe('[#8136] [GUARD] a declared 4xx refusal is quoted verbatim — green in // The prescription survives, whole — this is the half a blanket // sanitizer would destroy. - expect(String(err.message)).toContain('[item_locked]'); + expect(String(err.message)).toContain('that package is read-only'); expect(String(err.message)).toContain('Edit the source artifact and redeploy.'); // …and the envelope #7426 installed is unchanged. expectDeclaredEnvelope(err, 'ITEM_LOCKED', 403); diff --git a/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts b/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts index 3b5cb2b1d84..ef17866398c 100644 --- a/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts +++ b/packages/metadata-protocol/src/protocol.hydrate-overlay-canonical-type.test.ts @@ -257,7 +257,7 @@ describe('[#9111] `hydrateOverlayIntoRegistry` asserts its type is canonical', ( expect(() => protocol.hydrateOverlayIntoRegistry('objects', { name: 'ticket' }, { packageId: null, organizationId: null, - })).toThrowError(/registry_type_not_canonical/); + })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); expect(registeredItems).toHaveLength(0); }); @@ -269,12 +269,12 @@ describe('[#9111] `hydrateOverlayIntoRegistry` asserts its type is canonical', ( expect(() => protocol.hydrateOverlayIntoRegistry('fields', { name: 'x' }, { packageId: null, organizationId: 'org_alpha', - })).toThrowError(/registry_type_not_canonical/); + })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); // …and a body with no `name` at all. expect(() => protocol.hydrateOverlayIntoRegistry('fields', { label: 'no name' }, { packageId: null, organizationId: null, - })).toThrowError(/registry_type_not_canonical/); + })).toThrowError(/Refusing to register a SchemaRegistry overlay entry/); }); it('cannot refuse a canonical type or a plugin-registered kind', () => { diff --git a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts index e61cb4b9a70..d6111c1fba7 100644 --- a/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts @@ -217,7 +217,7 @@ describe('[#10888] `meta-envelope` renders the headline, not the prose', () => { const { protocol } = makeProtocol(); const err = await refusal(protocol, 'meta-envelope'); - expect(err.message).toContain('[invalid_metadata] view/task_list failed spec validation: '); + expect(err.message).toContain('view/task_list failed spec validation: '); expect(err.message).toContain(`${err.issues.length} issue`); // The same grammar `seedRequestValidationError` composes — count plus // `path [zod code]` locators — so one mistake reads the same whichever @@ -279,7 +279,7 @@ describe('[#10888 · GUARD] a face that carries no `issues[]` keeps the whole se + (err.issues.length > 3 ? ` (+${err.issues.length - 3} more)` : ''); expect(err.message).toBe( - `[invalid_metadata] view/task_list failed spec validation: ${expected}`, + `view/task_list failed spec validation: ${expected}`, ); // The #4001 self-correcting prescription, whole. expect(err.message).toContain(PRESCRIPTION); diff --git a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts index 8a0447579b2..77953af831b 100644 --- a/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts +++ b/packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts @@ -397,23 +397,27 @@ describe('#6960 — the boundary holds: the `object` tier does NOT move', () => * substitute one: the prose is what an operator reads, and #7426 adds a * field to the envelope without restating the sentence. * - * ⚠️ …and it is deliberately CASE-INSENSITIVE, which is a measurement, not - * a convenience. Promoting the old control-plane-only substring check to - * every leg turned the project-kernel legs red: the two producers spell the - * marker inside their *prose* differently — `deleteMetaItem`'s own block - * writes `[not_overridable]`, `SysMetadataRepository` writes - * `[NOT_OVERRIDABLE]`. Only the `code` FIELD is uniform, which is exactly - * ADR-0112's point (the catalog governs `error.code`; message prose is a - * different surface) and exactly why the field is the assertion that - * belongs here. Recorded rather than papered over — the prose divergence is - * pre-existing and outside #7426's scope. + * ⚠️ …and the marker is read off the `code` FIELD, never off the prose. + * That was a measurement before it was a rule: promoting the old + * control-plane-only substring check to every leg turned the project-kernel + * legs red, because the two producers spelled the marker inside their prose + * differently — one lowercase, one uppercased out of the declared code by + * interpolation. Only the `code` field was ever uniform, which is exactly + * ADR-0112's point: the catalog governs `error.code`, and message prose is + * a different surface. Both prose markers are now gone — `error` is human + * language and `code` is the machine token — so the field is not merely the + * assertion that belongs here, it is the only one there could be. */ const expectRefused = (err: any, environmentId: string | undefined, ctx: string) => { void environmentId; expect(err, `${ctx}: the delete was accepted`).toBeInstanceOf(Error); expect(err.status, ctx).toBe(403); expect(err.code, ctx).toBe('NOT_OVERRIDABLE'); - expect(String(err.message).toUpperCase(), ctx).toContain('NOT_OVERRIDABLE'); + // The prose still has to SAY something — the envelope assertion above + // is about the machine axis, and a refusal that lost its sentence would + // pass it while telling the operator nothing. + expect(String(err.message), ctx).toMatch(/code package|allowOrgOverride/); + expect(String(err.message).startsWith('['), ctx).toBe(false); }; for (const { label, environmentId } of KERNELS) { diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 1c9f1d16e0b..015c05c00dd 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -410,7 +410,7 @@ describe('#6190 — org-scoped writes of non-org-overridable types are refused', .catch((e: any) => e); expect(err.message).toContain( - "[not_overridable] Metadata item 'object/org_widget' cannot be written org-scoped (organization 'org_a').", + "Metadata item 'object/org_widget' cannot be written org-scoped (organization 'org_a').", ); expect(err.message).toContain('allowOrgOverride=false'); expect(err.message).toContain('Save it env-wide instead'); diff --git a/packages/metadata-protocol/src/protocol.stored-migration.test.ts b/packages/metadata-protocol/src/protocol.stored-migration.test.ts index 8919ac3f1d6..440da2ed1e6 100644 --- a/packages/metadata-protocol/src/protocol.stored-migration.test.ts +++ b/packages/metadata-protocol/src/protocol.stored-migration.test.ts @@ -568,7 +568,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', expect(report.failed).toBe(1); expect(report.rewritten).toBe(0); - expect(report.rows[0]!.reason).toMatch(/invalid_metadata/); + expect(report.rows[0]!.reason).toMatch(/failed spec validation/); expect(historyRows(tables)).toHaveLength(0); expect(JSON.parse(metaRows(tables)[0]!.metadata).fields.amount.conditionalRequired).toBe('x'); }); @@ -594,7 +594,7 @@ describe('migrateStoredMetadata — what it declines to touch, loudly (#4327)', const report = await protocol.migrateStoredMetadata({ apply: true }); expect(report.failed).toBe(1); - expect(report.rows[0]!.reason).toMatch(/metadata_conflict/); + expect(report.rows[0]!.reason).toMatch(/has been modified since you loaded it/); // The other writer's row is untouched. expect(metaRows(tables)[0]!.checksum).toBe('sha256:moved_by_someone_else'); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2204cd26953..9fb0b9d541b 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -289,7 +289,7 @@ function canonicalizeMetaRequestType(request: T): T const refusal = metaUrlSpellingRefusal(request.type); if (refusal) { const err = new Error( - `[invalid_request] '${request.type}' is not a recognised spelling of metadata type ` + `'${request.type}' is not a recognised spelling of metadata type ` + `'${refusal.declared}'. Address it as '${refusal.declared}' or '${refusal.hint}'. ` + `Refused rather than treated as a plugin-registered type, because forwarding an unrecognised ` + `spelling of a declared type would create a second namespace under type='${request.type}'.`, @@ -2304,8 +2304,8 @@ function carryCatalogedErrorCode(target: Error, source: unknown): void { * met. It is the inverse and bounded question: **did we author this sentence * for a caller?** A producer that declared 4xx has said the failure is the * caller's to fix and has written the remedy into the message — the - * self-correcting refusals `SysMetadataRepository` raises (`[item_locked]`, - * `[writable_package_required]`, `[no_draft]`, …) are exactly that, and they + * self-correcting refusals `SysMetadataRepository` raises (`ITEM_LOCKED`, + * `WRITABLE_PACKAGE_REQUIRED`, `NO_DRAFT`, …) are exactly that, and they * must survive intact. Everything else is withheld by DEFAULT, so a dialect * this repo has never run is handled correctly without anyone having enumerated * it. @@ -2639,7 +2639,7 @@ export function seedRequestValidationError(zodIssues: unknown): Error { // refusal thread it onto `seedApplied.issues` beside the headline, so // the author's curated per-key prose still arrives exactly once. const err = new Error( - `[invalid_metadata] the published seed bodies failed spec validation: ` + `The published seed bodies failed spec validation: ` + metadataIssueHeadline(issues), ); (err as any).code = 'INVALID_METADATA'; @@ -5873,7 +5873,7 @@ export class ObjectStackProtocolImplementation implements * `translation`, `email_template`): a draft authored env-wide — which is * what package/AI authoring writes, and what `PUT ?mode=draft` writes when no * active org is threaded — is looked up under `organization_id = `, - * matches nothing, and answers `404 [no_draft] … nothing to publish` over a + * matches nothing, and answers `404 NO_DRAFT` — `… nothing to publish` — over a * row the console's own pending-changes list is showing. Measured on a cloud * rig: four AI-authored `view` drafts, visible in `sys_metadata` at * `state='draft'`, all four refused by the per-item door while the batch @@ -5903,7 +5903,7 @@ export class ObjectStackProtocolImplementation implements * a package-stating publish resolves the scope of the draft it NAMED. * Without the dimension, probe 1 could match ANOTHER package's row in the * caller's org, name a scope the package-exact promote then finds empty, - * and answer `404 [no_draft]` over a publishable draft sitting env-wide. + * and answer `404 NO_DRAFT` over a publishable draft sitting env-wide. * Accepted cost, on the record: a caller stating a package no longer * discovers a no-package draft of the same `(type, name)` — it 404s and the * caller retries without `?package=`; that narrowing is the ruling, not a @@ -8201,7 +8201,7 @@ export class ObjectStackProtocolImplementation implements if (readState === 'draft') { if (item === undefined) { const err: any = new Error( - `[no_draft] No pending draft exists for ${request.type}/${request.name}.`, + `No pending draft exists for ${request.type}/${request.name}.`, ); err.code = 'NO_DRAFT'; err.status = 404; @@ -13569,7 +13569,7 @@ export class ObjectStackProtocolImplementation implements */ private static codeOnlyCreateError(type: string): Error { const err = new Error( - `[not_creatable] Metadata type '${type}' is code-only: the metadata-type registry declares ` + `Metadata type '${type}' is code-only: the metadata-type registry declares ` + `allowRuntimeCreate=false and allowOrgOverride=false, so it cannot be created through the ` + `runtime metadata API (PUT /api/v1/meta/${type}/:name) on any kernel.` + ObjectStackProtocolImplementation.codeOnlySourceHint(type) @@ -13588,7 +13588,7 @@ export class ObjectStackProtocolImplementation implements */ private static codeOnlyOverrideError(type: string, name: string): Error { const err = new Error( - `[not_overridable] Metadata item '${type}/${name}' is provided by a code package and its type is ` + `Metadata item '${type}/${name}' is provided by a code package and its type is ` + `code-only (allowRuntimeCreate=false, allowOrgOverride=false), so it cannot be overlaid through ` + `the runtime metadata API on any kernel.` + ObjectStackProtocolImplementation.codeOnlySourceHint(type) @@ -13684,7 +13684,7 @@ export class ObjectStackProtocolImplementation implements if (this.isOverlayAllowed(type)) return null; if (!this.STATIC_REGISTRY_TYPES.has(singular) && !this.STATIC_REGISTRY_TYPES.has(type)) return null; const err: any = new Error( - `[not_overridable] Metadata item '${type}/${name}' cannot be written org-scoped ` + `Metadata item '${type}/${name}' cannot be written org-scoped ` + `(organization '${organizationId}'). ` + `The metadata-type registry declares allowOrgOverride=false for '${singular}', so the platform has ` + `no per-org channel for it: boot hydration loads env-wide rows only, so this row would be absent ` @@ -14071,7 +14071,7 @@ export class ObjectStackProtocolImplementation implements const canonical = canonicalMetaType(entry.type); if (canonical !== entry.type) { const err: any = new Error( - `[audit_type_not_canonical] Refusing to write a sys_metadata_audit row under the ` + `Refusing to write a sys_metadata_audit row under the ` + `non-canonical metadata type '${entry.type}' (canonical: '${canonical}') for ` + `'${entry.name}'. ADR-0010's trail is keyed on (type, name) and read back through ` + `the '/meta' boundary, which folds — a row filed under '${entry.type}' is a row no ` @@ -14138,7 +14138,7 @@ export class ObjectStackProtocolImplementation implements if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; const err = new Error( - `[item_locked] ${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ''}). ` + `${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ''}). ` + `${reason} — See ADR-0010 §3.3.`, ); (err as any).code = 'ITEM_LOCKED'; @@ -14206,7 +14206,7 @@ export class ObjectStackProtocolImplementation implements if (!refusal) return null; const reason = state.lockReason ?? refusal.reason; const err = new Error( - `[item_locked] ${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ''}). ` + `${args.type}/${args.name} is locked (_lock=${state.lock}${state.lockSource ? `, source=${state.lockSource}` : ''}). ` + `${reason} — See ADR-0010 §3.3.`, ); (err as any).code = 'ITEM_LOCKED'; @@ -14499,7 +14499,7 @@ export class ObjectStackProtocolImplementation implements name: string, rowPackageId: string, ownerPackageId: string, ): Error { const err: any = new Error( - `[object_overlay_package_mismatch] Cannot layer object '${name}': the overlay is bound to package ` + `Cannot layer object '${name}': the overlay is bound to package ` + `'${rowPackageId}', but the object is owned by package '${ownerPackageId}'. ` + `An object has exactly one registry entry, so it can carry exactly one overlay layer — bind the ` + `customization to '${ownerPackageId}', or have '${rowPackageId}' extend the object instead. ` @@ -14657,7 +14657,7 @@ export class ObjectStackProtocolImplementation implements const canonicalType = canonicalMetaType(type); if (canonicalType !== type) { const err: any = new Error( - `[registry_type_not_canonical] Refusing to register a SchemaRegistry overlay entry under ` + `Refusing to register a SchemaRegistry overlay entry under ` + `the non-canonical metadata type '${type}' (canonical: '${canonicalType}'). The registry ` + `holds exactly one plain key per (type, name) and every reader addresses it through the ` + `'/meta' boundary, which folds — an entry minted under '${type}' is a second namespace no ` @@ -15346,7 +15346,7 @@ export class ObjectStackProtocolImplementation implements private refuseUngrammaticalMetaItemName(request: { type: string, name: string }): void { if (METADATA_ITEM_NAME_PATTERN.test(request.name)) return; const err = new Error( - `[invalid_request] ${JSON.stringify(request.name)} is not a legal metadata item name. ` + `${JSON.stringify(request.name)} is not a legal metadata item name. ` + `Item names are lowercase snake_case segments, optionally dot-qualified — ` + `/^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/ — e.g. 'crm_lead' or 'crm_lead.pipeline'. ` + `No slashes, spaces, uppercase, empty segments, or leading/trailing dots. ` @@ -15435,7 +15435,7 @@ export class ObjectStackProtocolImplementation implements // Exemption 2 — the namespace predates this write. if (await this.metaTypeNamespaceExists(unrecognised.type)) return; const err = new Error( - `[invalid_request] '${unrecognised.type}' is not a metadata type. The platform declares ` + `'${unrecognised.type}' is not a metadata type. The platform declares ` + `no such type, and since #8586 retired 'additionalTypes' a plugin cannot declare one ` + `either — so this write would mint a sys_metadata namespace under ` + `type='${unrecognised.type}' that nothing reads and nothing serves. Address a real ` @@ -15508,10 +15508,10 @@ export class ObjectStackProtocolImplementation implements // failure and break that convention. The structural twin is // {@link rollbackMetaItem}'s own opening guard — same class, same // position, a malformed REQUEST ENVELOPE rather than an off-spec - // document — which is `[invalid_request]`/400. + // document — which is `INVALID_REQUEST`/400. if (!request.item) { const err: any = new Error( - `[invalid_request] saveMetaItem requires an 'item' body for '${request.type}/${request.name}'. ` + `saveMetaItem requires an 'item' body for '${request.type}/${request.name}'. ` + `Send the metadata document as the request body, or wrap it as {"item": {...}} / {"metadata": {...}}. ` + `An explicitly null item is refused rather than persisted as an empty document.`, ); @@ -15750,7 +15750,7 @@ export class ObjectStackProtocolImplementation implements ); } const err = new Error( - `[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package ` + `Metadata item '${request.type}/${request.name}' is provided by a code package ` + `and the type has not opted into per-org overlay writes (allowOrgOverride=false). ` + `Edit the source artifact and redeploy, or set OS_METADATA_WRITABLE to grant a runtime escape hatch. ` + `See docs/adr/0005-metadata-customization-overlay.md.` @@ -15905,7 +15905,7 @@ export class ObjectStackProtocolImplementation implements // circle. See {@link destructiveChangeRemedy}. const summary = issues.slice(0, 3).map((i) => i.message).join('; '); const err = new Error( - `[destructive_change] ${request.type}/${request.name} would drop or transform existing data: ${summary}` + `${request.type}/${request.name} would drop or transform existing data: ${summary}` + (issues.length > 3 ? ` (+${issues.length - 3} more)` : '') + ` — ${destructiveChangeRemedy(request.writeFace, request.name)}` ); @@ -15943,7 +15943,7 @@ export class ObjectStackProtocolImplementation implements && 'code' in it && 'overlay' in it && 'overlayScope' in it && 'effective' in it; if (looksLikeLayeredEnvelope) { const err = new Error( - `[invalid_metadata] ${request.type}/${request.name}: the request body is a layered read ` + `${request.type}/${request.name}: the request body is a layered read ` + `envelope ({ code, overlay, overlayScope, effective }), not a metadata body. ` + `Unwrap and send the effective/overlay document instead — the layered shape is read-only ` + `(GET ?layers=true) and must never be persisted.` @@ -16040,7 +16040,7 @@ export class ObjectStackProtocolImplementation implements // resubmitting the same body cannot help. const first = result.conflicts[0]!; const err = new Error( - `[flow_conversion_conflict] ${request.type}/${request.name}: conversion refused — ` + `${request.type}/${request.name}: conversion refused — ` + `'${first.token}' at ${first.path} is a live name in this environment ` + `(${result.conflicts.length} conflict(s)). ${first.message}` ); @@ -16093,7 +16093,7 @@ export class ObjectStackProtocolImplementation implements // with it. `err.issues` below is unconditional — the split // decides only what the SENTENCE repeats. const err = new Error( - `[invalid_metadata] ${request.type}/${request.name} failed spec validation: ` + `${request.type}/${request.name} failed spec validation: ` + specValidationFindings(request.writeFace, issues) ); (err as any).code = 'INVALID_METADATA'; @@ -16504,7 +16504,7 @@ export class ObjectStackProtocolImplementation implements } catch (err: any) { if (err instanceof ConflictError) { const conflict = new Error( - `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. ` + `${request.type}/${request.name} has been modified since you loaded it. ` + `Expected parent ${err.expectedParent ?? 'null'} but current is ${err.actualHead ?? 'null'}.`, ); (conflict as any).code = 'METADATA_CONFLICT'; @@ -16903,7 +16903,7 @@ export class ObjectStackProtocolImplementation implements // this line changed, not assumed: every refusal this `try` can // receive declares 4xx — `saveMetaItem`'s spec rejection // (422 `INVALID_METADATA`), `SysMetadataRepository`'s - // `[item_locked]` / `[writable_package_required]` (403 / 422) — + // `ITEM_LOCKED` / `WRITABLE_PACKAGE_REQUIRED` (403 / 422) — // so each is still quoted verbatim and still names the fix. // // ⚠️ The old fallback was `String(e)`, which is itself a @@ -17004,7 +17004,7 @@ export class ObjectStackProtocolImplementation implements /** * Promote the pending draft overlay to the live (`active`) row. - * Records a history event with `op='publish'`. 404 (`[no_draft]`) + * Records a history event with `op='publish'`. 404 (`NO_DRAFT`) * when there is nothing to publish. */ async publishMetaItem(request: { @@ -17392,7 +17392,7 @@ export class ObjectStackProtocolImplementation implements if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) { const err: any = new Error( - `[not_overridable] Metadata type '${request.type}' is not draftable — no overlay/runtime-create permission.`, + `Metadata type '${request.type}' is not draftable — no overlay/runtime-create permission.`, ); err.code = 'NOT_OVERRIDABLE'; err.status = 403; @@ -17515,7 +17515,7 @@ export class ObjectStackProtocolImplementation implements } catch (err: any) { if (err instanceof ConflictError) { const conflict: any = new Error( - `[metadata_conflict] ${request.type}/${request.name} published row advanced while you held the draft. ` + `${request.type}/${request.name} published row advanced while you held the draft. ` + `Expected parent ${err.expectedParent ?? 'null'} but current is ${err.actualHead ?? 'null'}.`, ); conflict.code = 'METADATA_CONFLICT'; @@ -19046,7 +19046,7 @@ export class ObjectStackProtocolImplementation implements // composition `resolveActiveOrganizationId` makes real. if (request.organizationId && request.allTenants === true) { const err = new Error( - `[tenant_scope_required] Refusing to uninstall '${request.packageId}':` + `Refusing to uninstall '${request.packageId}':` + ` organizationId ('${request.organizationId}') and allTenants: true are mutually exclusive —` + ` one scopes the uninstall to a single tenant, the other clears every tenant's rows.` + ` — pass organizationId alone to scope it, or allTenants: true alone to confirm the cross-tenant uninstall.` @@ -19061,7 +19061,7 @@ export class ObjectStackProtocolImplementation implements // affirmative `true` does. if (!request.organizationId && request.allTenants !== true) { const err = new Error( - `[tenant_scope_required] Refusing to uninstall '${request.packageId}' with no organization scope:` + `Refusing to uninstall '${request.packageId}' with no organization scope:` + ` an uninstall that names neither an organization nor an explicit cross-tenant intent would delete` + ` EVERY organization's rows for this package.` + ` — pass organizationId to scope it, or allTenants: true to confirm the cross-tenant uninstall.` @@ -20233,7 +20233,7 @@ export class ObjectStackProtocolImplementation implements } const row = (await this.engine.findOne('sys_metadata_commit', { where })) as any; if (!row) { - const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`); + const err: any = new Error(`No commit '${request.commitId}'.`); err.code = 'COMMIT_NOT_FOUND'; err.status = 404; throw err; @@ -20284,7 +20284,8 @@ export class ObjectStackProtocolImplementation implements // `reverted[0].action === 'restored'`, `registerItem` called ZERO // times, and the only trace anywhere is // `[Protocol] registry write-through failed for fields/… : - // [registry_type_not_canonical] …` on the server's stderr. The + // Refusing to register a SchemaRegistry overlay entry …` + // (`REGISTRY_TYPE_NOT_CANONICAL`) on the server's stderr. The // receipt claims the pre-commit body is what the platform now // serves. It is not, and for this class it cannot be: #9111's mint // door refuses the entry and boot refuses it too, so the restored @@ -20641,10 +20642,10 @@ export class ObjectStackProtocolImplementation implements // field carried `SQLITE_ERROR: no such table: sys_metadata`. // // Every authored refusal this `try` can receive declares 4xx — - // `SysMetadataRepository`'s `[version_not_found]` 404 (measured - // end to end and still quoted whole), `[no_draft]` 404, - // `[version_not_restorable]` 409, `[item_locked]` 403, - // `[writable_package_required]` 422 — so the self-correcting + // `SysMetadataRepository`'s `VERSION_NOT_FOUND` 404 (measured + // end to end and still quoted whole), `NO_DRAFT` 404, + // `VERSION_NOT_RESTORABLE` 409, `ITEM_LOCKED` 403, + // `WRITABLE_PACKAGE_REQUIRED` 422 — so the self-correcting // sentences survive and only the driver line is withheld. // [#8441] …and the `code` limb beside it, which #8333 left // alone as a different field with a different rule. Measured @@ -20757,7 +20758,7 @@ export class ObjectStackProtocolImplementation implements } const target = (await this.engine.findOne('sys_metadata_commit', { where })) as any; if (!target) { - const err: any = new Error(`[commit_not_found] No commit '${request.commitId}'.`); + const err: any = new Error(`No commit '${request.commitId}'.`); err.code = 'COMMIT_NOT_FOUND'; err.status = 404; throw err; @@ -20801,7 +20802,7 @@ export class ObjectStackProtocolImplementation implements // sys_metadata` reached the field. // // This `try` wraps exactly one call, and `revertCommit` throws - // only refusals it declared — `[commit_not_found]` 404 + // only refusals it declared — `COMMIT_NOT_FOUND` 404 // (measured, still quoted whole) and the lock/authorization // 4xx above it; its PER-ITEM failures never throw at all, they // are collected into its own `failed[]` (P11). @@ -20820,8 +20821,8 @@ export class ObjectStackProtocolImplementation implements /** * Restore the body recorded at history `toVersion` as the new * live row. Writes a history event with `op='revert'`. 404 - * (`[version_not_found]`) when the target version doesn't exist; - * 409 (`[version_not_restorable]`) when the target is a delete + * (`VERSION_NOT_FOUND`) when the target version doesn't exist; + * 409 (`VERSION_NOT_RESTORABLE`) when the target is a delete * tombstone (no body to bring back). */ async rollbackMetaItem(request: { @@ -20840,7 +20841,7 @@ export class ObjectStackProtocolImplementation implements }> { if (!Number.isFinite(request.toVersion) || request.toVersion < 1) { const err: any = new Error( - `[invalid_request] rollbackMetaItem requires a positive integer 'toVersion' (got ${request.toVersion}).`, + `rollbackMetaItem requires a positive integer 'toVersion' (got ${request.toVersion}).`, ); err.code = 'INVALID_REQUEST'; err.status = 400; @@ -20855,7 +20856,7 @@ export class ObjectStackProtocolImplementation implements // the very top: that is the position {@link saveMetaItem} documents for // this exact pair, calling this method's opening guard its structural // twin — a malformed request envelope is refused before its type key is - // canonicalised, and both refusals are `[invalid_request]`/400 anyway. + // canonicalised, and both refusals are `INVALID_REQUEST`/400 anyway. // // What the fold reaches here, measured rather than assumed: // @@ -20873,7 +20874,7 @@ export class ObjectStackProtocolImplementation implements // `external_catalog`, `translation`), which stayed plural through // the manifest map and so took the PERMISSIVE PLUGIN branch — the // #7894 shape, one verb over. - // • the `[not_overridable]` refusal, the two ADR-0010 audit rows and + // • the `NOT_OVERRIDABLE` refusal, the two ADR-0010 audit rows and // both receipt sentences, which read `request.type` and so reported // the CALLER's spelling for a row written under the canonical one. // `recordMetadataAudit` re-folds through `PLURAL_TO_SINGULAR` @@ -20902,7 +20903,7 @@ export class ObjectStackProtocolImplementation implements if (!ObjectStackProtocolImplementation.isOverlayAllowed(singularType) && !ObjectStackProtocolImplementation.isRuntimeCreateAllowed(singularType)) { const err: any = new Error( - `[not_overridable] Metadata type '${request.type}' is not revertable — no overlay/runtime-create permission.`, + `Metadata type '${request.type}' is not revertable — no overlay/runtime-create permission.`, ); err.code = 'NOT_OVERRIDABLE'; err.status = 403; @@ -21039,7 +21040,7 @@ export class ObjectStackProtocolImplementation implements } catch (err: any) { if (err instanceof ConflictError) { const conflict: any = new Error( - `[metadata_conflict] ${request.type}/${request.name} advanced during rollback. ` + `${request.type}/${request.name} advanced during rollback. ` + `Expected parent ${err.expectedParent ?? 'null'} but current is ${err.actualHead ?? 'null'}.`, ); conflict.code = 'METADATA_CONFLICT'; @@ -21398,7 +21399,7 @@ export class ObjectStackProtocolImplementation implements .mergesOverlayAtRead(request.type); if (artifactBacked && !overlayAllowed && !legacyOverlayRemoval) { const err = new Error( - `[not_overridable] Metadata item '${request.type}/${request.name}' is provided by a code package ` + `Metadata item '${request.type}/${request.name}' is provided by a code package ` + `and the type has not opted into per-org overlay writes. ` + `See docs/adr/0005-metadata-customization-overlay.md.` ); @@ -21408,7 +21409,7 @@ export class ObjectStackProtocolImplementation implements } if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) { const err = new Error( - `[not_creatable] Metadata type '${request.type}' does not allow runtime creation or deletion.` + `Metadata type '${request.type}' does not allow runtime creation or deletion.` ); (err as any).code = 'NOT_CREATABLE'; (err as any).status = 403; @@ -21608,7 +21609,7 @@ export class ObjectStackProtocolImplementation implements } catch (err: any) { if (err instanceof ConflictError) { const conflict = new Error( - `[metadata_conflict] ${request.type}/${request.name} has been modified since you loaded it. ` + `${request.type}/${request.name} has been modified since you loaded it. ` + `Expected parent ${err.expectedParent ?? 'null'} but current is ${err.actualHead ?? 'null'}.`, ); (conflict as any).code = 'METADATA_CONFLICT'; @@ -22026,7 +22027,17 @@ export class ObjectStackProtocolImplementation implements `DELETE /api/v1/metadata/object/${record.name}.`, ); } else { - console.warn(`[Protocol] Failed to hydrate ${record.type}/${record.name}: ${e instanceof Error ? e.message : String(e)}`); + // The declared `code` is printed, not left to the prose. + // A LOG LINE has no envelope beside it — the `code` + // axis a caller reads does not exist here — so the one + // machine-readable token an operator can grep for has + // to be IN the line. Until the refusal messages stopped + // restating their own code, this line inherited the + // token by accident from the message it interpolates; + // now it names it deliberately, which is also what the + // sibling branch above already does. + const hydrateCode = (e as any)?.code; + console.warn(`[Protocol] Failed to hydrate ${record.type}/${record.name}: ${e instanceof Error ? e.message : String(e)}${typeof hydrateCode === 'string' && hydrateCode.length > 0 ? ` (code=${hydrateCode})` : ''}`); } } } @@ -22443,16 +22454,17 @@ export class ObjectStackProtocolImplementation implements // `rest-server-meta-references-refusal-envelope.test.ts` — and the // producer-side ORDER by `protocol.reference-target-unanswerable.test.ts`. // - // ⛔ And it opens with NO bracketed tag. The `[item_locked]`-style tags - // this file writes elsewhere are lowercase restatements of the throw's OWN - // declared `code`, so the wire carries the same token on the `code` axis; - // this refusal's code is `NOT_IMPLEMENTED`, so an `[unanswerable_target]` - // opener restated nothing the envelope carries and nothing ever read it. - // #12975 (2026-08-29) rules that `error` is HUMAN LANGUAGE while `code` is - // the MACHINE TOKEN, and since #15685 this prose reaches the operator - // VERBATIM — so the tag was the first thing they read. What separates this - // refusal from the route's other 501 is the sentence itself, not a tag. - // Its absence is pinned by `protocol.reference-target-unanswerable.test.ts`. + // ⛔ And it opens with NO bracketed tag — as no refusal this file raises + // does any more. The lowercase `[item_locked]`-style openers this producer + // once wrote were restatements of the throw's OWN declared `code`, so the + // wire already carried the same token on the `code` axis; they are gone, + // and `protocol.bracketed-refusal-opener-absence.test.ts` pins their + // absence across the whole family. #12975 (2026-08-29) rules that `error` is HUMAN + // LANGUAGE while `code` is the MACHINE TOKEN, and since #15685 this prose + // reaches the operator VERBATIM — so an opener is the first thing they + // read. What separates this refusal from the route's other 501 is the + // sentence itself, not a tag. Its absence is pinned here too, by + // `protocol.reference-target-unanswerable.test.ts`. if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) { const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : ''; const err = new Error( diff --git a/packages/metadata-protocol/src/seed-loader-driver-text.test.ts b/packages/metadata-protocol/src/seed-loader-driver-text.test.ts index a2dae2ad398..3a53f957a0d 100644 --- a/packages/metadata-protocol/src/seed-loader-driver-text.test.ts +++ b/packages/metadata-protocol/src/seed-loader-driver-text.test.ts @@ -313,11 +313,11 @@ describe('[#8442] [GUARD] a DECLARED refusal is quoted whole — the per-record it('a declared 4xx refusal keeps its sentence (#8333’s rule, still in force)', async () => { const { result } = await loadFailing(() => Object.assign( - new Error('[item_locked] Cannot overlay this item: the package is read-only.'), + new Error('Cannot overlay this item: the package is read-only.'), { code: 'ITEM_LOCKED', status: 403 }, )); - expect(result.errors[0].message).toContain('[item_locked]'); + expect(result.errors[0].message).toContain('the package is read-only'); expect(result.errors[0].message).toContain('the package is read-only.'); }); }); diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index ce0567fc46f..4e73bfac7a7 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -941,7 +941,7 @@ export class SysMetadataRepository implements MetadataRepository { }); if (!draftRow) { const err: any = new Error( - `[no_draft] No pending draft exists for ${ref.type}/${ref.name} — nothing to publish.`, + `No pending draft exists for ${ref.type}/${ref.name} — nothing to publish.`, ); err.code = 'NO_DRAFT'; err.status = 404; @@ -1001,7 +1001,7 @@ export class SysMetadataRepository implements MetadataRepository { * a Studio package is UPDATED in place rather than missed by a lookup * narrowed to `package_id IS NULL` (#6215). * - * Throws `[version_not_found]` (404) if the target version row is + * Throws `VERSION_NOT_FOUND` (404) if the target version row is * missing or is a delete tombstone (no body to restore). */ async restoreVersion( @@ -1021,7 +1021,7 @@ export class SysMetadataRepository implements MetadataRepository { }); if (!row) { const err: any = new Error( - `[version_not_found] No history row at version ${targetVersion} for ${ref.type}/${ref.name}.`, + `No history row at version ${targetVersion} for ${ref.type}/${ref.name}.`, ); err.code = 'VERSION_NOT_FOUND'; err.status = 404; @@ -1030,7 +1030,7 @@ export class SysMetadataRepository implements MetadataRepository { const raw = (row as any).metadata; if (raw === null || raw === undefined) { const err: any = new Error( - `[version_not_restorable] Version ${targetVersion} for ${ref.type}/${ref.name} is a delete tombstone — nothing to restore.`, + `Version ${targetVersion} for ${ref.type}/${ref.name} is a delete tombstone — nothing to restore.`, ); err.code = 'VERSION_NOT_RESTORABLE'; err.status = 409; @@ -1640,8 +1640,12 @@ export class SysMetadataRepository implements MetadataRepository { const detail = intent === 'runtime-only' ? `'${type}' has neither allowOrgOverride nor allowRuntimeCreate in the registry. ` : `'${type}' is not allowOrgOverride in the registry. `; + // ⛔ No `[${code}]` opener: the token below IS the `code` this throw + // declares three lines down, so a bracketed restatement duplicates onto the + // prose axis a fact the envelope already carries — and, spelled by + // interpolation, it is invisible to every grep for a literal tag. const err: any = new Error( - `[${code}] ${detail}` + + `${detail}` + `Overlay-allowed: ${Array.from(new Set(allowed)).join(', ') || '(none)'}. ` + `Set OS_METADATA_WRITABLE to enable additional types at runtime.`, ); @@ -1691,7 +1695,7 @@ export class SysMetadataRepository implements MetadataRepository { name?: string, ): Error { const err: any = new Error( - `[writable_package_required] Cannot create ${name ? `${type}/${name}` : type} in package '${packageId}': ` + `Cannot create ${name ? `${type}/${name}` : type} in package '${packageId}': ` + `that package is read-only (provided by code or an installed app), so it is not a writable base. ` + `Switch to a writable package in the package selector, or create a new one, and retry.` // [#8146] Said only when the hatch IS set, because otherwise it is noise. @@ -1741,7 +1745,7 @@ export class SysMetadataRepository implements MetadataRepository { static readOnlyBaseOverrideError(type: string, packageId: string, hatchOpen = false): Error { const singular = PLURAL_TO_SINGULAR[type] ?? type; const err: any = new Error( - `[item_locked] Cannot overlay '${type}' in package '${packageId}': that package is read-only ` + `Cannot overlay '${type}' in package '${packageId}': that package is read-only ` + `(provided by code or an installed app) and the type has no per-org overlay channel ` + `(allowOrgOverride=false), so this item is locked against runtime edits. ` // [#8146] The prescription is chosen by whether the hatch is ALREADY diff --git a/packages/objectql/src/protocol-commit-history.test.ts b/packages/objectql/src/protocol-commit-history.test.ts index 3e67cba878f..8b3cf8a9b0e 100644 --- a/packages/objectql/src/protocol-commit-history.test.ts +++ b/packages/objectql/src/protocol-commit-history.test.ts @@ -656,8 +656,10 @@ describe('#6563 — revertCommit restores a runtime-created `object`', () => { name: 'myapp_invoice', code: 'NOT_OVERRIDABLE', }); + // The token is asserted on `code` just above; the message carries the + // human sentence and no longer restates it. expect(res.failed[0].error).toContain( - `[NOT_OVERRIDABLE] 'object' is not allowOrgOverride in the registry.`, + `'object' is not allowOrgOverride in the registry.`, ); // Refused means refused: the edit the commit made is still the live body. expect(storedFields(rows, 'myapp_invoice').fields).toContain('due_date'); @@ -852,8 +854,10 @@ describe('#6620 — revertCommit soft-removes a runtime-CREATED `object`', () => name: 'myapp_invoice', code: 'NOT_OVERRIDABLE', }); + // The token is asserted on `code` just above; the message carries the + // human sentence and no longer restates it. expect(res.failed[0].error).toContain( - `[NOT_OVERRIDABLE] 'object' is not allowOrgOverride in the registry.`, + `'object' is not allowOrgOverride in the registry.`, ); // Refused means refused: the artifact-backed row is still there. expect(storedRows(rows, 'myapp_invoice')).toHaveLength(1); diff --git a/packages/objectql/src/protocol-meta-types-rich.test.ts b/packages/objectql/src/protocol-meta-types-rich.test.ts index b7215e443c7..2abb843eb79 100644 --- a/packages/objectql/src/protocol-meta-types-rich.test.ts +++ b/packages/objectql/src/protocol-meta-types-rich.test.ts @@ -128,9 +128,12 @@ describe('ObjectStackProtocolImplementation - getMetaTypes rich response', () => delete process.env.OS_METADATA_WRITABLE; ObjectStackProtocolImplementation.resetEnvWritableCache(); resetEnvWritableMetadataTypes(); + // Asserted on the ENVELOPE, not on a token inside the prose: the + // message is human language and the code is the machine axis, so a + // regex over the sentence is not what pins this gate. await expect( scoped.saveMetaItem({ type: 'agent', name: 'my_agent', item: { name: 'my_agent' } }) - ).rejects.toThrow(/not_(overridable|creatable)/); + ).rejects.toMatchObject({ code: expect.stringMatching(/^NOT_(OVERRIDABLE|CREATABLE)$/), status: 403 }); // With env var: `agent` writes allowed. process.env.OS_METADATA_WRITABLE = 'agent'; diff --git a/packages/objectql/src/protocol-meta.test.ts b/packages/objectql/src/protocol-meta.test.ts index 7795188ffa5..87a773ed417 100644 --- a/packages/objectql/src/protocol-meta.test.ts +++ b/packages/objectql/src/protocol-meta.test.ts @@ -631,7 +631,9 @@ describe('ObjectStackProtocolImplementation - Metadata Persistence', () => { expect(caught).toBeDefined(); expect(caught.code).toBe('INVALID_METADATA'); expect(caught.status).toBe(422); - expect(caught.message).toMatch(/invalid_metadata/); + // `code` and `status` above ARE the machine assertion; what the + // message owes is the human sentence, which is asserted as prose. + expect(caught.message).toMatch(/failed spec validation/); expect(Array.isArray(caught.issues)).toBe(true); expect(mockEngine.insert).not.toHaveBeenCalled(); }); diff --git a/packages/objectql/src/protocol-object-overlay-layer.test.ts b/packages/objectql/src/protocol-object-overlay-layer.test.ts index 736fa931600..715048b53d4 100644 --- a/packages/objectql/src/protocol-object-overlay-layer.test.ts +++ b/packages/objectql/src/protocol-object-overlay-layer.test.ts @@ -570,7 +570,9 @@ describe('ADR-0029 D9.9 / #6995 — the row\'s package_id is provenance, never a } expect(booted.res).toMatchObject({ loaded: 0, errors: 1 }); - expect(warned.join('\n')).toContain('object_overlay_package_mismatch'); + // A log line has no envelope, so the boot warning names the declared + // code itself rather than inheriting it from the refusal prose. + expect(warned.join('\n')).toContain('code=OBJECT_OVERLAY_PACKAGE_MISMATCH'); // The packaged definition is served, untouched — no half-applied layer. expect(kinds(booted.registry, 'myapp_invoice')).toEqual(['own']); diff --git a/packages/objectql/src/protocol-writepath-object-ownership.test.ts b/packages/objectql/src/protocol-writepath-object-ownership.test.ts index 4ea7b6efd6b..4ea52435d5d 100644 --- a/packages/objectql/src/protocol-writepath-object-ownership.test.ts +++ b/packages/objectql/src/protocol-writepath-object-ownership.test.ts @@ -318,7 +318,7 @@ describe('#4636 — cloud#970 counter-example: a freshly created app stays edita name: 'myapp_invoice', packageId: APP_PKG, item: objectBody('myapp_invoice'), - })).rejects.toThrow(/not_overridable/); + })).rejects.toMatchObject({ code: 'NOT_OVERRIDABLE', status: 403 }); // The mechanism, stated so a future reader does not have to re-derive // it: with no `_provenance`, `applyProtection` defaults the row to diff --git a/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts index 39aca7c79c7..a799930490c 100644 --- a/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-publish-drafts-response-conformance.test.ts @@ -234,8 +234,9 @@ describe('publish-drafts wire payload conforms to PublishPackageDraftsResponseSc expect(strippedKeys(data)).toEqual([]); const parsed = PublishPackageDraftsResponseSchema.parse(data); expect(parsed.seedApplied?.success).toBe(false); - // The headline names the refusal class and the offending key… - expect(parsed.seedApplied?.error).toContain('[invalid_metadata]'); + // The headline names the refusal class and the offending key. The + // class rides `code`, not a bracketed restatement inside the prose — + // `error` is human language, `code` is the machine token. expect(parsed.seedApplied?.error).toContain('failed spec validation'); expect(parsed.seedApplied?.error).toContain('seeds.0.mode'); // …and the prose lives ONCE, on the declared structured channel. diff --git a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts index 7fce671b99d..2d263da554c 100644 --- a/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts +++ b/packages/runtime/src/domains/packages-seed-apply-disclosure.test.ts @@ -270,7 +270,7 @@ describe('#8443 · 2 · a malformed seed body still reaches its author', () => { const { seedApplied, body } = await publishDrafts({ malformedSeedBody: true }); expect(seedApplied?.success).toBe(false); - expect(seedApplied?.error).toContain('[invalid_metadata]'); + expect(seedApplied?.error).toContain('failed spec validation'); // The author learns WHICH key — the whole reason this population may // not be blanked. `seeds.0.mode` is the path through the request the // loader parses. diff --git a/packages/spec/src/api/plugin-rest-api.zod.ts b/packages/spec/src/api/plugin-rest-api.zod.ts index a6f98505b36..f7e5236c4fc 100644 --- a/packages/spec/src/api/plugin-rest-api.zod.ts +++ b/packages/spec/src/api/plugin-rest-api.zod.ts @@ -895,7 +895,7 @@ export const DEFAULT_METADATA_ROUTES: RestApiRouteRegistration = { 'Promotes the item\'s pending DRAFT overlay to the live `active` row and records an ' + '`op=\'publish\'` history event. The sibling write door of `PUT /:type/:name` — the ' + 'ADR-0033 two-step spelling, where `?mode=draft` stages a body and this makes it live. ' - + '404 `[no_draft]` when there is nothing to publish; 409 `metadata_conflict` when the ' + + '404 `NO_DRAFT` when there is nothing to publish; 409 `metadata_conflict` when the ' + 'published row advanced while the draft was held. Served since before #7294 with no ' + 'declaration behind it — this entry is what makes its response contract nameable.', tags: ['Metadata'], diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 5db152247ae..b74dc909522 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -812,7 +812,7 @@ export const PublishMetaItemRequestSchema = lazySchema(() => z.object({ 'Organization (tenant) scope for the promotion. The implementation resolves ' + 'the draft through the org partition (ADR-0005), so a draft ' + 'authored org-scoped must be published under the same scope or the lookup ' - + 'answers 404 `[no_draft]`. Absent = environment-wide.', + + 'answers 404 `NO_DRAFT`. Absent = environment-wide.', ), actor: z.string().optional().describe( 'Identity recorded on the `op=\'publish\'` history event. On the REST door '