Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/16245-bracketed-tag-refusal-openers.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions content/docs/api/error-catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
}
}
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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=<id>` 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. |
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/commands/meta/delete-reset-carriers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});

/**
Expand Down Expand Up @@ -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({
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
});
Expand Down Expand Up @@ -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' })],
});
Expand All @@ -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) => {
Expand All @@ -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',
Expand All @@ -536,15 +536,15 @@ 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,
);
};

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.');
});
});
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading