Skip to content

deleteMetaItem's catch re-wrap drops the error code, so a repository refusal reaches the caller as a 403 with no catalogued code #7426

Description

@os-zhuang

Found while implementing #6960 (PR pending). Out of scope there — the fix is an error-envelope change on a path #6960 was not authorised to touch — so filing plainly and leaving the grading to triage.

What was measured

ObjectStackProtocolImplementation.deleteMetaItem's repository branch ends in a catch that translates ConflictError faithfully and then re-wraps everything else:

conste=newError(`Failed to delete customization overlay: ${err.message??err}`);(easany).status=err?.status??500;throwe;

status is carried forward. code is not. So a refusal thrown by SysMetadataRepository with a full ADR-0112 envelope reaches the caller with err.status === 403 and err.code === undefined, its code surviving only as text inside the message.

Measured on f16e54e1d, control-plane kernel (environmentId === undefined, which is what the CLI's lightweight assembler builds for a host config — the showcase topology of #5086), deleting an artifact-backed item of a type the registry does not mark allowOrgOverride:

Error: Failed to delete customization overlay: [NOT_OVERRIDABLE] 'permission' is not allowOrgOverride
in the registry. Overlay-allowed: view, dashboard, report, translation, email_template.
Set OS_METADATA_WRITABLE to enable additional types at runtime.
err.status -> 403
err.code -> undefined

The same refusal on a project kernel (environmentId set) is thrown by deleteMetaItem's own two-tier block and arrives intact, code: 'NOT_OVERRIDABLE' + status: 403. So the envelope a caller sees for one and the same refusal depends on the deployment topology.

Why it reads as unintended

  • ADR-0112 makes the code the machine-readable half of a refusal; a 403 with no code is exactly the shape the ledger exists to prevent, and every other refusal on this path carries one.
  • The evidence that it has already cost a pin: ADR-0029 D9's control-plane delete pin in packages/objectql/src/protocol-object-overlay-layer.test.ts asserts expect(String(refused.message)).toContain('NOT_OVERRIDABLE') — a substring of the message — while its project-kernel sibling one describe up asserts codeandstatus. A test that wants the envelope and can only reach the prose is the symptom, not a style choice.
  • #6960's new pin file hit the same wall and states the gap in a comment rather than asserting around it, carrying the full envelope one layer down (directly against SysMetadataRepository) instead.

Blast radius, and why the fix is not a one-liner

Every non-ConflictError failure of the repository delete path goes through this re-wrap — genuine 500s (driver faults, history-table outages) as well as the 403 refusals. Restoring code unconditionally therefore changes the envelope of that whole class at once, including cases where the underlying error has a code that is not meant to surface. That is a deliberate contract decision about which codes propagate through a wrapper, which is why #6960 stopped here instead of taking it: its ruling moved the delete authorization, not the delete error envelope.

Worth checking in the same pass whether saveMetaItem and the other repository-fronting verbs carry the same shape — this was found on the delete path only, and no claim is made about the others.

Suggested shape (not a decision)

Carry code forward alongside status when the wrapped error has one, i.e. the same err?.status ?? 500 treatment applied to err?.code, and then let the D9 control-plane pin assert code + status like its sibling. The alternative — leave the wrapper alone and let callers parse the message — is what exists today and is what made the pin weaker than its twin.

Refs: #6960, #5086 (the control-plane topology), ADR-0112, ADR-0029 D9 (f16e54e1d).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions