Skip to content

Commit 74f2f11

Browse files
os-zhuangclaude
andauthored
fix(metadata-protocol): ADR-0070 D1 delegates its refusal to the repository's create-side emitter (#8361) (#8630)
`saveMetaItem`'s D1 gate spelled its own `WRITABLE_PACKAGE_REQUIRED` sentence, which shadowed #8146's hatch-aware create-side emitter on every kernel: D1 refuses on a strictly wider predicate, so every write that would have reached `SysMetadataRepository.readOnlyBaseCreateError` had already been thrown by D1 — and D1's sentence carried no hatch clause. An operator with `OS_METADATA_WRITABLE` set was never told the hatch does not reach package writability. D1 now calls that emitter (the create-side mirror of #8184's override-side delegation), computing `hatchOpen` from this class's own memoised reader — the one the admission limb used. Both named reasons the one-emitter default might not apply were measured and did not hold: the emitter is static (no repository instance needed) and both sites already carried the identical ADR-0070 `docs` pointer. The emitter gained an optional trailing `name` so the delegated sentence keeps naming the item; omitted, its output is byte-identical for the direct `put` callers (promoteDraft / restoreVersion / revertCommit), which were the only callers reaching the hatch clause at all. D1's predicate is untouched — no create is newly refused or newly admitted. Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a102d0 commit 74f2f11

4 files changed

