Repository navigation
Expand file tree
/
Copy pathprotocol-diff-delete-load.ts
More file actions
1658 lines (1624 loc) · 104 KB
/
Copy pathprotocol-diff-delete-load.ts
File metadata and controls
1658 lines (1624 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* [#29985 codemod] The "Diff, deleteMetaItem, loadMetaFromDb, load reports,
* findReferencesToMeta" facet of `ObjectStackProtocolImplementation`: the
* bodies of `diffMetaItem`, `deleteMetaItemUnaudited`, `loadMetaFromDb`,
* `findReferencesToMeta` moved out of `protocol.ts` whole by
* `scripts/codemod-split-protocol.mjs`.
*
* A MOVE, not a redesign. `ProtocolDiffDeleteLoadFacet` is a stateless facet over the host
* (ruling 6624345766): each static method below is the body of the class
* member of the same name as `packages/metadata-protocol/scripts/host-split.mjs`
* moves one (ruling E, item E1), with `this.<member>` read as `host.<member>`
* and nothing else changed. The facet holds no state: the fields stay on the
* class, and a body reaches them through the host (`protocol-host.ts`).
*
* The class keeps every member as a one-line delegation. A public member
* keeps its docblock there, which the published `.d.ts` prints; a private
* member's docblock moved here with its body (docblock ownership).
*
* What a body reads from elsewhere, it imports from where `protocol.ts`
* imports it. Nothing re-exports this module, so the published surface does
* not move.
*/
import { overlayMemberWhere } from './migrations/overlay-index.js';
import {
historyRowsOfMember,
packagelessAddressAmbiguityRefusal,
versionNotFoundError,
} from './sys-metadata-repository.js';
import {
ConflictError,
findUndeclarableFieldType,
isObjectFieldTypeRefused,
readLegacyOrganizationRows,
} from '@objectstack/metadata-core';
import { isMissingTableError } from '@objectstack/metadata/errors';
import { PLURAL_TO_SINGULAR } from '@objectstack/spec/shared';
import { isServedOverrideRowOf } from './served-override.js';
import type { ServedOverrideRow } from './served-override.js';
import { REFERENCE_SITES } from './reference-sites.js';
import { computeMetadataDiagnostics } from './metadata-diagnostics.js';
import { hasMetadataRedactor, redactMetadataItem } from './metadata-redaction.js';
import { isPairRowRemoval } from './protocol-packaged-base-refusal.js';
import { canonicalizeMetaRequestType } from './protocol-meta-type-schema.js';
import { remainingRowsPhrase } from './protocol-authoring-refusals.js';
import { memberOption, mutationEventMember, resolveBundleMemberSelector } from './protocol-bundle-members.js';
import {
carryCatalogedErrorCode,
carryDeclaredUserMessage,
overlayDeleteFailureMessage,
} from './protocol-client-failures.js';
import { collectCompositeKeyHeadHits, collectReferenceHits } from './protocol-reference-hits.js';
import { diffShallow } from './protocol-write-face.js';
import type { MutationProjectionOutcome } from './protocol-mutation-events.js';
import type { DeleteMetaItemRequest } from './protocol-door-requests.js';
import type { ProtocolHost } from './protocol-host.js';
export class ProtocolDiffDeleteLoadFacet {
/** The body of `ObjectStackProtocolImplementation#diffMetaItem`; the method's docblock there is its contract. */
static async diffMetaItem(host: Pick<ProtocolHost, 'diffMetaItem' | 'findEnvironmentRow' | 'findEnvironmentRows' | 'resolveRowlessExpandedView' | 'rethrowUnlessMetadataStoreUnprovisioned'>, request: {
type: string;
name: string;
fromVersion?: number;
toVersion?: number;
}): Promise<{
type: string;
name: string;
fromVersion: number | null;
toVersion: number | null;
added: Array<{ path: string; value: unknown }>;
removed: Array<{ path: string; value: unknown }>;
changed: Array<{ path: string; from: unknown; to: unknown }>;
}> {
// [#8868] #4432 / #7894 — CANONICAL TYPE KEY. See {@link canonicalMetaType}.
// `diffMetaItem` is the NINTH `/meta` entry point on this URL family
// (`GET /api/v1/meta/:type/:name/diff`, caller-supplied `:type`, registered
// at `rest-route-ledger.ts:184`) and was the last one still deriving its type
// key from `PLURAL_TO_SINGULAR` — the MANIFEST-COLLECTION map #7894 moved this
// boundary off. The same one-line replacement #8769 (`publishMetaItem`) and
// #8819 (`rollbackMetaItem`) made, for the same reason.
//
// What the fold reaches here, measured rather than assumed:
//
// • the history read below, which queries `sys_metadata_history` by `type`.
// For the four MANIFEST-ABSENT types (`field`, `seed`, `external_catalog`,
// `translation` — legitimately absent from the manifest map because they
// are not stack collections) a plural stayed plural all the way into that
// `where`, matched no row, and this function answered a well-formed EMPTY
// diff for an item that DOES have history. Not a refusal and not an error:
// a silent "nothing changed" on a routed live endpoint, which is why this
// is a wrong-answer defect rather than #8819's protection-bypass class.
// Manifest-PRESENT types (`views` → `view`) folded already and were never
// affected — that asymmetry is what makes `fields` the discriminating
// fixture in the pins and `views` a fold that proves nothing.
// • the #7894 boundary REFUSAL, which never ran on this verb at all: an
// unrecognised spelling of a declared type (`viewes`) was forwarded to the
// plugin path instead of refused `400 INVALID_REQUEST`.
// • the echoed `type` in the response below. Reassigning `request` is what
// makes that echo report the CANONICAL spelling, and that is the precedent
// this family already set rather than a new answer: `saveMetaItem` and
// `deleteMetaItem` both `return { type: request.type }` AFTER their fold,
// and #8819's own header calls reporting the CALLER's spelling for a row
// addressed under the canonical one the defect it was fixing. The echo now
// names the spelling the read actually used.
request = canonicalizeMetaRequestType(request);
// Canonical by construction from the fold above. NOT a second fold: the
// `PLURAL_TO_SINGULAR` lookup that used to stand here is exactly what #7894
// removed from this boundary. The binding survives under its own name because
// the read below reads it as "the key the row is stored under" — the same
// shape {@link rollbackMetaItem} keeps.
const singularType = request.type;
// [#21442] A view name with no stored row of its own that a stored
// view container in this caller's scope expands — a name the by-name
// read answers with the container's expansion — has no versions of its
// own, and none are synthesized for it. The comparison is the
// container's own row's, made exactly as this method makes it under the
// container's own name, and the answer says so: its `name` is the
// container's — the item actually diffed, the same rule the echoed
// `type` follows above.
const expandedFrom = await host.resolveRowlessExpandedView(
{ type: singularType, name: request.name },
);
if (expandedFrom !== undefined) {
return host.diffMetaItem({ ...request, name: expandedFrom.container.name });
}
// [#8798] Read the history rows DIRECTLY, once. `historyMetaItem`
// cannot serve this function: its `MetadataEvent` shape doesn't carry
// the per-(type,name) `version` a diff selects versions by, so its
// result was computed into a `versions` array and then discarded
// (`const _used = versions; void _used;`) while the real read happened
// below — a second, unused round trip over `sys_metadata_history` on
// every request to a routed, live endpoint.
//
// ⛔ Do not reinstate a `historyMetaItem` call here "for the
// authorization check". It never performed one for this path, measured
// both ways: its early return (`isOverlayAllowed` / `isRuntimeCreateAllowed`)
// answers `{ events: [] }` WITHOUT throwing and without touching the
// engine, so the five types that take it (`field`, `job`, `api`,
// `capability`, `agent`) had their diff served by the read below
// regardless — the gate never reached this function's output. What the
// discarded call did change was failure behaviour, and only by accident:
// being unguarded, it made a `sys_metadata_history` outage FATAL for
// gated-open types while the `try` below answered an empty diff for the
// five gated-shut ones. One outage, two answers, decided by type. The
// `catch` below is this function's only stated intent for that failure,
// so removing the call makes every type take it. Pinned in
// `protocol.diff-dead-history-read.test.ts`.
const histRows: Array<{ version: number; body: Record<string, unknown> | null }> = [];
try {
// [#21908, ADR-0096 D5] The explicit system opt-in: the history
// lineage is a platform store the diff door already authorized.
// Principal-less,
// the engine refuses it, and the `catch` below would have answered
// an empty diff for an item that has history.
// [#22877] The canonical member's lineage only — a by-name diff of a
// bundled type compares the member a by-name read serves, never a
// second locale's snapshot as its "earlier version".
const rows: any[] = historyRowsOfMember<any>(singularType, await host.findEnvironmentRows('sys_metadata_history', {
where: {
type: singularType,
name: request.name,
},
context: { isSystem: true },
}));
rows.sort((a: any, b: any) => (a.version ?? 0) - (b.version ?? 0));
for (const r of rows) {
const body = r.metadata == null
? null
: (typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata);
histRows.push({ version: r.version ?? 0, body });
}
} catch (error) {
// [#8833] ADR-0110 D3 — a miss and an outage are different facts, and a
// consumer must never read one as the other.
//
// This `catch` used to be EMPTY and fall through, leaving `histRows` at
// `[]`. The invention then happened below, where that never-filled
// accumulator is read as though it were a real answer: a
// `sys_metadata_history` outage was served as a well-formed 200 with
// `added`/`removed`/`changed` all empty — byte-identical to "these two
// versions are the same". An operator comparing versions before a
// rollback, and any SDK or agent reading this endpoint, acted on
// "unchanged" with full confidence, and no log line was emitted either.
// (That fall-through shape is also why `check-durability-degradation-log-
// level`'s read-seam-invention rule cannot see this seam despite scanning
// this package: it keys on `ts.isReturnStatement` and this `catch`
// returned nothing. Extending that gate is #8845 in the devx lane.)
//
// Maintainer ruling (2026-08-15, #8833 comment 5302933802): route the
// failure through the platform's EXISTING discrimination rather than
// adding a response field. The genuinely-absent-table case — a minimal
// deployment that never provisioned history — keeps its benign empty
// answer, because there really are no rows and a first boot must not
// explode. EVERY other read failure (connection drop, timeout, permission
// denial, query error) propagates the 503.
// {@link rethrowUnlessMetadataStoreUnprovisioned} is that single
// discriminator — the same guard #5532 restored for `getMetaItems` and
// #8855 carries for `getMetaDiagnostics` — and it is conservative in the
// safe direction: an UNRECOGNISED error is loud, never benign.
//
// ⚠️ This ADDS loudness where there is now none, uniformly for every
// type. Before PR #8841 the outage threw for gated-open types (`view`)
// and was swallowed for gated-shut ones (`field`, `job`, `api`,
// `capability`, `agent`); #8841 deleted the discarded `historyMetaItem`
// round trip that caused that split, so it had become silent everywhere.
// The ruling was made against that post-#8841 state.
//
// ⛔ A `historyUnavailable: true` response key (the card's option B) was
// DECLINED in the same ruling — a new published key with no consumer, on
// the manual floor. Do not reintroduce it as "more informative".
host.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata_history');
}
const byVersion = new Map<number, Record<string, unknown> | null>();
for (const r of histRows) byVersion.set(r.version, r.body);
let fromBody: Record<string, unknown> | null = null;
let toBody: Record<string, unknown> | null = null;
let fromVersion: number | null = null;
let toVersion: number | null = null;
if (request.toVersion !== undefined) {
toVersion = request.toVersion;
// [#26462] ADR-0110 D3 — a named version with no row is a MISS,
// never an empty side: compared as `{}` it answered "no change"
// (both missing, or an item with no history at all) or
// "everything removed" with a 200. Refused as the rollback door
// refuses the same number. The read is keyed by (type, name), so
// another item's version is this item's miss too. A delete
// tombstone HAS a row (a `null` body, not `undefined`) and stays
// served.
const named = byVersion.get(request.toVersion);
if (named === undefined) {
throw versionNotFoundError({ type: singularType, name: request.name }, request.toVersion);
}
toBody = named;
} else {
// [#20397] The default `to` side is the CURRENT ACTIVE ROW, and ONE
// read of that row supplies both of its facts: the body compared and
// the `version` it is labelled with. `SysMetadataRepository.put`
// stamps that column in the same transaction that appends the history
// row carrying the same body, so it names the version this body is.
//
// The label used to come from the NEWEST `sys_metadata_history` row
// instead, which is a draft save whenever a draft is pending (every
// draft save appends a row too). Body and label then named different
// rows: on the real REST stack an app answered `2 → 3` over its
// version-1 body, and a view answered "no changes" labelled `1 → 2`
// while version 2 differs.
//
// Read here, not through `SysMetadataRepository.get`: its
// `MetadataItem` projection carries the row's content hash but not
// its lineage `version`. Same predicate as that read (active state, no
// package scope), and VERBATIM like the history bodies it is compared
// against: no ADR-0087 conversion on either side.
//
// No active row (a draft-only item, a deleted one) ⇒ that side is
// absent and so is its label: `null`, as `DiffMetaItemResponseSchema`
// declares, never the number of a row whose body is not the one
// compared. ⛔ Do not recover a number by matching bodies or hashes
// against history: a publish and a revert both write rows whose
// bodies repeat earlier ones.
// [#21908, ADR-0096 D5] The explicit system opt-in, as the history
// read above.
const current = (await host.findEnvironmentRow('sys_metadata', {
where: {
type: singularType, name: request.name, state: 'active',
// [#22877] The canonical member, as the by-name read serves.
...overlayMemberWhere(singularType),
},
context: { isSystem: true },
})) as { metadata?: unknown; version?: unknown } | null;
toBody = current?.metadata == null
? null
: (typeof current.metadata === 'string'
? JSON.parse(current.metadata)
: current.metadata as Record<string, unknown>);
toVersion = current && typeof current.version === 'number' ? current.version : null;
}
if (request.fromVersion !== undefined) {
fromVersion = request.fromVersion;
// [#26462] The same miss on the from side — see the to side above.
const named = byVersion.get(request.fromVersion);
if (named === undefined) {
throw versionNotFoundError({ type: singularType, name: request.name }, request.fromVersion);
}
fromBody = named;
} else if (toVersion !== null) {
// [#20451] The default from side is the NEAREST EARLIER history row
// whose body DIFFERS from the to side's, not the row immediately
// before it. Every draft save appends a history row and a publish
// appends the promoted body again as the next one, so the row
// immediately before a published version is usually the draft save
// it came from, with the same body: the default answered "no
// changes" right after every publish, and the change the publish
// carried was one explicit range away.
//
// "Differs" is this diff's OWN equality — `diffShallow`'s three
// buckets not all empty, with an absent body compared as `{}`
// exactly as the comparison below compares it — never a second
// notion of which rows count. So a body-less row (a delete's
// tombstone) differs from any non-empty to side and the walk stops
// on it, naming the deletion: the item did not exist there.
// ⛔ Do not branch on `operation_type` or add a lifecycle column to
// tell drafts from publishes: the #20378 ruling (comment
// 5865708652) declined the state column, and the retriage answer
// on #20451 (comment 5875579209) is this equality over every row.
//
// The walk reads only `histRows`, already the item's whole history
// from the one `find` above, sorted by version; it adds no read and
// no cap. No earlier row differs ⇒ the from side is absent (`null`,
// everything added), the answer for an item with no earlier version.
const earlier = histRows.map((r) => r.version).filter((v) => v < toVersion!);
const rawTarget = toBody ?? {};
for (let i = earlier.length - 1; i >= 0; i--) {
const candidate = byVersion.get(earlier[i]!) ?? null;
const d = diffShallow(candidate ?? {}, rawTarget);
if (d.added.length || d.removed.length || d.changed.length) {
fromVersion = earlier[i]!;
fromBody = candidate;
break;
}
}
}
// [commit 75e66fc8e] Diff RAW, then redact the EMITTED values — maintainer ruling
// (recorded in that commit's message), Option B. The comparison runs on the stored
// bodies untouched, so a credential ROTATION still registers as a
// changed path; only the values leaving this function are taken from
// the type's redacted projection of those same bodies.
//
// ⛔ Redacting the bodies BEFORE `diffShallow` (Option A) was ruled out
// and must not be reintroduced as a simplification: two redacted bodies
// are equal at a redacted path, so the rotation would vanish into a
// no-diff — destroying the one fact this endpoint exists to serve.
//
// WHY SUBSTITUTION RATHER THAN PATH-MATCHING against `redactedKeys`:
// the two namings live on different planes. `diffShallow` is TOP-LEVEL
// (a nested change collapses to one entry at the top-level key, whose
// value is the whole sub-object), while `redactedKeys` is nested and
// dotted (`config.password`). So `redactedKeys.includes(entry.path)`
// would match NOTHING on the real leak — the leaking entry's path is
// `config`. Reading each emitted value out of the already-redacted body
// gets the nested case right for free and mints no second rule set:
// the redactor stays the single source of what a credential is.
const rawFrom = fromBody ?? {};
const rawTo = toBody ?? {};
const diff = diffShallow(rawFrom, rawTo);
let served = diff;
// Skipped entirely for the overwhelming majority of types, which register
// no redactor — `hasMetadataRedactor` is the seam that answers that
// without this call site having to know which types hold a secret.
if (hasMetadataRedactor(singularType)) {
const servedFrom = redactMetadataItem(singularType, rawFrom) as Record<string, unknown>;
const servedTo = redactMetadataItem(singularType, rawTo) as Record<string, unknown>;
// Both sides, and all three buckets: leaking either the old or the
// new value defeats the point (the ruling's second binding note).
served = {
added: diff.added.map((e) => ({ path: e.path, value: servedTo[e.path] })),
removed: diff.removed.map((e) => ({ path: e.path, value: servedFrom[e.path] })),
changed: diff.changed.map((e) => ({ path: e.path, from: servedFrom[e.path], to: servedTo[e.path] })),
};
}
return {
type: request.type,
name: request.name,
fromVersion,
toVersion,
...served,
};
}
/** [#28355] The body of {@link deleteMetaItem} before its refusal audit; that method's docblock is the contract. */
static async deleteMetaItemUnaudited(host: Pick<ProtocolHost, 'assertLockAllowsDelete' | 'dropObjectStorage' | 'dropStorageRemainingRowsRefusal' | 'emitMetadataMutation' | 'engine' | 'environmentId' | 'findEnvironmentRow' | 'getOverlayRepo' | 'isArtifactBacked' | 'isOverlayAllowed' | 'isRuntimeCreateAllowed' | 'metadataConflictRefusal' | 'originGatedRemovalRefusal' | 'packagedArtifactOwner' | 'packagedBaseRefusal' | 'packagelessAddress' | 'packagelessDraftAddress' | 'packagelessOverlayWriteRefusal' | 'recordMetadataAudit' | 'recordOptimisticConflictAudit' | 'refuseOrganizationScopedWrite' | 'refusePackagedBaseRemoval' | 'registryAllowsOverlay' | 'restoreArtifactRegistryView' | 'runMutationProjector' | 'shouldDropStorage' | 'storedParentForToken'>, request: DeleteMetaItemRequest): Promise<{
success: boolean;
message?: string;
reset?: boolean;
seq?: number;
/** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */
projectionApplied?: MutationProjectionOutcome;
}> {
// [ADR-0131 D6] FIRST: an organization-scoped delete is refused.
// See {@link organizationScopedWriteRefusal}.
host.refuseOrganizationScopedWrite(
`Metadata item '${request.type}/${request.name}'`, request,
);
// #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. Without it
// the authorization tier (`isOverlayAllowed` / `isArtifactBacked`) and
// the registry heal (`restoreArtifactRegistryView`) read the caller's
// spelling while the repository deletes under the singular — so a
// DELETE could remove the row and leave the shadow it was meant to lift.
request = canonicalizeMetaRequestType(request);
// [#22877] Ruling 6116165299 (Q2 i): a reset by name removes ONE
// member's row of a bundled type — the selector's, else the canonical
// one — and heals the registry for that member alone.
const member = resolveBundleMemberSelector(request.type, request.member);
// [#21899] The removal verdict on an ORIGIN-GATED code-defined item
// (ADR-0126 §3: `datasource` — code-defined read-only, runtime-created
// free), held back until the row probe below. See
// {@link originGatedRemovalRefusal}: the item itself is never removed
// here, but a stored row under its name is, as repair.
const originGatedRefusal = host.originGatedRemovalRefusal(request.type, request.name);
// [#28412, ADR-0146 D9 item 1] A removal naming the shipping package's
// pair, of a type whose D9 conditions are delivered (`flow` only —
// every other open type keeps main's verdict): it removes
// the row the override is stored in, and the code package's item is
// served — and armed — again. Admitted past the sealed-removal refusal
// and told to the store as a runtime-only removal, as the declared
// save is ({@link isPairRowRemoval}).
const pairRowRemoval = isPairRowRemoval(
request.type, host.packagedArtifactOwner({ type: request.type, name: request.name }), request.packageId,
);
// Two-tier authorization for delete (mirrors saveMetaItem).
// • Artifact-backed item → delete becomes a tombstone overlay,
// requires `allowEnvironmentOverlay`…
// • …EXCEPT when the type's entry carries `supportsOverlay: true` —
// the removal carve-out: removing the row removes no managed
// definition (and changes what is served for `permission` alone),
// so it is repair and is allowed without it. The flag opens no save
// and decides no read. [#6960, delete-only; see below.]
// • DB-only item → hard delete of a user-created row,
// requires `allowRuntimeCreate` (or `allowEnvironmentOverlay`).
if (host.environmentId !== undefined) {
const overlayAllowed = host.isOverlayAllowed(request.type);
const runtimeCreateAllowed = host.isRuntimeCreateAllowed(request.type);
const artifactBacked = host.isArtifactBacked(request.type, request.name);
// [#6960] …and the ONE removal the refusal above must not swallow.
//
// MAINTAINER RULING, 2026-08-10 (#6960): removing a legacy env
// overlay on a type whose per-org override channel was rolled back
// is allowed through the ordinary delete path. Deleting an overlay
// restores the code-declared state — it is the NARROWING direction
// and cannot widen anything — so refusing it serves no security
// purpose while trapping the repair behind `OS_METADATA_WRITABLE`.
//
// ⛔ DELETE ONLY, AND IT IS NOT AN OVERSIGHT. Create and update on
// the same item stay refused exactly as before (`saveMetaItem`'s
// sibling gate is untouched); a future tidy-up that "restores
// symmetry" here re-opens the write door the rollback in commit ee58392e1 closed.
// The asymmetry is the ruling.
//
// THE TIER BOUNDARY, which is what makes this narrow enough to be
// safe: {@link mergesOverlayAtRead} is `supportsOverlay`, NOT
// `allowEnvironmentOverlay`.
//
// • `supportsOverlay: true` + `allowEnvironmentOverlay: false` — the
// ROLLED-BACK OVERLAYABLE tier (`permission` / `position` /
// `page` / `app` / `dataset` / `book`, and any type that lands
// in this shape later). Commit ee58392e1 closed the write door
// and, until ADR-0131 D6's seal, left the read path merging
// overlay-wins, so a row authored before the rollback shaped the
// effective body and the ordinary "Reset to package default" flow
// answered 403 with the item still customized. The seal now
// withholds such a row from the served read (`permission`
// excepted, {@link isUnservedSealedOverlay}); the row stays stored,
// and this branch is how the operator removes it.
// • `supportsOverlay: false` — `object` above all, whose overlay
// is a CONTRIBUTOR LAYER (ADR-0029 D9) rather than a merge, and
// whose reset refusal is D9.6's declared and maintainer-approved
// cost. This branch does not reach it and must not: pinned by
// `protocol-object-overlay-layer.test.ts` on both topologies.
//
// The same carve-out is mirrored — deliberately, not by accident —
// in `SysMetadataRepository`'s delete gate, because that one is
// TOPOLOGY-INDEPENDENT: a control-plane kernel skips this whole
// block (`environmentId === undefined`) and would otherwise be
// refused there instead, leaving the fix half-done. See
// {@link SysMetadataRepository.assertDeleteAllowed}.
//
// [#20679] The verdict — the artifact-backed refusal AND the
// carve-out above — was lifted, unchanged, into
// {@link refusePackagedBaseRemoval}, so a second removal door onto
// the same packaged artifact asks this one through
// {@link packagedBaseRefusal} instead of carrying a copy.
//
// [#21899] An origin-gated code-defined item's verdict is that same
// refusal, answered at the row probe instead of here: thrown when
// no stored row exists, and lifted for the repair that deletes one.
if (originGatedRefusal === null && !pairRowRemoval) host.refusePackagedBaseRemoval(request);
if (!artifactBacked && !overlayAllowed && !runtimeCreateAllowed) {
const err = new Error(
`Metadata type '${request.type}' does not allow runtime creation or deletion.`
);
(err as any).code = 'NOT_CREATABLE';
(err as any).status = 403;
throw err;
}
}
// [ADR-0146 D11] The package-less removal of a packaged item's active
// row, closed with the package-less write
// ({@link packagelessOverlayWriteRefusal}): a delete naming no package,
// of an item a code package ships, on one of the five overlay types,
// whose row the ceremony would move (Setup's "Reset overlay"). Its
// customization lives in the base's paired `.custom` once moved, and
// `?package=<base>.custom` removes it there. On every topology, as
// the save door's is, and ahead of the `_lock` gate's store read.
//
// ⛔ Not a draft discard: it changes nothing any read serves, and it is
// how a package-less draft stored before the close is cleared (the
// draft cannot be published package-less any more), including by the
// discard-all walk, which names the package each draft is listed under.
if (request.state !== 'draft') {
const closed = await host.packagelessOverlayWriteRefusal({
type: request.type,
name: request.name,
packageId: request.packageId,
state: 'active',
...memberOption(member),
}, 'delete');
if (closed) throw closed;
}
// ADR-0010 L3 — lock blocks delete. [#21694] On EVERY topology, ranked
// below the package removal door exactly as `saveMetaItem` ranks the
// save gate below its package door (see the note there): on an
// environment kernel that door has thrown above whenever it refuses;
// on a host-config kernel a packaged base it refuses keeps the answer
// that kernel already gave it — the repository's delete gate when an
// overlay row exists, a no-op that leaves the artifact standing when
// none does (see {@link packagedBaseRefusal}). [#21899] The
// origin-gated repair is a removal the doors allow, so the `_lock`
// gate asks about it as it asks about every other allowed removal.
if (
originGatedRefusal !== null
|| pairRowRemoval
|| host.packagedBaseRefusal({ type: request.type, name: request.name, operation: 'delete' }) === null
) {
const lockErr = await host.assertLockAllowsDelete({
type: request.type,
name: request.name,
// [#23297] The delete's address carries its package, as the save's does.
...(request.packageId ? { packageId: request.packageId } : {}),
...(request.actor ? { actor: request.actor } : {}),
source: 'protocol.deleteMetaItem',
});
if (lockErr) throw lockErr;
}
const singularTypeForRepo = PLURAL_TO_SINGULAR[request.type] ?? request.type;
// #5927 — the fact the four delete receipts below have to tell the
// truth about, hoisted to method scope because it is read by BOTH
// delete paths (repository and legacy raw-engine) and by `intent`.
//
// It is the SAME fact the repo path already computed inline for
// `intent: 'override-artifact' | 'runtime-only'` — this binding
// replaces that call rather than adding one, so the receipt split
// costs zero new registry reads. (The two-tier authorization block
// above computes it a second time under `request.type`; that one is
// block-scoped to `environmentId !== undefined` and cannot be reused
// here. Both spellings agree: `canonicalizeMetaRequestType` already
// folded `request.type` to singular at the top of this method, which
// makes `singularTypeForRepo` a no-op re-fold — see #4432.)
const artifactBacked = host.isArtifactBacked(singularTypeForRepo, request.name);
// [ADR-0131 D6] For an item a managed package ships the hatch decides
// nothing, the route included: the item takes the route it takes with
// `OS_METADATA_WRITABLE` shut. Read through the hatch, a code-only
// managed item's stored residue was routed onto the repository, which
// refuses it as sealed, away from the raw-engine repair below that
// removes it with the hatch shut — so setting the hatch would have
// blocked a repair the deployment otherwise has.
const overlayAllowedForRepoDel = artifactBacked
? host.registryAllowsOverlay(singularTypeForRepo)
: host.isOverlayAllowed(singularTypeForRepo);
const runtimeCreateAllowedForRepoDel = host.isRuntimeCreateAllowed(singularTypeForRepo);
const useRepoPath = overlayAllowedForRepoDel || runtimeCreateAllowedForRepoDel;
// ADR-0008 — overlay-allowed types route through SysMetadataRepository
// so the delete (a) is wrapped in engine.transaction(), (b) appends a
// tombstone row to sys_metadata_history, and (c) emits a watch event
// with a monotonic `seq` for HMR. Non-overlay-allowed types (only
// reachable in control-plane bootstrap mode where environmentId is
// undefined) take the legacy raw-engine path below — the repository's
// `assertAllowed()` whitelist would 403 those deletes.
if (useRepoPath) {
// [ADR-0131 D6] Environment-wide: refused above otherwise.
const repo = host.getOverlayRepo();
const ref = {
type: singularTypeForRepo,
name: request.name,
org: 'env',
} as Parameters<typeof repo.delete>[0];
// [#23165] The refusal of a package-less delete of an item two or
// more packages hold: the door's own verdict, rethrown as it is by
// the `catch` below, never re-wrapped as a delete that failed.
let addressRefusal: Error | undefined;
// [#23415] The refusal of a table drop another stored row of the
// object still needs: rethrown as it is, as `addressRefusal` is.
let dropRefusal: Error | undefined;
try {
const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
// [#23165, ADR-0048] THE ADDRESS of the row this delete removes.
// It used to be no address at all: `repo.get` and `repo.delete`
// were handed no package, so each took the first row a
// package-agnostic `findOne` met — one package's row of an item
// two packages hold (the other package's row then served, and
// the receipt said "it no longer exists"), or the package's row
// when a package-less row sat beside it.
//
// Named, the delete removes that package's row and no other.
// Naming none, it takes the save door's resolution
// ({@link packagelessAddress}), read from the rows at the state
// it removes: the one package those rows are bound to, the
// package-unbound row when they are bound to none, and a
// refusal when they are bound to two or more. A draft discard
// reads the draft rows, so it removes the draft that exists,
// wherever the draft save put it (#11087).
//
// [#23495] …except that is not where the draft save puts it: a
// draft save naming no package writes the draft of the package
// the item's ACTIVE rows are bound to, so over an item bound to
// `P` whose only draft is another package's, the discard
// removed that draft while the save, the read and the token
// named `P`'s. A draft discard naming no package takes the
// draft address instead ({@link packagelessDraftAddress}), the
// one the per-item publish takes: refused when the item's
// active rows are bound to two or more packages, as the draft
// save is.
//
// Resolved here, below every door that judges the item rather
// than its row (the organization refusal, the packaged-base
// door, the creatable check and the `_lock` gate), as the save
// door orders it. The rows read here are also what the receipt
// below tells the truth about: what is still stored, and so
// still served, once this row is gone.
const draftAddress = request.packageId == null && targetState === 'draft'
? await host.packagelessDraftAddress(singularTypeForRepo, request.name, member)
: undefined;
if (draftAddress?.kind === 'ambiguous') {
addressRefusal = packagelessAddressAmbiguityRefusal(
'delete', request.type, request.name, draftAddress.candidates,
);
throw addressRefusal;
}
const rowsAtAddress = draftAddress?.drafts ?? await host.packagelessAddress(
singularTypeForRepo, request.name, member, targetState,
);
if (request.packageId == null && draftAddress === undefined && rowsAtAddress.candidates.length > 1) {
addressRefusal = packagelessAddressAmbiguityRefusal(
'delete', request.type, request.name, rowsAtAddress.candidates,
);
throw addressRefusal;
}
const packageId: string | null = request.packageId
?? (draftAddress !== undefined ? draftAddress.key : rowsAtAddress.packageId);
// Probe first — "no overlay exists" is a success/no-op, not
// a conflict. The repo would otherwise throw ConflictError.
const current = await repo.get(ref, { state: targetState, packageId, ...memberOption(member) });
// [#21899] An origin-gated code-defined item with NO stored row:
// the delete would remove nothing, and the item itself is not
// removable at runtime — refused with the package door's own
// verdict, the one its save gets. Rethrown as it is by the
// `catch` below.
if (!current && originGatedRefusal !== null) throw originGatedRefusal;
// [#23165] A delete NAMING a package that holds no row here, of
// an item whose other rows are stored: nothing is removed and
// those rows are still served, so neither the registry heal
// below nor its "already at artifact default" / "nothing to
// delete" claim holds. A delete naming none never lands here:
// it addresses a row the read above found.
if (!current && request.packageId != null && (rowsAtAddress.candidates.length > 0 || rowsAtAddress.unbound)) {
return {
success: true,
reset: false,
message: targetState === 'draft'
? `No pending draft of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was discarded: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'draft')}.`
: `No row of ${request.type}/${request.name} is bound to package '${request.packageId}', so nothing was deleted: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'row')}.`,
};
}
// [#23495] …and a draft discard naming no package, of an item
// bound to a package that holds no draft at that package's
// address while other packages' drafts are stored: nothing is
// discarded, and the receipt names what remains rather than
// claiming no draft exists.
if (!current && draftAddress?.kind === 'none' && draftAddress.into !== null && rowsAtAddress.candidates.length > 0) {
return {
success: true,
reset: false,
message: `No pending draft of ${request.type}/${request.name} is bound to package '${draftAddress.into}', the package its published rows are bound to and the draft a discard that names no package removes, so nothing was discarded: ${remainingRowsPhrase(rowsAtAddress.candidates, rowsAtAddress.unbound, 'draft')}.`,
};
}
if (!current) {
// Self-heal: even with no overlay row, a stale runtime
// shadow may linger in the registry (e.g. pollution from
// before this fix shipped) — drop it so the artifact
// view really IS the default we claim below.
//
// [#6780] The organization is passed, and this branch is where it
// matters MOST: with no row to delete, an org that never
// customized anything at all could evict the env-wide
// plain-key entry with a single no-op DELETE. Measured on
// `origin/main`: receipt `{reset: false, "nothing to
// delete"}` and `registry.getItem('view','shared_grid')`
// → `undefined`, the env-wide row untouched in
// `sys_metadata`. A gate applied only to the delete-ful
// branch below would have left this door standing open.
if (targetState === 'active') {
await host.restoreArtifactRegistryView(request.type, request.name, null, member);
}
return {
success: true,
reset: false,
// #5927 — "already at artifact default" presumes an
// artifact default EXISTS to be at. When nothing is
// shipped under this (type, name), the absent overlay
// row is the absence of the whole item, and the miss
// says that instead of naming a baseline that was
// never there. The draft leg claimed neither and is
// unchanged, verbatim.
message: targetState === 'draft'
? `No pending draft for ${request.type}/${request.name}.`
: artifactBacked
? `No customization overlay found for ${request.type}/${request.name} — already at artifact default.`
: `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`,
};
}
// [#23165] What is still stored at this address once this row
// is gone, from the rows the address read already holds. Read
// by the table drop's verdict just below and by the receipt.
const remainingPackages = rowsAtAddress.candidates.filter((id) => id !== packageId);
const unboundRemains = rowsAtAddress.unbound && packageId !== null;
const remains = remainingPackages.length > 0 || unboundRemains;
// [#23415] The table drop, decided ONCE, before anything is
// written: an object's table goes only with its last stored
// row. A `dropStorage` delete that would leave another row of
// the object stored, and served over this table, is refused
// whole — see {@link dropStorageRemainingRowsRefusal}.
const dropsStorage = host.shouldDropStorage(request.type, request.name, request.dropStorage, targetState);
if (dropsStorage && remains) {
dropRefusal = host.dropStorageRemainingRowsRefusal(
singularTypeForRepo, request.name, packageId, remainingPackages, unboundRemains,
);
throw dropRefusal;
}
// Last-write-wins parent resolution unless the caller pinned
// an explicit version (Studio's "Reset" button is unpinned;
// a future "delete vN" flow can pass parentVersion).
// [#21207] A pinned version is a caller's token, compared in
// keyed form against the current stored head
// (`storedParentForToken`); the repository's lock then runs on
// the stored value. A token naming no current head throws a
// `ConflictError`, answered by the conflict branch below.
const parentVersion: string = typeof request.parentVersion === 'string'
? ((await host.storedParentForToken(ref, request.parentVersion, current.hash)) ?? current.hash)
: current.hash;
const result = await repo.delete(ref, {
parentVersion,
// #4556 — NULL, not 'system', for an actor-less delete.
actor: request.actor ?? null,
source: 'protocol.deleteMetaItem',
// #5927 — was an inline `this.isArtifactBacked(...)` call
// with these exact arguments; now reads the method-scoped
// binding the receipts share. Same fact, one call fewer.
// [#28412] The pair's row of a type whose D9 conditions
// are delivered (`flow` only, {@link isPairRowRemoval})
// is removed as the declared save wrote it: runtime-only.
// Every other type keeps the intent main gave it.
intent: artifactBacked && !pairRowRemoval
? 'override-artifact'
: 'runtime-only',
state: targetState,
// [#23165] The row the probe read, by its key — never the
// repository's "any package" match, which takes the first
// row it meets.
packageId,
...memberOption(member),
});
// Heal the registry: drop the overlay's runtime shadow so the
// packaged artifact is visible again (all kernels), and on
// control-plane kernels also refresh from MetadataService —
// see {@link restoreArtifactRegistryView}. Draft discards
// skip this: drafts never hydrate into the registry, and the
// still-active overlay (if any) must keep its shadow.
//
// [#6780] SCOPED by the same organization the row was deleted with,
// so the registry's view cannot disagree with the row's scope
// — the sibling rule the write side states in
// {@link applyRegistryWriteThrough}. Org A resetting ITS OWN
// overlay used to retire the plain-key entry belonging to the
// ENV-WIDE row, i.e. one tenant's "reset to default" blanked
// the item for every other tenant and the control plane.
if (targetState === 'active') {
await host.restoreArtifactRegistryView(request.type, request.name, null, member);
}
// Storage teardown (opt-in): drop the now-orphaned physical table
// for a discarded object so a publish-to-preview leaves no residue.
// [#23415] Decided above; reached only when this was the
// object's last stored row.
if (dropsStorage) {
await host.dropObjectStorage(singularTypeForRepo, request.name);
}
// ADR-0010 — success audit (best-effort).
await host.recordMetadataAudit({
type: request.type,
name: request.name,
organizationId: null,
operation: 'delete',
outcome: 'allowed',
code: 'ok',
...(request.actor ? { actor: request.actor } : {}),
source: 'protocol.deleteMetaItem',
note: targetState,
});
// [ADR-0094] Awaited projection: a delete may retire the
// derived record OR reset it to the artifact baseline — the
// projector re-reads the layered state and decides.
const deleteProjection = await host.runMutationProjector({
type: singularTypeForRepo,
name: request.name,
state: 'deleted',
organizationId: null,
});
host.emitMetadataMutation({
type: singularTypeForRepo,
name: request.name,
state: 'deleted',
organizationId: null,
...mutationEventMember(singularTypeForRepo, member),
});
const deletedPackage = packageId !== null ? ` (package=${packageId})` : '';
return {
success: true,
reset: true,
seq: result.seq,
...(deleteProjection ? { projectionApplied: deleteProjection } : {}),
// #5927 — the same split #5265/PR #5926 made on the save
// side, on the reset path. `artifactBacked` is exactly the
// difference between the two things a delete can be:
//
// • override-artifact — a code-shipped artifact sits
// under this (type, name). Removing the row really
// does lift a customization layer and really does
// leave the packaged default in force; the sentence is
// literally true and is unchanged, byte for byte.
// • runtime-only — nothing is underneath. The row WAS
// the item, and after this delete it does not exist in
// any layer. Telling an admin who just deleted an
// `object`/`flow`/`hook` they created that it was
// "reset to artifact default" points them at a
// baseline that has never existed.
//
// The draft leg discards a pending draft and never claimed
// a reset, so it is unchanged. `[seq=…]` stays on every
// branch — HMR cursors read it.
//
// [#23165] Both active sentences claim what is left once
// this row is gone, and that is true only when no other row
// of the item is stored at this address: a delete of one
// package's row leaves another package's row, or the
// package-less row beside it, stored and served. Then the
// receipt names what remains instead. A delete of a
// package's row names the package, as the save receipt
// does; a delete of the package-unbound row with nothing
// left reads byte for byte as before.
//
// [#23657] The draft discard names the package of the
// draft it removed too, as the draft save's receipt names
// the package it wrote into; a package-unbound draft's
// discard reads byte for byte as before.
message: (request.state === 'draft')
? `Draft discarded${deletedPackage} — ${request.type}/${request.name}. [seq=${result.seq}]`
: artifactBacked
? (remains
? `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} still carries another overlay: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]`
: `Customization overlay deleted${deletedPackage} — ${request.type}/${request.name} reset to artifact default. [seq=${result.seq}]`)
: (remains
? `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage}, and it still exists: ${remainingRowsPhrase(remainingPackages, unboundRemains, 'row')}. [seq=${result.seq}]`
: `Deleted ${singularTypeForRepo} '${request.name}'${deletedPackage} — it no longer exists. [seq=${result.seq}]`),
};
} catch (err: any) {
// [#21899] The door's own verdict, not a store failure: it
// leaves with its envelope and sentence, never re-wrapped as an
// overlay delete that failed.
if (
err === originGatedRefusal
|| (addressRefusal !== undefined && err === addressRefusal)
|| (dropRefusal !== undefined && err === dropRefusal)
) throw err;
if (err instanceof ConflictError) {
// [#21207] Keyed values or none in the text and attributes.
const conflict = await host.metadataConflictRefusal(
err,
`${request.type}/${request.name}`,
`${request.type}/${request.name} has been modified since you loaded it.`,
);
await host.recordOptimisticConflictAudit({
type: request.type,
name: request.name,
operation: 'delete',
...(request.actor ? { actor: request.actor } : {}),
source: 'protocol.deleteMetaItem',
expectedParent: err.expectedParent,
actualHead: err.actualHead,
});
throw conflict;
}
// [#8136] The message quotes `err` only when `err` DECLARED
// itself a caller-facing refusal — see {@link
// declaresClientRefusal}. Every engine touch in the `try` above
// (`repo.get`, `repo.delete`, `restoreArtifactRegistryView`,
// `dropObjectStorage`, `recordMetadataAudit`, the projector)
// can land a bare driver `Error` in this catch, and this exit
// used to interpolate it whole.
const e = new Error(overlayDeleteFailureMessage(err, request.type, request.name));
(e as any).status = err?.status ?? 500;
// The withheld text is not lost, it is relocated: `cause` is
// what `handleRouteError` / `logWithheldServerFault` print, the
// same posture {@link metadataStoreUnavailableError} takes.
(e as any).cause = err;
// [#7426] …and the SAME treatment for `code`, gated on the
// declared vocabulary. This is the exit a control-plane
// kernel's repository refusal leaves by — `NOT_OVERRIDABLE` /
// 403 for a `supportsOverlay: false` type — and until now only
// its `status` made it out. See {@link carryCatalogedErrorCode}
// for why an unconditional copy is the wrong shape here.
carryCatalogedErrorCode(e, err);
// [#12536] …and the same for the mark (commit 79c46da90). A hook that
// refused this delete in its own words wrote that text for the
// END USER; dropping it here destroys the channel exactly the
// way the store-unavailable wrapper used to, one exit over.
// Only the marked field is copied — the sentence selection
// above stays #8136's, unchanged.
carryDeclaredUserMessage(e, err);
throw e;
}
}
// ── Legacy raw-engine path: reachable in control-plane bootstrap
// (`environmentId === undefined`) for a code-only type — one whose
// registry entry sets BOTH `allowEnvironmentOverlay: false` and
// `allowRuntimeCreate: false` (today `job` and `agent`). No history
// row, no watch event — these types don't participate in the
// change-log model, and `SysMetadataRepository.assertAllowed()` would
// 403 the delete outright.
//
// #5264 — THIS ONE IS ALIVE. `saveMetaItem` used to carry a branch of
// the same shape; that one was deleted because #5086 (PR #5263) put an
// unconditional code-only refusal in front of it, on every kernel.
// The delete side was left ungated ON PURPOSE by that same PR, and the
// asymmetry is the point: refusing to CREATE a code-only row is a new
// guarantee, while REMOVING a code-only row that predates the refusal
// is the repair action the guarantee depends on. Note where the gate
// above stops — the two-tier delete authorization runs only when
// `environmentId !== undefined`, so on a control-plane kernel a
// code-only delete arrives here with `useRepoPath === false` and this
// is the only code that can serve it.
//
// So: do not "clean up the symmetry" by deleting this the way #5264
// deleted its twin, and do not gate it to match `saveMetaItem` —
// either would strand the very rows #5263 made unwritable. If it ever
// does become unreachable, the proof has to come from the delete
// side's own gates, not from the save side's.
const scopedWhere: Record<string, unknown> = {
type: request.type,
name: request.name,
};
try {
// [#21908, ADR-0096 D5] The explicit system opt-in on the read and
// the delete: a platform store row the delete door already
// authorized the caller to remove, addressed by the protocol's own
// `scopedWhere` and then by id. Principal-less, the engine refuses
// both.
const existing = await host.findEnvironmentRow('sys_metadata', { where: scopedWhere, context: { isSystem: true } });
if (!existing) {
return {
success: true,
reset: false,
// #5927 — same split as the repository path's miss above.
message: artifactBacked
? `No customization overlay found for ${request.type}/${request.name} — already at artifact default.`
: `No ${singularTypeForRepo} '${request.name}' found — nothing to delete.`,
};
}
await host.engine.delete('sys_metadata', { where: { id: existing.id }, context: { isSystem: true } });
// Storage teardown (opt-in) — see the repo-path branch above.
{
const targetState: 'active' | 'draft' = request.state === 'draft' ? 'draft' : 'active';
if (host.shouldDropStorage(request.type, request.name, request.dropStorage, targetState)) {
await host.dropObjectStorage(PLURAL_TO_SINGULAR[request.type] ?? request.type, request.name);
}
}
// [#6780] The legacy path deletes under the SAME org predicate it
// built into `scopedWhere` above, so the heal reads its scope from
// the same place. Reachable only on a control-plane kernel for a
// code-only type, which is exactly the kernel whose registry every
// org shares — the narrowest path and the widest blast radius.
if (request.state !== 'draft') {
await host.restoreArtifactRegistryView(
request.type,