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).
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 acatchthat translatesConflictErrorfaithfully and then re-wraps everything else:statusis carried forward.codeis not. So a refusal thrown bySysMetadataRepositorywith a full ADR-0112 envelope reaches the caller witherr.status === 403anderr.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 markallowOrgOverride:The same refusal on a project kernel (
environmentIdset) is thrown bydeleteMetaItem'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
packages/objectql/src/protocol-object-overlay-layer.test.tsassertsexpect(String(refused.message)).toContain('NOT_OVERRIDABLE')— a substring of the message — while its project-kernel sibling one describe up assertscodeandstatus. 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 againstSysMetadataRepository) instead.Blast radius, and why the fix is not a one-liner
Every non-
ConflictErrorfailure of the repository delete path goes through this re-wrap — genuine 500s (driver faults, history-table outages) as well as the 403 refusals. Restoringcodeunconditionally 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
saveMetaItemand 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
codeforward alongsidestatuswhen the wrapped error has one, i.e. the sameerr?.status ?? 500treatment applied toerr?.code, and then let the D9 control-plane pin assertcode+statuslike 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).