Lines changed: 458 additions & 13 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): the ADR-0070 D1 refusal tells an operator with the hatch open that it does not reach package writability (#8361)
6+
7+
`OS_METADATA_WRITABLE` unlocks a metadata **type**; it has never unlocked a
8+
package's **writability**. #8146 wrote that sentence into both package-door
9+
emitters in `SysMetadataRepository`, so a refusal emitted while the variable is
10+
set says so instead of leaving the operator to guess. On the override side that
11+
clause is reachable, and #8184 made it reachable on scoped kernels too.
12+
13+
On the **create** side it was reachable from nowhere an author actually writes
14+
from. `saveMetaItem`'s ADR-0070 D1 gate refuses on a strictly wider predicate
15+
than the repository's package door — no "did the caller name a base" limb, no
16+
registry limbs above it — so it threw first on every kernel, with its own
17+
sentence, which had no hatch clause in it. Measured before the fix, with
18+
`OS_METADATA_WRITABLE=permission` set and a runtime-only create aimed at a
19+
read-only package:
20+
21+
```text
22+
[writable_package_required] Cannot save permission/runtime_reviewer: the package
23+
'com.example.showcase' is read-only (provided by code or an installed app).
24+
Switch to a writable package in the package selector, or create a new one, and retry.
25+
```
26+
27+
Byte-identical with the hatch open and with it shut. The operator is told the
28+
base is read-only — true — and never told that the variable they set a moment
29+
ago cannot make it writable. Milder than the false prescription #8146 closed on
30+
the override side (D1 never told anyone to set the variable), so nobody retried
31+
forever; the missing half is guidance, which is why this ships as a diagnostic
32+
fix.
33+
34+
**What changed.** D1 now calls the repository's existing emitter,
35+
`SysMetadataRepository.readOnlyBaseCreateError`, instead of spelling a second
36+
sentence for the same condition — the create-side mirror of what #8184 did on
37+
the override side, and the same one-emitter direction: two independently
38+
authored refusals behind one condition is how a vocabulary drifts. The same
39+
request now answers:
40+
41+
```text
42+
[writable_package_required] Cannot create permission/runtime_reviewer in package
43+
'com.example.showcase': that package is read-only (provided by code or an installed
44+
app), so it is not a writable base. Switch to a writable package in the package
45+
selector, or create a new one, and retry. (OS_METADATA_WRITABLE is set for
46+
'permission': it unlocks the metadata TYPE, not package writability, so it does not
47+
make a read-only package a writable base.)
48+
```
49+
50+
With the hatch **shut** the clause is absent and the sentence keeps the remedy
51+
that is true there — the clause is selected, never appended.
52+
53+
**No acceptance decision moves.** D1's predicate is untouched: every create it
54+
refused it still refuses, with the same `WRITABLE_PACKAGE_REQUIRED` code, the
55+
same 422, the same `packageId`, and the same ADR-0070 `docs` pointer; every
56+
create it admitted — into a writable base, or naming no base at all — still
57+
lands. Only the sentence the refusal carries changed.
58+
59+
`readOnlyBaseCreateError` gained an optional trailing `name` so the delegated
60+
sentence can keep naming the item the way D1's always did. Omitted, its output
61+
is byte-identical to what shipped in #8146 — which is what the direct
62+
`repository.put` callers (`promoteDraft`, `restoreVersion`, `revertCommit`) see,
63+
and until this change they were the *only* callers reaching that clause at all.

packages/metadata-protocol/src/protocol.ts

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11678,23 +11678,55 @@ export class ObjectStackProtocolImplementation implements
1167811678
// A `null` packageId is still accepted here (legacy org-overlay
1167911679
// destination); ADR-0070 D5 retires it once the surfaces always
1168011680
// resolve a base and the orphan migration has run.
11681+
//
11682+
// [#8361] THE SENTENCE IS THE REPOSITORY'S, the predicate is still
11683+
// D1's. `SysMetadataRepository.readOnlyBaseCreateError` is CALLED, not
11684+
// copied — the create-side mirror of what #8184 did four hundred lines
11685+
// above on the override side.
11686+
//
11687+
// ⛔ NOTHING ABOUT THE ACCEPTANCE SET MOVES. The `if` is byte-for-byte
11688+
// the predicate D1 has always used; only the error object it throws
11689+
// changes. Every create D1 refused it still refuses, every create it
11690+
// admitted it still admits — this card is about what the operator is
11691+
// TOLD, not about what is refused.
11692+
//
11693+
// WHY DELEGATE RATHER THAN TEACH D1 ITS OWN `hatchOpen` CLAUSE — the
11694+
// fork #8361 was filed on, and both of the reasons the one-emitter
11695+
// default might NOT have applied were measured here and did not hold:
11696+
// • "D1 fires before the repository exists in the call." True of the
11697+
// INSTANCE (`getOverlayRepo` is below), irrelevant to the emitter:
11698+
// it is `static`, the class is imported at the top of this file,
11699+
// and the override-side call at the top of this same method has
11700+
// been calling a static sibling since #8184.
11701+
// • "D1 carries its own `docs` pointer." Measured identical — both
11702+
// sites set `docs/adr/0070-package-first-authoring.md`, because D1
11703+
// and this emitter implement the SAME decision. (That is what
11704+
// separates the create side from the override side, where the
11705+
// repository points at ADR-0010 and the fork was real.)
11706+
// The one true difference was that D1's sentence names the ITEM and
11707+
// the emitter's named only the type; the emitter took an optional
11708+
// `name` rather than the sentence being forked.
11709+
//
11710+
// `hatchOpen` is COMPUTED here, and must be — unlike the override site,
11711+
// which passes a literal `false` because reaching it proves the hatch
11712+
// is shut. Both directions are live at D1: a type with
11713+
// `allowRuntimeCreate` (e.g. `permission`) arrives with the hatch shut,
11714+
// and a type with neither channel (e.g. `job`) arrives ONLY because
11715+
// `OS_METADATA_WRITABLE` opened it — `isOverlayAllowed` folds the hatch
11716+
// in, so an open hatch carries the write past the code-only refusal
11717+
// straight to this gate. Read through this class's OWN memoised reader,
11718+
// the one that admission decision used, so `hatchOpen` cannot disagree
11719+
// with the limb that let the write through.
1168111720
if (
1168211721
intent === 'runtime-only' &&
1168311722
request.packageId != null &&
1168411723
!this.isWritablePackage(request.packageId)
1168511724
) {
11686-
// Surfaced verbatim as a console toast — keep the sentence
11687-
// user-actionable; the ADR pointer lives in `docs` below.
11688-
const err = new Error(
11689-
`[writable_package_required] Cannot save ${singularTypeForRepo}/${request.name}: `
11690-
+ `the package '${request.packageId}' is read-only (provided by code or an installed app). `
11691-
+ `Switch to a writable package in the package selector, or create a new one, and retry.`,
11725+
const envWritable = ObjectStackProtocolImplementation.envWritableTypes();
11726+
const hatchOpen = envWritable.has(singularTypeForRepo) || envWritable.has(request.type);
11727+
throw SysMetadataRepository.readOnlyBaseCreateError(
11728+
singularTypeForRepo, request.packageId, hatchOpen, request.name,
1169211729
);
11693-
(err as any).code = 'WRITABLE_PACKAGE_REQUIRED';
11694-
(err as any).status = 422;
11695-
(err as any).packageId = request.packageId;
11696-
(err as any).docs = 'docs/adr/0070-package-first-authoring.md';
11697-
throw err;
1169811730
}
1169911731
const orgId = request.organizationId ?? null;
1170011732
const repo = this.getOverlayRepo(orgId);

0 commit comments

Comments
 (0)