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
63 changes: 63 additions & 0 deletions .changeset/d1-create-side-hatch-clause.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
---
"@objectstack/metadata-protocol": patch
---

fix(metadata-protocol): the ADR-0070 D1 refusal tells an operator with the hatch open that it does not reach package writability (#8361)

`OS_METADATA_WRITABLE` unlocks a metadata **type**; it has never unlocked a
package's **writability**. #8146 wrote that sentence into both package-door
emitters in `SysMetadataRepository`, so a refusal emitted while the variable is
set says so instead of leaving the operator to guess. On the override side that
clause is reachable, and #8184 made it reachable on scoped kernels too.

On the **create** side it was reachable from nowhere an author actually writes
from. `saveMetaItem`'s ADR-0070 D1 gate refuses on a strictly wider predicate
than the repository's package door — no "did the caller name a base" limb, no
registry limbs above it — so it threw first on every kernel, with its own
sentence, which had no hatch clause in it. Measured before the fix, with
`OS_METADATA_WRITABLE=permission` set and a runtime-only create aimed at a
read-only package:

```text
[writable_package_required] Cannot save permission/runtime_reviewer: the package
'com.example.showcase' is read-only (provided by code or an installed app).
Switch to a writable package in the package selector, or create a new one, and retry.
```

Byte-identical with the hatch open and with it shut. The operator is told the
base is read-only — true — and never told that the variable they set a moment
ago cannot make it writable. Milder than the false prescription #8146 closed on
the override side (D1 never told anyone to set the variable), so nobody retried
forever; the missing half is guidance, which is why this ships as a diagnostic
fix.

**What changed.** D1 now calls the repository's existing emitter,
`SysMetadataRepository.readOnlyBaseCreateError`, instead of spelling a second
sentence for the same condition — the create-side mirror of what #8184 did on
the override side, and the same one-emitter direction: two independently
authored refusals behind one condition is how a vocabulary drifts. The same
request now answers:

```text
[writable_package_required] Cannot create permission/runtime_reviewer in package
'com.example.showcase': 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. (OS_METADATA_WRITABLE is set for
'permission': it unlocks the metadata TYPE, not package writability, so it does not
make a read-only package a writable base.)
```

With the hatch **shut** the clause is absent and the sentence keeps the remedy
that is true there — the clause is selected, never appended.

**No acceptance decision moves.** D1's predicate is untouched: every create it
refused it still refuses, with the same `WRITABLE_PACKAGE_REQUIRED` code, the
same 422, the same `packageId`, and the same ADR-0070 `docs` pointer; every
create it admitted — into a writable base, or naming no base at all — still
lands. Only the sentence the refusal carries changed.

`readOnlyBaseCreateError` gained an optional trailing `name` so the delegated
sentence can keep naming the item the way D1's always did. Omitted, its output
is byte-identical to what shipped in #8146 — which is what the direct
`repository.put` callers (`promoteDraft`, `restoreVersion`, `revertCommit`) see,
and until this change they were the *only* callers reaching that clause at all.
54 changes: 43 additions & 11 deletions packages/metadata-protocol/src/protocol.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -11556,23 +11556,55 @@ export class ObjectStackProtocolImplementation implements
// A `null` packageId is still accepted here (legacy org-overlay
// destination); ADR-0070 D5 retires it once the surfaces always
// resolve a base and the orphan migration has run.
//
// [#8361] THE SENTENCE IS THE REPOSITORY'S, the predicate is still
// D1's. `SysMetadataRepository.readOnlyBaseCreateError` is CALLED, not
// copied — the create-side mirror of what #8184 did four hundred lines
// above on the override side.
//
// ⛔ NOTHING ABOUT THE ACCEPTANCE SET MOVES. The `if` is byte-for-byte
// the predicate D1 has always used; only the error object it throws
// changes. Every create D1 refused it still refuses, every create it
// admitted it still admits — this card is about what the operator is
// TOLD, not about what is refused.
//
// WHY DELEGATE RATHER THAN TEACH D1 ITS OWN `hatchOpen` CLAUSE — the
// fork #8361 was filed on, and both of the reasons the one-emitter
// default might NOT have applied were measured here and did not hold:
// • "D1 fires before the repository exists in the call." True of the
// INSTANCE (`getOverlayRepo` is below), irrelevant to the emitter:
// it is `static`, the class is imported at the top of this file,
// and the override-side call at the top of this same method has
// been calling a static sibling since #8184.
// • "D1 carries its own `docs` pointer." Measured identical — both
// sites set `docs/adr/0070-package-first-authoring.md`, because D1
// and this emitter implement the SAME decision. (That is what
// separates the create side from the override side, where the
// repository points at ADR-0010 and the fork was real.)
// The one true difference was that D1's sentence names the ITEM and
// the emitter's named only the type; the emitter took an optional
// `name` rather than the sentence being forked.
//
// `hatchOpen` is COMPUTED here, and must be — unlike the override site,
// which passes a literal `false` because reaching it proves the hatch
// is shut. Both directions are live at D1: a type with
// `allowRuntimeCreate` (e.g. `permission`) arrives with the hatch shut,
// and a type with neither channel (e.g. `job`) arrives ONLY because
// `OS_METADATA_WRITABLE` opened it — `isOverlayAllowed` folds the hatch
// in, so an open hatch carries the write past the code-only refusal
// straight to this gate. Read through this class's OWN memoised reader,
// the one that admission decision used, so `hatchOpen` cannot disagree
// with the limb that let the write through.
if (
intent === 'runtime-only' &&
request.packageId != null &&
!this.isWritablePackage(request.packageId)
) {
// Surfaced verbatim as a console toast — keep the sentence
// user-actionable; the ADR pointer lives in `docs` below.
const err = new Error(
`[writable_package_required] Cannot save ${singularTypeForRepo}/${request.name}: `
+ `the package '${request.packageId}' is read-only (provided by code or an installed app). `
+ `Switch to a writable package in the package selector, or create a new one, and retry.`,
const envWritable = ObjectStackProtocolImplementation.envWritableTypes();
const hatchOpen = envWritable.has(singularTypeForRepo) || envWritable.has(request.type);
throw SysMetadataRepository.readOnlyBaseCreateError(
singularTypeForRepo, request.packageId, hatchOpen, request.name,
);
(err as any).code = 'WRITABLE_PACKAGE_REQUIRED';
(err as any).status = 422;
(err as any).packageId = request.packageId;
(err as any).docs = 'docs/adr/0070-package-first-authoring.md';
throw err;
}
const orgId = request.organizationId ?? null;
const repo = this.getOverlayRepo(orgId);
Expand Down
Loading
Loading