|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * [#25467 cut 13] Two packaged-base door verdicts of |
| 5 | + * `ObjectStackProtocolImplementation` — the package door's refusal of a write |
| 6 | + * onto an item a code package ships (`refusePackagedBaseOverride`) and the |
| 7 | + * removal verdict on a code-defined item of an origin-gated type |
| 8 | + * (`originGatedRemovalRefusal`) — moved out of `protocol.ts` whole. |
| 9 | + * |
| 10 | + * A MOVE, not a redesign. Each function below is the body of the |
| 11 | + * `ObjectStackProtocolImplementation` method of the same name as it stood, |
| 12 | + * re-indented one level and otherwise unedited: the same predicates, in the |
| 13 | + * same order, refusing with the same emitters, codes, statuses and sentences. |
| 14 | + * |
| 15 | + * ## How the moved bodies reach the class |
| 16 | + * |
| 17 | + * The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts` |
| 18 | + * header carries the full reasoning), as `protocol-batch-data.ts` applies it. |
| 19 | + * The class keeps every method — the published `dist/*.d.ts` lists each one's |
| 20 | + * name, docblock and declared signature — and each body is one literal of |
| 21 | + * exactly the {@link PackagedBaseRefusalHost} members that method reads, plus |
| 22 | + * a `.call`. A method is handed over as a closure that calls it on the |
| 23 | + * protocol at call time, so a test double or spy set on the instance is still |
| 24 | + * the one the body reaches. Every other reader of these two methods keeps |
| 25 | + * calling the class method. |
| 26 | + * |
| 27 | + * The removal door's own refusal, `refusePackagedBaseRemoval`, stays in the |
| 28 | + * class: it reads the private static `mergesOverlayAtRead`, and the class's |
| 29 | + * private-static readers stay there. So does `packagedBaseRefusal`, the public |
| 30 | + * verdict both doors' refusals answer through, which reads the private static |
| 31 | + * `isPackagedBaseRefusal`. |
| 32 | + * |
| 33 | + * The bodies read no module-level declaration of `protocol.ts`; what they |
| 34 | + * import, they import from where `protocol.ts` imported it. Nothing |
| 35 | + * re-exports this module, so the published surface does not move. |
| 36 | + */ |
| 37 | + |
| 38 | +import type { ObjectStackProtocolImplementation } from './protocol.js'; |
| 39 | +import { isOriginGatedType, managedItemSealedSentence } from './packaged-base-regime.js'; |
| 40 | +import { SysMetadataRepository } from './sys-metadata-repository.js'; |
| 41 | + |
| 42 | +/** |
| 43 | + * The `ObjectStackProtocolImplementation` members the functions below read — |
| 44 | + * every one of them, and no other; each function's `this` is the `Pick` of the |
| 45 | + * members IT reads. Each is typed AS the class member (an indexed access on |
| 46 | + * the class), so a change to a member's signature on the class is a change |
| 47 | + * here too, and naming a member the class does not have is a compile error. |
| 48 | + */ |
| 49 | +export interface PackagedBaseRefusalHost { |
| 50 | + isSealedManagedItem: ObjectStackProtocolImplementation['isSealedManagedItem']; |
| 51 | + isWritablePackage: ObjectStackProtocolImplementation['isWritablePackage']; |
| 52 | + packagedBaseRefusal: ObjectStackProtocolImplementation['packagedBaseRefusal']; |
| 53 | +} |
| 54 | + |
| 55 | +/** The body of `ObjectStackProtocolImplementation#refusePackagedBaseOverride`; the method's docblock there is its contract. */ |
| 56 | +export function refusePackagedBaseOverride( |
| 57 | + this: Pick<PackagedBaseRefusalHost, 'isSealedManagedItem' | 'isWritablePackage'>, |
| 58 | + request: { type: string; name: string; packageId?: string | null }, |
| 59 | +): void { |
| 60 | + if (this.isSealedManagedItem(request.type, request.name)) { |
| 61 | + // [#8184] THE PACKAGE DOOR — the SECOND refusal point for one |
| 62 | + // condition, and the reason this card exists. |
| 63 | + // |
| 64 | + // `SysMetadataRepository.assertAllowed` reads the base the |
| 65 | + // caller NAMED and answers `ITEM_LOCKED` (`lockSource: |
| 66 | + // 'package'`) when it is read-only (#7682, then #8146's |
| 67 | + // hatch ruling). That door is topology-INDEPENDENT — and it |
| 68 | + // was unreachable here, because this branch throws first on |
| 69 | + // every kernel with an `environmentId`. So one request |
| 70 | + // answered `ITEM_LOCKED` on a host-config / CLI-assembled |
| 71 | + // kernel and the undiscriminated `NOT_OVERRIDABLE` on a |
| 72 | + // project/cloud per-env one: the refusal VOCABULARY keyed off |
| 73 | + // a row-scoping key, which is the #5086 / #6710 finding |
| 74 | + // (see the block comment above) arriving on the error codes. |
| 75 | + // A client that learns to handle `ITEM_LOCKED` on one |
| 76 | + // deployment never saw it on the other. |
| 77 | + // |
| 78 | + // ⚠️ MIRRORED, NOT RE-INVENTED. Same predicate |
| 79 | + // ({@link isWritablePackage}, the ADR-0070 rule in one |
| 80 | + // place), same emitter — `readOnlyBaseOverrideError` is |
| 81 | + // called, not copied — so the code, the status, the |
| 82 | + // `lockSource`, the `packageId` and the sentence cannot drift |
| 83 | + // between the two doors. Two independently-authored refusals |
| 84 | + // for one condition is how `NOT_OVERRIDABLE`-everywhere |
| 85 | + // started. |
| 86 | + // |
| 87 | + // THE LIMB ORDERING IS THE RULE, and it is the same ordering |
| 88 | + // the repository states: BELOW every registry limb, and the |
| 89 | + // hatch is not a limb at all. |
| 90 | + // • Below the registry limb — this whole branch is guarded |
| 91 | + // by {@link isSealedManagedItem}, whose type half is the |
| 92 | + // registry's `allowEnvironmentOverlay` alone, so a regime-O type |
| 93 | + // never reaches the door. That is ADR-0005: an overlay of |
| 94 | + // a code-shipped item ALWAYS names the read-only package |
| 95 | + // it customizes, and a door one limb higher would close |
| 96 | + // the overlay model outright. Pinned. |
| 97 | + // • [ADR-0131 D6] No hatch limb. `OS_METADATA_WRITABLE` used |
| 98 | + // to take an artifact-backed write past this branch, down |
| 99 | + // to the repository door, where a package-less write landed |
| 100 | + // an overlay of the managed item (#8146 had narrowed that to |
| 101 | + // "never a NAMED read-only base"). Managed content is sealed |
| 102 | + // now — the broad reading that comment reserved for a |
| 103 | + // maintainer decision is the decision ADR-0131 D6 records — |
| 104 | + // so the hatch is not consulted here, and the repository |
| 105 | + // refuses the same write without it. |
| 106 | + // |
| 107 | + // The named-base limb below keeps its own code: a write that |
| 108 | + // NAMES the read-only base answers `ITEM_LOCKED`, and a |
| 109 | + // package-less one `NOT_OVERRIDABLE`, with or without the hatch. |
| 110 | + // |
| 111 | + // `runtime-only` needs no limb here: this branch is guarded by |
| 112 | + // `artifactBacked`, so the intent is always |
| 113 | + // `override-artifact`. The create side of the door is the |
| 114 | + // ADR-0070 D1 gate further down this method, which is already |
| 115 | + // topology-independent and already answers |
| 116 | + // `WRITABLE_PACKAGE_REQUIRED` / 422 on every kernel. |
| 117 | + const namedBase = typeof request.packageId === 'string' && request.packageId.length > 0; |
| 118 | + if (namedBase && !this.isWritablePackage(request.packageId)) { |
| 119 | + throw SysMetadataRepository.readOnlyBaseOverrideError( |
| 120 | + request.type, request.packageId as string, |
| 121 | + ); |
| 122 | + } |
| 123 | + // [#20819, ADR-0131 D6] The SENTENCE is chosen per ADR-0126 regime, |
| 124 | + // and names the managed package for every other type; the code and |
| 125 | + // the status are unchanged. [#23577] Every write reads it, whatever |
| 126 | + // the write meant to do: the door cannot tell a create from an edit. |
| 127 | + const err = new Error(managedItemSealedSentence(request.type, request.name, 'save')); |
| 128 | + (err as any).code = 'NOT_OVERRIDABLE'; |
| 129 | + (err as any).status = 403; |
| 130 | + throw err; |
| 131 | + } |
| 132 | +} |
| 133 | + |
| 134 | +/** The body of `ObjectStackProtocolImplementation#originGatedRemovalRefusal`; the method's docblock there is its contract. */ |
| 135 | +export function originGatedRemovalRefusal( |
| 136 | + this: Pick<PackagedBaseRefusalHost, 'packagedBaseRefusal'>, |
| 137 | + type: string, name: string): Error | null { |
| 138 | + if (!isOriginGatedType(type)) return null; |
| 139 | + return this.packagedBaseRefusal({ type, name, operation: 'delete' }); |
| 140 | +} |
0 commit comments