@objectstack/core 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,12 +1,11 @@
1
1
  import { Logger, LifecycleEventName, IServiceRegistry, AudienceBindingSuggestionFilter, IObjectQLEngine } from '@objectstack/spec/contracts';
2
2
  export { EngineSchemaRegistryView, EngineTransactionInfo, EngineTransactionOptions, HttpResponseObservation, HttpResponseObserver, IDataDriver, IDataEngine, IHttpRequest, IHttpResponse, IHttpServer, IObjectQLEngine, Logger, Middleware, RouteHandler, UNMATCHED_ROUTE_PATTERN } from '@objectstack/spec/contracts';
3
- import { z } from 'zod';
4
3
  import { LoggerConfig, MigrationOnCrashPolicy, MigrationJournalEvent } from '@objectstack/spec/system';
4
+ import { PluginDefinition, CORE_PLUGIN_TYPES, PluginCapability, PluginPermissions as PluginPermissions$1, PluginPermissionSet, ResourceType, PermissionAction, PluginPermission, SandboxConfig, ExecutionContext, PluginHealthCheckParsed, PluginHealthStatus as PluginHealthStatus$1, PluginHealthReport, HotReloadConfigParsed, VersionConstraint, DependencyConflict, SemanticVersion, CompatibilityLevel } from '@objectstack/spec/kernel';
5
5
  import { ObjectLogger } from './logger.js';
6
6
  export { createLogger } from './logger.js';
7
7
  import * as QA from '@objectstack/spec/qa';
8
8
  import { KeyObject } from 'node:crypto';
9
- import { PluginCapability, PluginPermissions as PluginPermissions$1, PluginPermissionSet, ResourceType, PermissionAction, PluginPermission, SandboxConfig, KernelSecurityScanResult, KernelSecurityVulnerability, ExecutionContext, PluginHealthCheckParsed, PluginHealthStatus as PluginHealthStatus$1, PluginHealthReport, HotReloadConfigParsed, VersionConstraint, DependencyConflict, SemanticVersion, CompatibilityLevel } from '@objectstack/spec/kernel';
10
9
  import { TenancyPosture, AuthzPosture } from '@objectstack/spec/security';
11
10
  export { nextUtcCalendarDay, utcInstantMs } from '@objectstack/spec/data';
12
11
 
@@ -42,16 +41,12 @@ interface ServiceRegistration {
42
41
  interface PluginMetadata extends Plugin {
43
42
  /** Semantic version (e.g., "1.0.0") */
44
43
  version: string;
45
- /** Configuration schema for validation */
46
- configSchema?: z.ZodSchema;
47
44
  /** Plugin signature for security verification */
48
45
  signature?: string;
49
46
  /** Plugin health check function */
50
47
  healthCheck?(): Promise<PluginHealthStatus>;
51
48
  /** Startup timeout in milliseconds (default: 30000) */
52
49
  startupTimeout?: number;
53
- /** Whether plugin supports hot reload */
54
- hotReloadable?: boolean;
55
50
  }
56
51
  /**
57
52
  * Plugin Health Status
@@ -77,6 +72,27 @@ interface PluginLoadResult {
77
72
  interface PluginStartupResult {
78
73
  success: boolean;
79
74
  pluginName: string;
75
+ /**
76
+ * Elapsed milliseconds the plugin's `start()` took.
77
+ *
78
+ * Named for the member `packages/spec` declares for the same measure --
79
+ * `PluginStartupResultSchema.durationMs` in
80
+ * `packages/spec/src/kernel/startup-orchestrator.zod.ts` ("Time taken to
81
+ * start the plugin in milliseconds"), where the bare `duration` spelling is
82
+ * retired: a duration-shaped number carries its unit in its key name. Like
83
+ * `PluginLoadResult.loadTime` above, it is the same `Date.now() - startTime`
84
+ * computation under a name that does not lie.
85
+ */
86
+ durationMs?: number;
87
+ /**
88
+ * The same elapsed milliseconds as {@link PluginStartupResult.durationMs}.
89
+ *
90
+ * @deprecated Misnamed: this has never held an instant, so a reader who
91
+ * correctly takes `startTime` for one and writes `Date.now() - result.startTime`
92
+ * gets an age near the epoch instead of a wait. Read `durationMs` instead.
93
+ * Still populated so nothing has to change on this release (ADR-0087 L1 --
94
+ * the old shape keeps working while the fleet moves); slated for removal.
95
+ */
80
96
  startTime?: number;
81
97
  error?: Error;
82
98
  timedOut?: boolean;
@@ -97,7 +113,6 @@ interface VersionCompatibility {
97
113
  declare class PluginLoader {
98
114
  private logger;
99
115
  private context?;
100
- private configValidator;
101
116
  private loadedPlugins;
102
117
  private serviceFactories;
103
118
  private serviceInstances;
@@ -158,9 +173,27 @@ declare class PluginLoader {
158
173
  getLoadedPlugins(): Map<string, PluginMetadata>;
159
174
  private toPluginMetadata;
160
175
  private validatePluginStructure;
176
+ /**
177
+ * Refuse a plugin object the DECLARED plugin contract refuses (#16049,
178
+ * maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime
179
+ * aligns to it").
180
+ *
181
+ * The check itself — `PluginSchema.safeParse` for validation only, the
182
+ * eight keys it reaches, the `version` exclusion and the
183
+ * `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`,
184
+ * because since #16721 it is ONE statement run by BOTH kernels:
185
+ * `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches
186
+ * it here, through `loadPlugin`. That module's comment is the authority on
187
+ * what is refused; this method adds nothing to it and subtracts nothing.
188
+ *
189
+ * What stays THIS loader's own, and is deliberately not shared: the
190
+ * structural checks one call up ({@link validatePluginStructure} —
191
+ * `name`, `init`, semver) and the version-compatibility check below.
192
+ * The convergence is on the schema, not on the loader.
193
+ */
194
+ private validatePluginContract;
161
195
  private checkVersionCompatibility;
162
196
  private isValidSemanticVersion;
163
- private validatePluginConfig;
164
197
  private verifyPluginSignature;
165
198
  private getSingletonService;
166
199
  private createTransientService;
@@ -211,7 +244,12 @@ declare class ObjectKernel {
211
244
  private pluginLoader;
212
245
  private config;
213
246
  private startedPlugins;
214
- private pluginStartTimes;
247
+ /**
248
+ * Plugin name -> elapsed milliseconds that plugin's `start()` took. These
249
+ * are DURATIONS, never start instants; the old spelling `pluginStartTimes`
250
+ * said the opposite of what it held.
251
+ */
252
+ private pluginStartupDurations;
215
253
  private shutdownHandlers;
216
254
  /**
217
255
  * Name of the plugin whose init() is currently executing (Phase 1 is
@@ -251,6 +289,25 @@ declare class ObjectKernel {
251
289
  * Validate Critical System Requirements
252
290
  */
253
291
  private validateSystemRequirements;
292
+ /**
293
+ * Publish this boot's degraded-capabilities conclusion on
294
+ * {@link DEGRADED_CAPABILITIES_SERVICE} — the data half of the warning
295
+ * `validateSystemRequirements()` just logged (#16630).
296
+ *
297
+ * ⛔ Best-effort, and silent on failure BY DESIGN: this is a diagnostic
298
+ * readout, and a readout must never be able to fail a boot that the kernel
299
+ * has just decided is good enough to run. The one way `registerService`
300
+ * can throw here is a name collision, which the guard above already
301
+ * forecloses; the `catch` is there so that stays true if either ever
302
+ * changes. (`recordSeedOutcome` in `@objectstack/runtime` states the same
303
+ * rule for the same reason.)
304
+ *
305
+ * The value is FROZEN and holds a COPY. `getService` hands out the stored
306
+ * reference, so an unfrozen live array would let any reader edit the
307
+ * kernel's own record of what was missing — and this record exists
308
+ * precisely so that two packages cannot disagree about it.
309
+ */
310
+ private publishDegradedCapabilities;
254
311
  /**
255
312
  * Bootstrap the kernel with enhanced features
256
313
  */
@@ -267,8 +324,18 @@ declare class ObjectKernel {
267
324
  * Check health of all plugins
268
325
  */
269
326
  checkAllPluginsHealth(): Promise<Map<string, any>>;
327
+ /**
328
+ * Per-plugin startup durations: plugin name -> elapsed milliseconds that
329
+ * plugin's `start()` took. Not start instants -- see
330
+ * {@link PluginStartupResult.durationMs}.
331
+ */
332
+ getPluginStartupDurations(): Map<string, number>;
270
333
  /**
271
334
  * Get plugin startup metrics
335
+ *
336
+ * @deprecated Renamed to {@link ObjectKernel.getPluginStartupDurations},
337
+ * which states what the values are. Retained as a delegating alias so
338
+ * nothing has to change on this release; slated for removal.
272
339
  */
273
340
  getPluginMetrics(): Map<string, number>;
274
341
  /**
@@ -466,25 +533,70 @@ interface PluginContext {
466
533
  */
467
534
  getKernel(): ObjectKernel;
468
535
  }
536
+ /**
537
+ * The closed set of plugin types (#13925): `'standard'` plus the seven
538
+ * `CORE_PLUGIN_TYPES` members, in exactly the shape `PluginSchema.type`
539
+ * declares in `@objectstack/spec` (`kernel/plugin.zod.ts`:
540
+ * `z.enum(['standard', ...CORE_PLUGIN_TYPES])`). Derived from the spec's own
541
+ * constant rather than re-spelled here, so the compiler's accept set and the
542
+ * Zod gate's cannot drift apart: `plugin-type-closed-set.test.ts` pins the
543
+ * parity at runtime, and `packages/rest`'s `plugin-type-closed-set.pin.test.ts`
544
+ * pins the published `.d.ts` at compile time.
545
+ */
546
+ type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number];
469
547
  /**
470
548
  * Plugin Interface
471
549
  *
472
550
  * All ObjectStack plugins must implement this interface.
473
- */
474
- interface Plugin {
551
+ *
552
+ * ## Two halves, one contract (#16334)
553
+ *
554
+ * **The metadata half is inherited, not restated.** Every key `PluginSchema`
555
+ * declares (`@objectstack/spec`, `kernel/plugin.zod.ts`) — `id`, `type`,
556
+ * `staticPath`, `slug`, `default`, `version`, `description`, `author`,
557
+ * `homepage` — arrives here through `PluginDefinition`
558
+ * (`z.input<typeof PluginSchema>`), so the keys the compiler accepts on a
559
+ * plugin object and the keys `kernel.use()` validates
560
+ * (`PluginLoader.validatePluginContract`, #16049) are ONE declaration. Before
561
+ * this the interface spelled `type` and `version` itself and declared neither
562
+ * `staticPath` nor `slug`, so an in-repo `ui` plugin could not carry the two
563
+ * keys the schema requires of it without widening its own type — two shapes
564
+ * for one contract, free to drift.
565
+ *
566
+ * **The runtime half is declared here and only here**: `name`, the ADR-0116
567
+ * ordering declarations, and the `init` / `start` / `destroy` lifecycle. The
568
+ * spec's schema describes what a plugin OBJECT may say about itself, never
569
+ * what it does.
570
+ *
571
+ * ### `type`
572
+ *
573
+ * The inherited `type` is a {@link PluginType} — the closed set the spec
574
+ * declares (`'standard'` plus `CORE_PLUGIN_TYPES`); `packages/rest`'s
575
+ * `plugin-type-closed-set.pin.test.ts` pins that the inherited key and the
576
+ * exported alias are the same union. Absent means `'standard'` at the schema
577
+ * (`.default('standard')`), and the loader never writes that default back
578
+ * onto the object. A value outside the set no longer type-checks, and since
579
+ * #16049 `kernel.use()` REFUSES it at boot — `assertPluginContract`
580
+ * (`plugin-contract.ts`, run by BOTH `ObjectKernel.use()` and `LiteKernel.use()`
581
+ * since #16721) runs `PluginSchema` over every plugin object and raises
582
+ * `PLUGIN_CONTRACT_VIOLATION` naming the plugin and the first violated key.
583
+ * `type: 'ui'` additionally owes `staticPath` and `slug` (#16334,
584
+ * `PLUGIN_UI_REQUIRED_KEY_MISSING`), refused on the same path.
585
+ *
586
+ * ⚠️ This comment used to say a bad `type` was refused "at parse". It was
587
+ * measured false (#16049, from #15638): `PluginSchema` had no runtime caller,
588
+ * kernel plugin objects were never parsed, and a `type` outside the set was
589
+ * accepted and stored verbatim. The refusal described here is the one that
590
+ * now exists, on the boot path, and the compiler's arm is the second half
591
+ * rather than the only one — `kernel.use(plugin as any)` is a shipped
592
+ * in-repo pattern, and externally authored plugins never meet this compiler
593
+ * at all.
594
+ */
595
+ interface Plugin extends PluginDefinition {
475
596
  /**
476
597
  * Unique plugin name (e.g., 'com.objectstack.engine.objectql')
477
598
  */
478
599
  name: string;
479
- /**
480
- * Plugin version
481
- */
482
- version?: string;
483
- /**
484
- * Plugin type (standard, ui, driver, server, app, theme, agent)
485
- * @default 'standard'
486
- */
487
- type?: string;
488
600
  /**
489
601
  * List of other plugin names that this plugin depends on.
490
602
  * The kernel ensures these plugins are initialized before this one.
@@ -799,6 +911,41 @@ declare function describeInitOrderFault(currentlyInitializing: string | undefine
799
911
  */
800
912
  declare function assertInitServiceRequirements(plugin: OrderablePlugin, isServiceRegistered: (name: string) => boolean): void;
801
913
 
914
+ /**
915
+ * Refusals raised by {@link resolveArtifactPackageOrder}, as ADR-0112 envelopes
916
+ * (`code` + `status`) — the shape this repository's rejection tests assert
917
+ * against, never a bare throw.
918
+ */
919
+ type ArtifactPackageError = Error & {
920
+ code: string;
921
+ status: number;
922
+ };
923
+ /**
924
+ * The id one artifact package is keyed by.
925
+ *
926
+ * `||`, not `??`, on purpose: `ObjectQL.registerApp` keys the installed package
927
+ * on `manifest.id || manifest.name`, so an empty-string `id` falls back to
928
+ * `name` there. Every seam that has to name a package — this module's ordering
929
+ * map, and the install gate's co-ownership set (ADR-0130 D1) — reads the id
930
+ * through THIS function, so none of them can order or admit a package under a
931
+ * key the registry never stores it by.
932
+ *
933
+ * @returns The package id, or `undefined` when the manifest carries neither a
934
+ * usable `id` nor a usable `name`.
935
+ */
936
+ declare function artifactPackageId(manifest: unknown): string | undefined;
937
+ /**
938
+ * Resolve an artifact into the manifests to register, in dependency-topological
939
+ * order (ADR-0130 D4 + D5).
940
+ *
941
+ * @param artifact - A release artifact (`{ packages: [...] }`), or a bare
942
+ * manifest / single-`manifest` artifact — both shapes are read.
943
+ * @returns The manifest bodies to register, in the order to register them.
944
+ * @throws An ADR-0112 envelope (`code` + `status: 422`) for a malformed entry or
945
+ * a duplicate package id, and `resolvePluginOrder`'s own error for a cycle.
946
+ */
947
+ declare function resolveArtifactPackageOrder(artifact: unknown): unknown[];
948
+
802
949
  /**
803
950
  * ObjectKernel - MiniKernel Architecture
804
951
  *
@@ -822,6 +969,26 @@ declare class LiteKernel extends ObjectKernelBase {
822
969
  * Register a plugin
823
970
  * @param plugin - Plugin instance
824
971
  *
972
+ * A plugin object the DECLARED plugin contract refuses is refused here,
973
+ * with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope,
974
+ * that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts`
975
+ * is the one statement both kernels call; #16721, maintainer ruling
976
+ * 2026-09-08, option A under #9864's precedent that the kernels converge).
977
+ *
978
+ * This method used to write the object straight into the registry, so the
979
+ * same plugin was accepted by this kernel and refused by `ObjectKernel` —
980
+ * and `AGENTS.md` names THIS kernel for tests, so a plugin could be green
981
+ * in vitest and refused at production boot. Measured before converging
982
+ * (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this
983
+ * repository's suites, 807 were accepted by the schema unchanged and the
984
+ * six refusals came from three test-local fixture objects, none of them
985
+ * product code.
986
+ *
987
+ * Ordering, and why it is pinned: state first (`validateIdle`), then the
988
+ * contract, then registration — a refused plugin never reaches the
989
+ * registry, so it can neither be booted nor supersede an earlier
990
+ * registration under its name.
991
+ *
825
992
  * Duplicate names OVERWRITE, with one `warn` naming both versions — the
826
993
  * declared contract in `plugin-registration.ts`, applied identically by
827
994
  * `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19).
@@ -862,6 +1029,100 @@ declare class LiteKernel extends ObjectKernelBase {
862
1029
  isRunning(): boolean;
863
1030
  }
864
1031
 
1032
+ /**
1033
+ * [#13905] The discriminator that tells **"nothing ever registered this
1034
+ * service"** apart from **"the service IS registered and could not be built"**
1035
+ * on the ASYNCHRONOUS resolution path.
1036
+ *
1037
+ * ## The fault
1038
+ *
1039
+ * `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered
1040
+ * both facts with the same bare `Error`. A caller that holds only the rejection
1041
+ * therefore could not tell an UNWIRED embedder from a BROKEN one, and the only
1042
+ * thing separating them was message text.
1043
+ *
1044
+ * That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch
1045
+ * absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is
1046
+ * wired". It must keep doing so — a kernel with no data plane is a SUPPORTED
1047
+ * configuration (`rest-api-plugin.ts` declares
1048
+ * `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a
1049
+ * multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver
1050
+ * as "no engine is wired", degrading silently where it should have refused
1051
+ * loudly. The branch could not be repaired from the outside, because the fact
1052
+ * it needed had been collapsed before it arrived.
1053
+ *
1054
+ * ## Why a brand, and ⛔ not message text
1055
+ *
1056
+ * The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and
1057
+ * the comment there records what happened the last time someone read the fact
1058
+ * off the wrong surface: reading "not found" off the async path "reported every
1059
+ * missing service as `is async - use await` — the wrong fix, pointing at the
1060
+ * wrong layer". A second text classifier on a resolution path is the failure
1061
+ * mode this module removes, ⛔ not a repair of it.
1062
+ *
1063
+ * The sync side decides from the REGISTRY — synchronous and authoritative — and
1064
+ * raises two different messages. The async side now carries that same
1065
+ * distinction as a branded, `code`-bearing rejection: one fact, spelled for a
1066
+ * caller that only ever sees the rejection.
1067
+ *
1068
+ * ## The test is CLOSED, and its default is LOUD
1069
+ *
1070
+ * Exactly one throw in `PluginLoader.getService` means "never registered", and
1071
+ * it is the one branded here. Every other way that method can reject — a
1072
+ * factory that threw, a missing scope id, an unset loader context, a circular
1073
+ * service dependency — is a service that IS registered and could not be
1074
+ * produced, and stays unbranded. So `false` is the safe answer: a consumer that
1075
+ * absorbs only the branded rejection stays loud about everything else,
1076
+ * including rejections added to that method later.
1077
+ *
1078
+ * ## Two deliberate omissions
1079
+ *
1080
+ * - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but
1081
+ * the whole point of this discriminator is that the CONSUMER decides what an
1082
+ * unwired service means — absorb and degrade (the supported no-data-plane
1083
+ * kernel) or refuse. Carrying an HTTP status here would presuppose that
1084
+ * decision at the layer that must not make it.
1085
+ * - **No `name` override.** The rejection stays `name: 'Error'` with a
1086
+ * byte-identical message, so `String(err)`, logs and existing assertions
1087
+ * render exactly as before. The only observable change is two added
1088
+ * own-properties.
1089
+ *
1090
+ * Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed
1091
+ * own property rather than `instanceof`, so the predicate still answers
1092
+ * correctly when two copies of `@objectstack/core` are installed (a duplicated
1093
+ * module makes `instanceof` say "no" to an error it built itself).
1094
+ *
1095
+ * ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on
1096
+ * it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`,
1097
+ * `stack` and `cause` and DROPS every other own property, brand and `code`
1098
+ * alike. This discriminator is for an in-process rejection travelling from
1099
+ * `PluginLoader.getService` to a seam that catches it, which is the only path
1100
+ * it is used on.
1101
+ */
1102
+ /**
1103
+ * The code carried by the "never registered" rejection.
1104
+ *
1105
+ * ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value
1106
+ * is read in-process by the seam that catches the rejection and is never
1107
+ * serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts`
1108
+ * classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the
1109
+ * same class as the migration-journal runner refusals. If a transport ever
1110
+ * needs to ANSWER with this fact, that is a registration question for #8846's
1111
+ * ledger, ⛔ not something to start doing at a door.
1112
+ */
1113
+ declare const SERVICE_NOT_REGISTERED_CODE = "SERVICE_NOT_REGISTERED";
1114
+ /**
1115
+ * True when `err` is the rejection meaning **nothing was ever registered under
1116
+ * that service name** — never when a registered service failed to construct.
1117
+ *
1118
+ * The predicate a seam uses to keep absorbing the supported "no data plane"
1119
+ * composition while staying loud about a service that IS wired and broke.
1120
+ */
1121
+ declare function isServiceNotRegisteredError(err: unknown): err is Error & {
1122
+ readonly code: typeof SERVICE_NOT_REGISTERED_CODE;
1123
+ readonly serviceName: string;
1124
+ };
1125
+
865
1126
  /**
866
1127
  * Interface for executing test actions against a target system.
867
1128
  * The target could be a local Kernel instance or a remote API.
@@ -1193,82 +1454,45 @@ declare function verifyPluginArtifact(input: {
1193
1454
  requirePlatform?: boolean;
1194
1455
  }): Promise<PluginArtifactVerifyResult>;
1195
1456
 
1196
- /**
1197
- * Plugin Configuration Validator
1198
- *
1199
- * Validates plugin configurations against Zod schemas to ensure:
1200
- * 1. Type safety - all config values have correct types
1201
- * 2. Business rules - values meet constraints (min/max, regex, etc.)
1202
- * 3. Required fields - all mandatory configuration is provided
1203
- * 4. Default values - missing optional fields get defaults
1204
- *
1205
- * Architecture:
1206
- * - Uses Zod for runtime validation
1207
- * - Provides detailed error messages with field paths
1208
- * - Supports nested configuration objects
1209
- * - Allows partial validation for incremental updates
1210
- *
1211
- * Usage:
1212
- * ```typescript
1213
- * const validator = new PluginConfigValidator(logger);
1214
- * const validConfig = validator.validatePluginConfig(plugin, userConfig);
1215
- * ```
1216
- */
1217
- declare class PluginConfigValidator {
1218
- private logger;
1219
- constructor(logger: Logger);
1220
- /**
1221
- * Validate plugin configuration against its Zod schema
1222
- *
1223
- * @param plugin - Plugin metadata with configSchema
1224
- * @param config - User-provided configuration
1225
- * @returns Validated and typed configuration
1226
- * @throws Error with detailed validation errors
1227
- */
1228
- validatePluginConfig<T = any>(plugin: PluginMetadata, config: any): T;
1229
- /**
1230
- * Validate partial configuration (for incremental updates)
1231
- *
1232
- * @param plugin - Plugin metadata
1233
- * @param partialConfig - Partial configuration to validate
1234
- * @returns Validated partial configuration
1235
- */
1236
- validatePartialConfig<T = any>(plugin: PluginMetadata, partialConfig: any): Partial<T>;
1237
- /**
1238
- * Get default configuration from schema
1239
- *
1240
- * @param plugin - Plugin metadata
1241
- * @returns Default configuration object
1242
- */
1243
- getDefaultConfig<T = any>(plugin: PluginMetadata): T | undefined;
1244
- /**
1245
- * Check if configuration is valid without throwing
1246
- *
1247
- * @param plugin - Plugin metadata
1248
- * @param config - Configuration to check
1249
- * @returns True if valid, false otherwise
1250
- */
1251
- isConfigValid(plugin: PluginMetadata, config: any): boolean;
1252
- /**
1253
- * Get configuration errors without throwing
1254
- *
1255
- * @param plugin - Plugin metadata
1256
- * @param config - Configuration to check
1257
- * @returns Array of validation errors, or empty array if valid
1258
- */
1259
- getConfigErrors(plugin: PluginMetadata, config: any): Array<{
1260
- path: string;
1261
- message: string;
1262
- }>;
1263
- private formatZodErrors;
1457
+ /** A single unpacked artifact file. `path` is POSIX, archive-relative. */
1458
+ interface IntegrityFile {
1459
+ path: string;
1460
+ data: Uint8Array;
1461
+ }
1462
+ type IntegrityViolationKind = 'digest_mismatch' | 'missing_file' | 'extra_file';
1463
+ /** One structured integrity finding (the rejection envelope's unit). */
1464
+ interface IntegrityViolation {
1465
+ kind: IntegrityViolationKind;
1466
+ /** Artifact-relative POSIX path the finding is about. */
1467
+ path: string;
1468
+ /** The digest the manifest declares (absent for `extra_file`). */
1469
+ declared?: string;
1470
+ /** The digest computed from the supplied bytes (absent unless comparable). */
1471
+ actual?: string;
1472
+ }
1473
+ interface VerifyIntegrityResult {
1474
+ /** Overall verdict: every declared digest matched and no file was unaccounted for. */
1475
+ ok: boolean;
1476
+ /** True when no integrity map was supplied, so nothing was checked (still `ok`). */
1477
+ skipped: boolean;
1478
+ /** Number of declared entries whose digests were computed and compared. */
1479
+ checked: number;
1480
+ violations: IntegrityViolation[];
1264
1481
  }
1265
1482
  /**
1266
- * Create a plugin config validator
1483
+ * Verify `files` against the manifest's declared `integrity` map.
1267
1484
  *
1268
- * @param logger - Logger instance
1269
- * @returns Plugin config validator
1485
+ * `options.exempt` names paths outside the map's coverage — the compiled
1486
+ * manifest itself and the signature placeholder, which `computeIntegrity`
1487
+ * excludes at build time (the manifest cannot hash itself, and the
1488
+ * signature signs the manifest) — so their presence is never an
1489
+ * `extra_file` finding.
1270
1490
  */
1271
- declare function createPluginConfigValidator(logger: Logger): PluginConfigValidator;
1491
+ declare function verifyIntegrity(files: readonly IntegrityFile[], integrity: Readonly<Record<string, string>> | null | undefined, options?: {
1492
+ exempt?: readonly string[];
1493
+ }): VerifyIntegrityResult;
1494
+ /** Render one violation as a single human-actionable line. */
1495
+ declare function formatIntegrityViolation(v: IntegrityViolation): string;
1272
1496
 
1273
1497
  /**
1274
1498
  * Plugin Permissions
@@ -1656,96 +1880,6 @@ declare class PluginSandboxRuntime {
1656
1880
  shutdown(): void;
1657
1881
  }
1658
1882
 
1659
- /**
1660
- * Scan Target
1661
- */
1662
- interface ScanTarget {
1663
- pluginId: string;
1664
- version: string;
1665
- files?: string[];
1666
- dependencies?: Record<string, string>;
1667
- }
1668
- /**
1669
- * Security Issue
1670
- */
1671
- interface SecurityIssue {
1672
- id: string;
1673
- severity: 'critical' | 'high' | 'medium' | 'low' | 'info';
1674
- category: 'vulnerability' | 'malware' | 'license' | 'code-quality' | 'configuration';
1675
- title: string;
1676
- description: string;
1677
- location?: {
1678
- file?: string;
1679
- line?: number;
1680
- column?: number;
1681
- };
1682
- remediation?: string;
1683
- cve?: string;
1684
- cvss?: number;
1685
- }
1686
- /**
1687
- * Plugin Security Scanner
1688
- *
1689
- * Scans plugins for security vulnerabilities, malware, and license issues
1690
- */
1691
- declare class PluginSecurityScanner {
1692
- private logger;
1693
- private vulnerabilityDb;
1694
- private scanResults;
1695
- private passThreshold;
1696
- constructor(logger: ObjectLogger, config?: {
1697
- passThreshold?: number;
1698
- });
1699
- /**
1700
- * Perform a comprehensive security scan on a plugin
1701
- */
1702
- scan(target: ScanTarget): Promise<KernelSecurityScanResult>;
1703
- /**
1704
- * Scan code for vulnerabilities
1705
- */
1706
- private scanCode;
1707
- /**
1708
- * Scan dependencies for known vulnerabilities
1709
- */
1710
- private scanDependencies;
1711
- /**
1712
- * Scan for malware patterns
1713
- */
1714
- private scanMalware;
1715
- /**
1716
- * Check license compliance
1717
- */
1718
- private scanLicenses;
1719
- /**
1720
- * Check configuration security
1721
- */
1722
- private scanConfiguration;
1723
- /**
1724
- * Calculate security score based on issues
1725
- */
1726
- private calculateSecurityScore;
1727
- /**
1728
- * Add a vulnerability to the database
1729
- */
1730
- addVulnerability(packageName: string, version: string, vulnerability: KernelSecurityVulnerability): void;
1731
- /**
1732
- * Get scan result from cache
1733
- */
1734
- getScanResult(pluginId: string, version: string): KernelSecurityScanResult | undefined;
1735
- /**
1736
- * Clear scan results cache
1737
- */
1738
- clearCache(): void;
1739
- /**
1740
- * Update vulnerability database from external source
1741
- */
1742
- updateVulnerabilityDatabase(): Promise<void>;
1743
- /**
1744
- * Shutdown security scanner
1745
- */
1746
- shutdown(): void;
1747
- }
1748
-
1749
1883
  /** Default visible prefix for generated keys (helps users identify a key). */
1750
1884
  declare const API_KEY_PREFIX = "osk_";
1751
1885
  /**
@@ -1789,6 +1923,21 @@ declare function isExpired(value: unknown, nowMs: number): boolean;
1789
1923
  /** The principal resolved from a valid `sys_api_key`. */
1790
1924
  interface ApiKeyPrincipal {
1791
1925
  userId: string;
1926
+ /**
1927
+ * [#15256 / 2A] The `sys_api_key` ROW id — a non-secret handle an operator
1928
+ * can look the credential up by. Carried so the posture-conditional refusal
1929
+ * log in `resolve-authz-context.ts` can name WHICH key was refused without
1930
+ * naming the credential.
1931
+ *
1932
+ * ⛔ Never the raw key and never its hash: the raw key is returned exactly
1933
+ * once by {@link generateApiKey} and only `sha256(raw)` is ever stored, and
1934
+ * neither may enter a log line (see this module's SECURITY header). The row
1935
+ * id is not derived from either.
1936
+ *
1937
+ * Optional because a row is only required to identify its owner; a store
1938
+ * that answers without an `id` still yields a usable principal.
1939
+ */
1940
+ keyId?: string;
1792
1941
  /**
1793
1942
  * The organization this key authenticates INTO — read from the row's
1794
1943
  * `active_organization_id` and adopted by `resolveAuthzContext` as the
@@ -1827,6 +1976,19 @@ type ApiKeyAdmission = {
1827
1976
  outcome: 'refused';
1828
1977
  reason: ApiKeyRefusalReason;
1829
1978
  message: string;
1979
+ /**
1980
+ * [#15256 / 2A] The refused key's `sys_api_key` row id — same non-secret
1981
+ * handle as {@link ApiKeyPrincipal.keyId}, carried on this arm too so the
1982
+ * refusal log can name the credential the operator must go look at. ⛔
1983
+ * Never the raw key or its hash. The WIRE answer is unchanged (a generic
1984
+ * `401 UNAUTHENTICATED`, no reason and no id), so nothing here reaches a
1985
+ * caller holding someone else's key.
1986
+ */
1987
+ keyId?: string;
1988
+ /** The owner this refused key authenticates as — for the same log line. */
1989
+ userId?: string;
1990
+ /** The organization the refusal is about, when the key names one. */
1991
+ organizationId?: string;
1830
1992
  };
1831
1993
  /**
1832
1994
  * The shape of the kernel's `tenancy` service this module reads a posture from.
@@ -1884,9 +2046,200 @@ declare function resolveApiKeyPrincipal(ql: any, headers: any, nowMs?: number, t
1884
2046
  */
1885
2047
  declare function resolveApiKeyAdmission(ql: any, headers: any, nowMs?: number, tenancyPosture?: TenancyPosture): Promise<ApiKeyAdmission>;
1886
2048
 
2049
+ /**
2050
+ * [#13279] The LOUD failure an unreachable permission store raises.
2051
+ *
2052
+ * ## The defect this exists to end
2053
+ *
2054
+ * `resolveAuthzContext`'s per-read helper `tryFind` used to answer a THROWN
2055
+ * read the same way it answers an EMPTY one: `[]`. So a permission-store
2056
+ * outage resolved as a well-formed context for an AUTHENTICATED principal
2057
+ * holding ZERO capabilities, and the package-management door answered
2058
+ * `403 FORBIDDEN` — "Reading packages requires the `studio.access` or
2059
+ * `setup.access` capability." That answer was measured BYTE-IDENTICAL
2060
+ * (`JSON.stringify` equal, against a positive control that separates two
2061
+ * answers which differ) to the answer a caller who genuinely holds nothing
2062
+ * gets. An administrator was told they lack a capability, during an outage of
2063
+ * the store that holds the capability.
2064
+ *
2065
+ * The resolver was asserting a fact it did not have. "No rows came back"
2066
+ * and "the read failed" are different facts, and only one of them licenses
2067
+ * the sentence "this user holds nothing".
2068
+ *
2069
+ * ## …and "the read failed" turned out to be TWO facts (ruled 2026-08-30, A)
2070
+ *
2071
+ * A read ALSO throws when the table was never PROVISIONED — a real engine,
2072
+ * wired and reachable, whose `sys_*` tables were never created. There "this
2073
+ * user holds nothing" is TRUE, not invented, so failing loud would refuse
2074
+ * service to a correctly-configured deployment. The first implementation of
2075
+ * this card did exactly that and four CI suites measured it.
2076
+ *
2077
+ * So `tryFind` raises this error only for a read failure that is NOT
2078
+ * positively identified as an unprovisioned table, asking the one relocated
2079
+ * `isMissingTableError` predicate (`@objectstack/types`) rather than a second
2080
+ * copy. The boundary, the ruling's verbatim text and the false-positive risk
2081
+ * signed off with it are written beside that call in `resolve-authz-context.ts`;
2082
+ * both directions are pinned in `authz-store-unavailable.test.ts` §4.
2083
+ *
2084
+ * ## Maintainer ruling, 2026-08-30, verbatim 「第一批其余同意」
2085
+ *
2086
+ * > `tryFind` 区分「无行」与「读失败」,读失败 fail-loud —— 权限库不可达时
2087
+ * > 不再解析为「已认证零能力」,而是响亮拒绝(与真实能力拒绝的 403 可区分),
2088
+ * > 让宕机不再伪装成一次逐字节相同的能力否决。
2089
+ *
2090
+ * The ruling fixes the DIRECTION and leaves the spelling to the implementation.
2091
+ *
2092
+ * ## Why a THROW, and not a field on the envelope
2093
+ *
2094
+ * The alternative was a discriminator field on `ResolvedAuthzContext` — the
2095
+ * shape `authRefusal` had (#8287). That was rejected on a MEASUREMENT, not a
2096
+ * preference: from #8287 until #14273 removed it, `authRefusal` had **zero**
2097
+ * consumers anywhere in the repo outside this module and test assertions — a
2098
+ * reading #14273 acted on by deleting the field. Every transport reads `userId`
2099
+ * and `systemPermissions`; a new sibling field would have to be taught to eight
2100
+ * separate call sites before it made a single door louder, and would answer
2101
+ * the old quiet 403 at every site that was missed.
2102
+ *
2103
+ * A field is quiet by default and must be deliberately made loud. A throw is
2104
+ * loud by default and must be deliberately silenced. On a security surface
2105
+ * whose whole defect is a silence, the default is the entire decision.
2106
+ *
2107
+ * It is also the idiom this platform already uses for unresolvable authority:
2108
+ * `packages/mcp`'s stdio entry throws and refuses to start rather than run with
2109
+ * an authority it could not resolve.
2110
+ *
2111
+ * ## Why `SERVICE_UNAVAILABLE` / 503, and why that is not a new wire shape
2112
+ *
2113
+ * `SERVICE_UNAVAILABLE` is an EXISTING member of the closed ADR-0112 wire
2114
+ * vocabulary (`StandardErrorCode`, `packages/spec/src/api/errors.zod.ts`), and
2115
+ * `HttpStatusErrorCodeMap` already maps it to 503 — "service exists but is
2116
+ * temporarily down". Nothing is added to the vocabulary and no envelope gains
2117
+ * or loses a key: a door that already renders `{ code, message }` renders this
2118
+ * one the same way. What changes is WHICH declared code an outage selects —
2119
+ * from the caller's `FORBIDDEN` to the operator's `SERVICE_UNAVAILABLE`.
2120
+ *
2121
+ * That is the ruling's own test, stated on the wire: 503 is not 403, so an
2122
+ * outage is no longer answerable as a capability denial.
2123
+ *
2124
+ * ## Recognise by BRAND, never by `instanceof`
2125
+ *
2126
+ * {@link isAuthzStoreUnavailableError} tests a own-property brand rather than
2127
+ * `instanceof`. This error crosses package boundaries (`@objectstack/core` →
2128
+ * rest / runtime / mcp / services / plugins) and a monorepo resolves the same
2129
+ * module through more than one path (`src` under vitest aliases, `dist` under
2130
+ * the published `exports`). Two copies of this class make `instanceof` answer
2131
+ * FALSE for a genuine instance — which, here, silently restores the exact
2132
+ * quiet 403 this module exists to remove. The brand survives duplication.
2133
+ */
2134
+ /** HTTP status an unreachable authorization store answers with. */
2135
+ declare const AUTHZ_STORE_UNAVAILABLE_STATUS: 503;
2136
+ /**
2137
+ * Machine code — an EXISTING `StandardErrorCode` member (ADR-0112: SCREAMING).
2138
+ * Deliberately NOT a new code: the wire vocabulary is closed.
2139
+ */
2140
+ declare const AUTHZ_STORE_UNAVAILABLE_CODE: "SERVICE_UNAVAILABLE";
2141
+ /**
2142
+ * Human-facing message. States the OUTAGE, and says explicitly that no
2143
+ * capability judgement was reached — so neither the caller nor the operator
2144
+ * reads it as a permission verdict.
2145
+ */
2146
+ declare const AUTHZ_STORE_UNAVAILABLE_MESSAGE: string;
2147
+ /**
2148
+ * The own-property brand {@link isAuthzStoreUnavailableError} tests for.
2149
+ * A string-keyed own property (not a `Symbol.for` registry key), so a
2150
+ * duplicated copy of this module still brands identically — which is exactly
2151
+ * what `instanceof` cannot do (module doc above).
2152
+ *
2153
+ * ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends
2154
+ * on it doing so — the same measured behaviour `service-not-registered.ts`
2155
+ * records for its own brand. Reproduce on Node 22.22.2:
2156
+ *
2157
+ * ```js
2158
+ * const e = new Error('x'); e.__brand = true; e.code = 'C';
2159
+ * const c = structuredClone(e);
2160
+ * // c.__brand === undefined c.code === undefined c.message === 'x'
2161
+ * // control: structuredClone({ __brand: true, code: 'C' }) keeps BOTH keys
2162
+ * ```
2163
+ *
2164
+ * `Error` has a dedicated serialization carrying `message`, `stack` and
2165
+ * `cause` only, so it DROPS every other own property — this brand, the
2166
+ * ADR-0112 `code`, `status` and `object` alike (and a subclass's own `name`
2167
+ * returns as `'Error'`). The plain-object control is the half that proves the
2168
+ * loss is specific to `Error`, not general to `structuredClone`.
2169
+ *
2170
+ * ⛔ So never branch on this brand across a worker or `postMessage` boundary:
2171
+ * it would answer `false` and fail OPEN. Every call site today is in-process —
2172
+ * `rethrowAuthzStoreUnavailable` on the rest rethrow paths and
2173
+ * `isAuthzStoreUnavailableError` inside service `catch` blocks.
2174
+ */
2175
+ declare const AUTHZ_STORE_UNAVAILABLE_BRAND: "__objectstackAuthzStoreUnavailable";
2176
+ /**
2177
+ * Raised when a permission-store read FAILED — never when it legitimately
2178
+ * returned no rows.
2179
+ *
2180
+ * Carries the `object` whose read failed so an operator sees WHICH table was
2181
+ * unreachable, and the originating error as `cause` so the driver's own
2182
+ * diagnostic is not lost behind this one.
2183
+ */
2184
+ declare class AuthzStoreUnavailableError extends Error {
2185
+ /** Brand — see the module doc on why this is not `instanceof`. */
2186
+ readonly [AUTHZ_STORE_UNAVAILABLE_BRAND]: true;
2187
+ /** ADR-0112 wire code. */
2188
+ readonly code: "SERVICE_UNAVAILABLE";
2189
+ /** HTTP status a transport should answer. */
2190
+ readonly status: 503;
2191
+ /** The object/table whose read failed (e.g. `sys_user_permission_set`). */
2192
+ readonly object: string;
2193
+ /** The driver's originating failure, kept so its diagnostic is not lost. */
2194
+ readonly cause?: unknown;
2195
+ constructor(object: string, cause?: unknown);
2196
+ }
2197
+ /**
2198
+ * True when `err` is the loud authorization-store failure above.
2199
+ *
2200
+ * The predicate every transport uses to tell "the store was unreachable" apart
2201
+ * from every other throw, so a fail-closed `catch` can re-raise THIS one
2202
+ * without loosening its handling of anything else.
2203
+ */
2204
+ declare function isAuthzStoreUnavailableError(err: unknown): err is AuthzStoreUnavailableError;
2205
+ /**
2206
+ * The `.catch` argument every fail-closed seam between `resolveAuthzContext`
2207
+ * and a door should use in place of `() => undefined`.
2208
+ *
2209
+ * Re-raises {@link AuthzStoreUnavailableError} and swallows everything else to
2210
+ * `undefined`, so a seam keeps its existing fail-closed behaviour for every
2211
+ * fault EXCEPT the one the 2026-08-30 ruling requires to stay loud.
2212
+ *
2213
+ * ## Why the seams need this at all
2214
+ *
2215
+ * Making `tryFind` throw is necessary but NOT sufficient, and that was
2216
+ * MEASURED rather than assumed. Between the resolver and the package door sit
2217
+ * three independent nets — `computeExecCtx`'s `try { … } catch { return
2218
+ * undefined; }`, `resolvePackageRouteExecutionContext`'s `.catch(() =>
2219
+ * undefined)`, and `refusePackageRequest`'s own — and with the throw in place
2220
+ * but the nets untouched, the door answered **401**: the outage had simply
2221
+ * changed disguises, from "you hold no capability" (403) into "you are not
2222
+ * authenticated" (401), which is byte-identical to a genuine anonymous caller.
2223
+ * Distinguishable from a capability denial, yes — but still not LOUD, and now
2224
+ * wearing the costume of a different card's defect.
2225
+ *
2226
+ * A blanket `catch` cannot tell a fault from a refusal, so each net has to be
2227
+ * told once, in one shape. This is that shape.
2228
+ */
2229
+ declare function rethrowAuthzStoreUnavailable(err: unknown): undefined;
2230
+
1887
2231
  /** The transport-agnostic authorization envelope produced from a request. */
1888
2232
  interface ResolvedAuthzContext {
1889
2233
  userId?: string;
2234
+ /**
2235
+ * The ACTIVE organization this request operates in.
2236
+ *
2237
+ * ⚠️ [#15409] For a session principal under a wall-enforcing posture this is
2238
+ * a VETTED value, never the stored `activeOrganizationId` as read: a claim
2239
+ * that is not in {@link accessible_org_ids} is dropped and the context
2240
+ * resolves with no active organization at all. Absent here is the fail-closed
2241
+ * state, not a missing lookup — Layer 0 denies on it.
2242
+ */
1890
2243
  tenantId?: string;
1891
2244
  email?: string;
1892
2245
  accessToken?: string;
@@ -1907,31 +2260,15 @@ interface ResolvedAuthzContext {
1907
2260
  /**
1908
2261
  * [ADR-0095 D2/D3] The monotonic posture rung this principal resolves to,
1909
2262
  * DERIVED once here from held capability grants (never a better-auth role):
1910
- * `PLATFORM_ADMIN` (unscoped `admin_full_access`) > `TENANT_ADMIN`
2263
+ * `PLATFORM_ADMIN` (an unscoped `admin_full_access` grant, or — since #11663
2264
+ * L2 — a VERIFIED `sys_user.email` on the deployment's declared
2265
+ * administrator list) > `TENANT_ADMIN`
1911
2266
  * (`organization_admin`) > `MEMBER` (the authenticated floor). `EXTERNAL` is
1912
2267
  * defined/test-locked but never resolved yet (no external principal type —
1913
2268
  * see `posture-ladder.ts`). Present only for an authenticated principal;
1914
2269
  * anonymous requests carry no rung.
1915
2270
  */
1916
2271
  posture?: AuthzPosture;
1917
- /**
1918
- * [#8287] Set when an inbound API key was REFUSED — a real, intact
1919
- * credential this deployment's tenancy posture cannot admit. The context is
1920
- * otherwise EMPTY (no `userId`), so every transport already fails it closed
1921
- * to 401 with no change; this field only lets a transport that wants to say
1922
- * WHY do so, instead of answering the operator with a bare "unauthenticated"
1923
- * for a key they can see is neither revoked nor expired.
1924
- *
1925
- * ⚠️ `reason` is NOT an `error.code`. The wire vocabulary is closed
1926
- * (ADR-0112: `StandardErrorCode ∪ ERROR_CODE_LEDGER`, both in `packages/spec`)
1927
- * and a refused credential's standard member is `UNAUTHENTICATED`. This is a
1928
- * diagnostic discriminator for the message, deliberately lowercase so it can
1929
- * never be mistaken for one.
1930
- */
1931
- authRefusal?: {
1932
- reason: ApiKeyRefusalReason;
1933
- message: string;
1934
- };
1935
2272
  }
1936
2273
  interface ResolveAuthzInput {
1937
2274
  /** Data engine (ObjectQL) exposing `find(object, { where, limit, context })`. */
@@ -1960,8 +2297,19 @@ interface ResolveAuthzInput {
1960
2297
  tenancyPosture?: TenancyPosture;
1961
2298
  }
1962
2299
  /**
1963
- * Resolve the authorization context for an inbound request. Always resolves —
1964
- * never throws. Anonymous requests yield `{ positions: [], permissions: [], ... }`.
2300
+ * Resolve the authorization context for an inbound request. Anonymous requests
2301
+ * yield `{ positions: [], permissions: [], ... }`.
2302
+ *
2303
+ * ⚠️ [#13279] This function used to document itself as "Always resolves — never
2304
+ * throws", and that total guarantee WAS the defect: the only way to always
2305
+ * resolve across a permission-store outage is to report a capability set the
2306
+ * resolver never actually read. It now throws exactly one error —
2307
+ * {@link AuthzStoreUnavailableError}, when a permission-store read was issued
2308
+ * and failed FOR A REASON THAT IS NOT AN UNPROVISIONED TABLE. Every other path
2309
+ * still resolves, including every MISSING-service path and every
2310
+ * never-provisioned one. A transport that fails closed on unexpected throws should re-raise this
2311
+ * one ({@link isAuthzStoreUnavailableError}) rather than degrade it to a
2312
+ * refusal — degrading it restores the disguise the ruling removed.
1965
2313
  */
1966
2314
  declare function resolveAuthzContext(input: ResolveAuthzInput): Promise<ResolvedAuthzContext>;
1967
2315
  /** The authorization grants a KNOWN user holds — a subset of {@link ResolvedAuthzContext}. */
@@ -1992,6 +2340,25 @@ interface ResolveUserAuthzGrantsOptions {
1992
2340
  seedPermissions?: string[];
1993
2341
  /** A caller-supplied email (e.g. from the session) that wins over the `sys_user` read. */
1994
2342
  seedEmail?: string;
2343
+ /**
2344
+ * ⭐ Force a FRESH resolution even when the #11971 grants cache is enabled
2345
+ * — the ruled bypass list of #11633 (leg B, maintainer acceptance
2346
+ * 2026-08-25). Two call sites carry it, for two ruled reasons:
2347
+ *
2348
+ * - `plugin-security/src/explain-engine.ts` (`buildContextForUser`): the
2349
+ * permission explainer is the tool an administrator uses to VERIFY that
2350
+ * a revocation took effect. An explainer answering from cache would
2351
+ * explain a state that no longer exists, and would do it at exactly the
2352
+ * moment someone is checking.
2353
+ * - `service-automation/src/plugin.ts` (`runAs:'user'` runs): automation
2354
+ * runs are not request-shaped and can be long-lived; they must not pin
2355
+ * an envelope.
2356
+ *
2357
+ * Bypassing reads NOTHING from the cache and writes NOTHING into it — a
2358
+ * bypassed resolution must not repopulate an entry the next cached caller
2359
+ * would then trust.
2360
+ */
2361
+ bypassGrantsCache?: boolean;
1995
2362
  }
1996
2363
  /**
1997
2364
  * resolveUserAuthzGrants — the userId-driven core of {@link resolveAuthzContext}.
@@ -2012,13 +2379,84 @@ interface ResolveUserAuthzGrantsOptions {
2012
2379
  * automation engine calls this to run the flow's data ops exactly as that user
2013
2380
  * — not the bare member/everyone fallback the missing grants used to leave it.
2014
2381
  *
2015
- * Fail-closed like its parent: every read is defensive, a missing engine/table
2016
- * yields an empty-but-valid envelope, and it never throws.
2382
+ * Fail-closed like its parent: a missing engine yields an empty-but-valid
2383
+ * envelope.
2384
+ *
2385
+ * ⚠️ [#13279] "and it never throws" was removed from this sentence deliberately.
2386
+ * A permission-store read that is issued and FAILS now raises
2387
+ * {@link AuthzStoreUnavailableError} rather than contributing an empty grant
2388
+ * set, so a `runAs:'user'` automation cannot silently run with the authority of
2389
+ * a user whose grants were never read.
2017
2390
  */
2018
2391
  declare function resolveUserAuthzGrants(ql: any, userId: string, opts?: ResolveUserAuthzGrantsOptions): Promise<UserAuthzGrants>;
2392
+ /**
2393
+ * hasPlatformAdminStanding — the ID-SHAPED platform-admin question, asked in
2394
+ * exactly one place.
2395
+ *
2396
+ * ADR-0068 D2 defined PLATFORM standing as one thing: an UNSCOPED
2397
+ * (`organization_id = null`) `sys_user_permission_set` grant on the
2398
+ * `admin_full_access` set, held **now**. Since #11663 L2 there is a SECOND
2399
+ * anchor beside it — a `sys_user` row whose VERIFIED email is on the
2400
+ * deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) — and
2401
+ * this predicate answers for both, for free, because it is a projection rather
2402
+ * than a copy (see below). A surface that only knows a user id —
2403
+ * a session-payload derivation, a platform-operator route gate, an
2404
+ * impersonation oracle — asks here, so it never has to re-read the grant tables
2405
+ * itself, which is the prohibition this module's header states.
2406
+ *
2407
+ * It is a PROJECTION of {@link resolveUserAuthzGrants}, never a second
2408
+ * derivation: the answer is the `PLATFORM_ADMIN` rung of the posture ladder,
2409
+ * and that rung is derived from the unscoped-grant evidence and nothing else.
2410
+ * Everything that governs those grants therefore applies here by construction
2411
+ * and cannot drift from it — the ADR-0091 validity window (§6), the ADR-0049
2412
+ * `active` flag on the catalogue row (§6b), the system-identity read, and the
2413
+ * resolution of `admin_full_access` BY ID rather than by scanning a page of the
2414
+ * catalogue. Each of those was missing from a hand-written copy of this
2415
+ * predicate; none of them can be missing from a projection.
2416
+ *
2417
+ * ⛔ Read the RUNG — never `positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN)`.
2418
+ * The positions list is wider on purpose: an ADR-0057 D4 `sys_user_position`
2419
+ * row may spell that very name, and a platform-RBAC assignment is not the D2
2420
+ * capability grant. The two readings genuinely differ, so the narrow one is the
2421
+ * one that gets a name here.
2422
+ *
2423
+ * ⛔ The options are deliberately NOT {@link ResolveUserAuthzGrantsOptions}.
2424
+ * That type carries caller-supplied seeds (`seedEmail`, `seedPermissions`) for
2425
+ * transports that already resolved part of a principal; an authorization
2426
+ * predicate that accepted them would let a caller supply part of its own
2427
+ * verdict. Clock injection is the only thing a caller may pass, so this
2428
+ * function's answer is a function of `(ql, userId)` and the stored rows alone.
2429
+ *
2430
+ * ⚠️ This is the PER-USER predicate. The POPULATION question ("which user is
2431
+ * the platform admin?" — `ensure-default-organization.ts`) is a different kind
2432
+ * and is deliberately not expressible through it; do not widen this to serve
2433
+ * it.
2434
+ *
2435
+ * Fail-CLOSED: an empty id, a missing engine, or any unreadable lookup answers
2436
+ * `false`. This backs security gates, and an unverifiable actor never passes.
2437
+ */
2438
+ declare function hasPlatformAdminStanding(ql: any, userId: string, opts?: {
2439
+ nowMs?: number;
2440
+ }): Promise<boolean>;
2019
2441
  interface ResolveLocalizationInput {
2020
2442
  ql: any;
2021
- /** Settings service exposing `get(namespace, key, { tenantId, userId })`. */
2443
+ /**
2444
+ * Settings service occupant. Two methods are consumed, in this order:
2445
+ *
2446
+ * - `getMany(namespace, keys, { tenantId, userId })` — PREFERRED since
2447
+ * #10826, and what this resolver calls for all three localization keys in
2448
+ * ONE grouped read.
2449
+ * - `get(namespace, key, { tenantId, userId })` — the per-key fallback,
2450
+ * taken only when the occupant does not expose `getMany` (three parallel
2451
+ * reads; see the feature-detect below).
2452
+ *
2453
+ * `getMany` is OPTIONAL for an occupant: the branch is feature-detected, so
2454
+ * a service that predates it still resolves — at three reads instead of one.
2455
+ * Typed `any` deliberately (the occupant's shape varies by host); the
2456
+ * declaration above is the contract this resolver actually relies on, and it
2457
+ * is prose precisely because nothing type-checks it — `getService` is a cast
2458
+ * and `rest-server.ts` widens the provider's return to a bare promise.
2459
+ */
2022
2460
  settings?: any;
2023
2461
  tenantId?: string;
2024
2462
  userId?: string;
@@ -2034,14 +2472,22 @@ type LocalizationResult = {
2034
2472
  * platform default → global → tenant); falls back to direct tenant-scoped
2035
2473
  * `sys_setting` rows, then the built-ins `UTC` / `en-US`. Never throws.
2036
2474
  *
2037
- * A read that fails outright (backend fault — table missing, connection
2038
- * refused, etc.) is memoized for {@link LOCALIZATION_FAILURE_CACHE_TTL_MS}
2039
- * per `(ql, tenantId, userId)` so the failing query — and the driver's log
2040
- * line for it — does not repeat every request (#10221). A successful read,
2041
- * including a legitimate "no settings configured yet" empty result, is NEVER
2042
- * cached: the next call always re-reads, so a settings write takes effect
2043
- * immediately (see the cache doc above for why — the dogfood analytics
2044
- * bucketing test pins this).
2475
+ * The DIRECT `sys_setting` read failing outright (backend fault — table
2476
+ * missing, connection refused, etc.) is memoized for
2477
+ * {@link LOCALIZATION_FAILURE_CACHE_TTL_MS} per `(ql, tenantId, userId)` so
2478
+ * the failing query — and the driver's log line for it — does not repeat
2479
+ * every request (#10221). A settings-service refusal is not a backend fault
2480
+ * and never populates that memo (#11877).
2481
+ *
2482
+ * A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only
2483
+ * when the engine carries the write-epoch seam, and only until the first of:
2484
+ * a `localization` settings change, an engine write, or
2485
+ * `OS_LOCALIZATION_CACHE_TTL_MS`. Both invalidations are synchronous and
2486
+ * in-process, which is what lets the success cache exist at all: the
2487
+ * dogfood analytics-bucketing test writes a new org timezone and reads it back
2488
+ * on the very next request, and it is kept unweakened as this leg's acceptance
2489
+ * criterion. See the leg-C docblock above for the full contract, including why
2490
+ * a `ql` with no seam declines to cache rather than falling back to the TTL.
2045
2491
  */
2046
2492
  declare function resolveLocalizationContext(input: ResolveLocalizationInput): Promise<LocalizationResult>;
2047
2493
 
@@ -2592,6 +3038,20 @@ declare function isRowActive(row: ActivatableRow | null | undefined): boolean;
2592
3038
  * the table-level half of the same guarantee: a resolver that starts deriving
2593
3039
  * administrator standing from a new table would otherwise be invisible to a
2594
3040
  * column-set comparison, because the new table appears in neither side's list.
3041
+ *
3042
+ * ## ⚠️ Tables are no longer the whole surface (#11663 L2)
3043
+ *
3044
+ * Since the platform-admin re-anchor's core leg, one input to the administrator
3045
+ * derivation is NOT a table at all: the deployment's declared administrator
3046
+ * list, read from the environment on every resolution
3047
+ * (`security/platform-admin.ts`). A file that listed only tables would go on
3048
+ * being perfectly accurate about the tables while silently claiming the
3049
+ * derivation reads nothing else — the same shape as the stale comment this file
3050
+ * replaced, one level up. {@link ADMIN_STANDING_NON_TABLE_INPUTS} is the place
3051
+ * that says so, and it is deliberately a SEPARATE export rather than a
3052
+ * pseudo-row in the table map: the map is compared for equality against
3053
+ * observed table reads, and a pseudo-row would have to be excluded from that
3054
+ * comparison by name, which is exactly the kind of special case that rots.
2595
3055
  */
2596
3056
  /** How a table this resolver reads relates to "who is an administrator". */
2597
3057
  interface AdminStandingTable {
@@ -2618,11 +3078,37 @@ interface AdminStandingTable {
2618
3078
  * principal, and therefore all of `resolveUserAuthzGrants`. The API-key
2619
3079
  * ADMISSION path (`resolveApiKeyAdmission`) is outside it on purpose: it
2620
3080
  * authenticates a principal and seeds `permissions` with the key's scopes, and
2621
- * confers no administrator standing of its own — `hasPlatformAdminGrant` (§6b)
2622
- * is set only from a `sys_permission_set` row reached through an UNSCOPED
2623
- * `sys_user_permission_set` grant, never from a scope string.
3081
+ * confers no administrator standing of its own — `hasPlatformAdminGrant` is set
3082
+ * from a `sys_permission_set` row reached through an UNSCOPED
3083
+ * `sys_user_permission_set` grant (§6b) or from the deployment config matched
3084
+ * against the caller's own STORED `sys_user` row (§6b-config), never from a
3085
+ * scope string and never from the caller-seedable `grants.email`.
2624
3086
  */
2625
3087
  declare const ADMIN_STANDING_SURFACE: Readonly<Record<string, AdminStandingTable>>;
3088
+ /** A derivation input that is not a table — see {@link ADMIN_STANDING_NON_TABLE_INPUTS}. */
3089
+ interface AdminStandingNonTableInput {
3090
+ /** How the value reaches the resolver, e.g. `env` for a process environment variable. */
3091
+ readonly kind: 'env';
3092
+ /** The exact spelling an operator sets — quotable verbatim in a refusal message. */
3093
+ readonly name: string;
3094
+ /** What it decides, and what a break-glass guard can and cannot do about it. */
3095
+ readonly reason: string;
3096
+ }
3097
+ /**
3098
+ * [#11663 L2] Inputs to the administrator derivation that no table write can
3099
+ * reach — declared here so this file's silence about them cannot be read as
3100
+ * "the derivation reads only tables".
3101
+ *
3102
+ * The practical consequence is the one worth writing down: a break-glass guard
3103
+ * simulates a pending WRITE, and there is no write to simulate for any of
3104
+ * these. Standing that rests on one of them is taken away by changing the
3105
+ * deployment's configuration and rolling the process, which is deliberately
3106
+ * outside every in-product path — including every path an agent could be talked
3107
+ * into calling. That is the whole point of the config anchor, and it is also
3108
+ * the reason a guard cannot promise to prevent this class of lockout: it can
3109
+ * only refuse the writes it can see.
3110
+ */
3111
+ declare const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInput[];
2626
3112
  /** The tables a write to which can change who is an administrator. */
2627
3113
  declare function adminStandingTables(): string[];
2628
3114
  /**
@@ -2631,6 +3117,147 @@ declare function adminStandingTables(): string[];
2631
3117
  */
2632
3118
  declare function adminStandingColumns(table: string): readonly string[] | undefined;
2633
3119
 
3120
+ /**
3121
+ * The one separator {@link parsePlatformAdminEmails} splits on (Choice 2B).
3122
+ * Same shape as `OS_CORS_ORIGIN`, the existing comma-separated precedent.
3123
+ */
3124
+ declare const PLATFORM_ADMIN_EMAIL_SEPARATOR = ",";
3125
+ /**
3126
+ * The ONE normalization, applied to both sides of every comparison: trim, then
3127
+ * lowercase. Email domains are case-insensitive and every mailbox this platform
3128
+ * issues is too, so an operator who types `Ada@Example.com` and a row storing
3129
+ * `ada@example.com` must be one administrator, not two half-matches.
3130
+ */
3131
+ declare function normalizePlatformAdminEmail(value: unknown): string;
3132
+ /** The parsed state of `OS_PLATFORM_OWNER_EMAIL` for one raw value. */
3133
+ interface PlatformAdminEmailConfig {
3134
+ /**
3135
+ * Normalized, de-duplicated administrator addresses in the order the operator
3136
+ * declared them. EMPTY when the variable is unset, blank, or refused — those
3137
+ * three are one outcome by design (zero config-derived administrators), and
3138
+ * they are told apart by {@link refusal} rather than by a second empty value.
3139
+ */
3140
+ readonly emails: readonly string[];
3141
+ /**
3142
+ * The SAME administrators as {@link emails} and index-aligned with it, each
3143
+ * spelled as the operator typed it — trimmed only, never lowercased (exactly
3144
+ * what `resolvePlatformOwnerEmail()` used to hand a single-value reader).
3145
+ *
3146
+ * It exists so that no consumer ever has a reason to split {@link raw} a
3147
+ * second time. Two readers need the as-typed form and neither may re-parse
3148
+ * to get it: the platform-admin STANDING surface's by-email `sys_user`
3149
+ * lookup queries the verbatim spelling alongside the normalized one (an
3150
+ * imported/legacy row may not be stored lowercased, and a driver `where` is
3151
+ * an exact match) — that lookup is `resolvePlatformAdminStanding` in
3152
+ * plugin-security's `platform-admin-service.ts`, which inherited the
3153
+ * two-spelling discipline from the elevation gate the #11663 re-anchor
3154
+ * (leg L4) retired — and the walled boot diagnostic quotes the addresses
3155
+ * back to the operator, who should see what they wrote.
3156
+ */
3157
+ readonly declaredSpellings: readonly string[];
3158
+ /** What the operator actually typed, when the variable was set to anything. */
3159
+ readonly raw?: string;
3160
+ /**
3161
+ * Set when the variable was DECLARED but refused, naming the offending entry.
3162
+ * `emails` is empty in that case: the whole variable fails closed, never the
3163
+ * one entry (Choice 2B).
3164
+ */
3165
+ readonly refusal?: string;
3166
+ }
3167
+ /**
3168
+ * Parse one raw `OS_PLATFORM_OWNER_EMAIL` value into the administrator list.
3169
+ *
3170
+ * Pure — no env read, no logging — so the whole parse is testable as a
3171
+ * function of its input. {@link resolvePlatformAdminEmails} is the env-reading,
3172
+ * memoizing, once-per-value-loud wrapper around it.
3173
+ */
3174
+ declare function parsePlatformAdminEmails(raw: string | undefined): PlatformAdminEmailConfig;
3175
+ /**
3176
+ * Sink for the refusal notice. `console` by default so the loudness does not
3177
+ * depend on any host wiring it up — a deployment that declared administrators
3178
+ * and got none must never find that out silently. Swappable for tests.
3179
+ */
3180
+ interface PlatformAdminConfigSink {
3181
+ error(message: string): void;
3182
+ warn(message: string): void;
3183
+ }
3184
+ /** Redirect this module's notices (tests). Returns the previous sink. */
3185
+ declare function setPlatformAdminConfigSink(next: PlatformAdminConfigSink | undefined): PlatformAdminConfigSink;
3186
+ /**
3187
+ * Resolve the deployment's declared platform administrators — live from the
3188
+ * environment, memoized on the raw string, and LOUD exactly once per distinct
3189
+ * refused value.
3190
+ *
3191
+ * Silence for an UNSET variable is deliberate and is not the same decision:
3192
+ * every `single`-posture deployment runs that way by design (Choice 4A leaves
3193
+ * first-user promotion in place there), and warning on the shipped default is
3194
+ * how a log people read becomes a log people skim. A walled posture with the
3195
+ * variable unset already REFUSES BOOT one layer up, in plugin-auth.
3196
+ */
3197
+ declare function resolvePlatformAdminEmails(): PlatformAdminEmailConfig;
3198
+ /** Drop the memo — for tests that drive several values through one process. */
3199
+ declare function resetPlatformAdminEmailMemo(): void;
3200
+ /**
3201
+ * Does this stored `sys_user` row belong to a declared platform administrator?
3202
+ *
3203
+ * Fail-closed on every axis: an empty/refused config answers `false` without
3204
+ * looking at the row at all, an address that is not on the list answers
3205
+ * `false`, and an address that IS on the list but whose `email_verified`
3206
+ * column does not read verified answers `false` too. The last one is the point
3207
+ * of the whole leg — an unverified account holding a configured address confers
3208
+ * nothing, so an attacker who registers the operator's address before the
3209
+ * operator does gains no standing by it.
3210
+ *
3211
+ * ⚠️ `row` MUST be the caller's own stored `sys_user` row. See this module's
3212
+ * header: `grants.email` is caller-seedable and reading it here would be an
3213
+ * escalation channel.
3214
+ */
3215
+ declare function matchesConfiguredPlatformAdmin(row: unknown, config: PlatformAdminEmailConfig): boolean;
3216
+ /**
3217
+ * [#13147] Is this bare ADDRESS one of the declared administrators?
3218
+ *
3219
+ * The membership half of {@link matchesConfiguredPlatformAdmin}, spelled once
3220
+ * and exported, because the row-and-verified predicate above is not the shape
3221
+ * every reader of `OS_PLATFORM_OWNER_EMAIL` needs:
3222
+ *
3223
+ * - the walled platform-admin STANDING surface must keep the two halves
3224
+ * SEPARATE — `resolvePlatformAdminStanding`
3225
+ * (`plugin-security/platform-admin-service.ts`, reported at boot by
3226
+ * `bootstrap-platform-admin.ts`) answers `registered` and `verified` as two
3227
+ * independent per-entry fields, so the operator's log can tell "not
3228
+ * registered yet" apart from "registered, NOT verified". ⚠️ That reason
3229
+ * predates the #11663 re-anchor and survives it: the pair used to be the
3230
+ * retired elevation gate's `walled_owner_not_registered` /
3231
+ * `walled_owner_not_verified` reasons — the mechanism moved, the need to
3232
+ * keep the halves apart did not;
3233
+ * - the creation-time operator stamp (`plugin-auth`) is handed an email
3234
+ * STRING by better-auth, before any row exists to read;
3235
+ * - the Layer 0 wall bypass takes a fast negative on the session's
3236
+ * server-resolved email before it spends a `sys_user` read.
3237
+ *
3238
+ * ⛔ Those readers must NOT hand-roll `config.emails.includes(x.toLowerCase())`
3239
+ * instead. That expression is where a seventh dialect gets born: it silently
3240
+ * drops the trim, and a stray space in one list entry then makes an
3241
+ * administrator vanish with nothing to notice. One membership expression, one
3242
+ * normalization ({@link normalizePlatformAdminEmail}), one place to fix.
3243
+ *
3244
+ * Fail-closed like everything else here: an empty or refused config answers
3245
+ * `false` without looking at the candidate, and a blank/non-string candidate
3246
+ * answers `false` against any config.
3247
+ *
3248
+ * ⚠️ This is a match against CONFIGURATION only — it says nothing about whether
3249
+ * the address is verified, or whether the caller actually holds it. Standing
3250
+ * still requires {@link matchesConfiguredPlatformAdmin} over the caller's own
3251
+ * stored row; see this module's header for why `grants.email` is never it.
3252
+ */
3253
+ declare function isConfiguredPlatformAdminEmail(email: unknown, config: PlatformAdminEmailConfig): boolean;
3254
+ declare function reportLegacyPlatformAdminGrant(input: {
3255
+ userId: string;
3256
+ email?: unknown;
3257
+ }): void;
3258
+ /** Drop the once-per-process latch — for tests. */
3259
+ declare function resetLegacyPlatformAdminGrantReport(): void;
3260
+
2634
3261
  /**
2635
3262
  * [#7678] The `?status=` vocabulary of the audience-binding suggestion list
2636
3263
  * (ADR-0090 D5/D9) — ONE owner for a rule that had exactly one implementation
@@ -2697,7 +3324,12 @@ declare const unknownAudienceBindingSuggestionStatusMessage: (value: string) =>
2697
3324
  * `__expandRead` marks a read as a lookup EXPANSION sub-read (it no longer
2698
3325
  * relaxes any gate — #7626 removed that waiver — but it still travels with
2699
3326
  * one operation and must not be inherited by another), `__referentialFieldClear`
2700
- * authorizes the referential-clear write.
3327
+ * authorizes the referential-clear write. [#13644] The latter also has a
3328
+ * DECLARED, read-only projection — `HookContext.referentialFieldClear`
3329
+ * (`@objectstack/spec/data`), populated by objectql's `update()` assembly
3330
+ * and carried across the sandbox boundary by contract — which is what an
3331
+ * APP reads; the `__` key here remains the engine/middleware authorization
3332
+ * channel, and this file's stripping rule is unchanged by the projection.
2701
3333
  *
2702
3334
  * plugin-security is the PRODUCER of that vocabulary and would be the most
2703
3335
  * honest owner of the rule for consuming it, but none of the three consumers
@@ -2786,6 +3418,244 @@ declare const OPERATION_PRIVATE_KEY_PREFIX = "__";
2786
3418
  */
2787
3419
  declare function withoutOperationPrivateKeys(exec: Record<string, unknown>): ExecutionContext;
2788
3420
 
3421
+ /**
3422
+ * ── The `authz.invalidated` cluster channel (#11968, #11633 §3) ─────────────
3423
+ *
3424
+ * The cross-node half of the authorization invalidation substrate: one channel
3425
+ * name, one payload shape, and the contract statement that governs how both may
3426
+ * be read. It carries no cache and no consumer — leg B (#11967) is the first.
3427
+ *
3428
+ * ## ⭐ THE TTL IS THE CORRECTNESS CONTRACT. THIS CHANNEL IS NOT.
3429
+ *
3430
+ * A message on this channel is a **hint**, and **a missed message is EXPECTED**.
3431
+ * That is not a caveat about an unreliable network; it is the shipped
3432
+ * guarantee, measured rather than assumed:
3433
+ *
3434
+ * - `content/docs/kernel/cluster.mdx` §4.2, on `at-least-once`:
3435
+ * *"**No shipped driver provides this yet.** The `redis` driver publishes
3436
+ * over plain Redis pub/sub, which is *at-most-once* — fire-and-forget, no
3437
+ * persistence, no replay for a node that was down at publish time."*
3438
+ * - `@objectstack/service-cluster-redis`'s own `publish` docblock:
3439
+ * *"there is no delivery guarantee to subscribers and no replay for a node
3440
+ * that was down or slow at publish time. This is acceptable **only** for
3441
+ * events that are pure cache-invalidation hints, never the source of
3442
+ * truth."*
3443
+ * - The `memory` driver does not cross a process boundary at all
3444
+ * (`service-cluster/src/memory/pubsub.ts`, and the split-brain guard's
3445
+ * `IN_PROCESS_DRIVERS`).
3446
+ *
3447
+ * So a dropped message on an at-most-once transport is a staleness window with
3448
+ * **no upper bound**, and no amount of care at the publish site changes that.
3449
+ * What bounds it is the **TTL** every cached authorization answer must carry:
3450
+ * a peer that never hears the message still converges when its entry expires.
3451
+ *
3452
+ * ⇒ **A consumer that would be incorrect if a message were lost is misusing
3453
+ * this channel.** The channel exists for one thing: moving the *typical*
3454
+ * convergence from "one TTL" down to "one network hop". It never moves the
3455
+ * worst case, and it is never the mechanism that makes a cached authorization
3456
+ * answer safe.
3457
+ *
3458
+ * ⚠️ For the same reason this channel is **best-effort at the publish site
3459
+ * too**: a publish failure is logged and swallowed, never propagated into the
3460
+ * write that triggered it. A grant revocation must not fail because a cache
3461
+ * hint could not be delivered — the TTL already covers exactly that case.
3462
+ *
3463
+ * `IPubSub`'s own interface docblock (`@objectstack/spec/contracts`) states the
3464
+ * same thing from the contract side — delivery is whatever the configured
3465
+ * driver declares, no shipped driver exceeds at-most-once, and handlers must be
3466
+ * idempotent **and** tolerate loss. That docblock, `cluster.mdx` §4.2 and the
3467
+ * redis driver agree; there is no disagreement here for a later reader to go
3468
+ * looking for.
3469
+ *
3470
+ * ## Why a new channel on the existing bus, and not a new transport
3471
+ *
3472
+ * `MetadataClusterBridgePlugin` already shows the whole shape — a channel on
3473
+ * `IPubSub`, bridged by a plugin that late-binds at `kernel:ready` and does
3474
+ * nothing when the services it needs are absent. Reusing it adds no dependency
3475
+ * and no new failure mode. The one thing metadata's channel does NOT have to
3476
+ * carry is what makes this one different: a missed `metadata.changed` costs a
3477
+ * stale schema until reload and loses no data, while a missed
3478
+ * `authz.invalidated` would cost a permission honoured past its revocation —
3479
+ * which is why the bound lives in the TTL and why the absence of this bridge is
3480
+ * stated out loud at boot ({@link ../security/authz-cache-posture.js}).
3481
+ */
3482
+ /**
3483
+ * The cluster channel authorization-cache invalidation hints travel on.
3484
+ *
3485
+ * Named as a fact about authorization, not about any one cache, because a
3486
+ * second consumer must reuse this channel rather than mint a parallel one —
3487
+ * two channels would be two chances to miss a bridge.
3488
+ */
3489
+ declare const AUTHZ_INVALIDATED_CHANNEL = "authz.invalidated";
3490
+ /**
3491
+ * Why an authorization epoch advanced. Coarse on purpose (#11633 §2.2, Fork 1 →
3492
+ * A): the engine seam sees `update`/`delete` expressed as a `where`, from which
3493
+ * the affected user or organization is frequently **not derivable without
3494
+ * reading the row back**. So the substrate carries "something authorization-
3495
+ * relevant changed", never "whose entry to drop", and a consumer retires its
3496
+ * whole bucket. Keyed invalidation is gated behind a measurement of a
3497
+ * write-heavy tenant and is explicitly not the starting point.
3498
+ */
3499
+ type AuthzInvalidationReason =
3500
+ /** A write (`insert` / `update` / `delete`) passed the engine middleware seam. */
3501
+ 'write'
3502
+ /** A metadata change — a permission set can be DECLARED, so no row is written. */
3503
+ | 'metadata'
3504
+ /** A hint received from a peer node on this channel. */
3505
+ | 'remote'
3506
+ /** An explicit bump by a host that knows something the seam cannot see. */
3507
+ | 'manual';
3508
+ /**
3509
+ * The payload on {@link AUTHZ_INVALIDATED_CHANNEL}.
3510
+ *
3511
+ * Deliberately tiny and deliberately NOT a description of what to invalidate:
3512
+ * see {@link AuthzInvalidationReason} for why the seam cannot supply that. A
3513
+ * receiver's only correct response is to retire its authorization cache
3514
+ * wholesale — and to remain correct if this message never arrives.
3515
+ *
3516
+ * ⛔ Not a `packages/spec` contract type. #11633 §5 reserves a declared shape
3517
+ * for the invalidation event to the spec seat and does not pre-commit it; this
3518
+ * is the runtime shape the substrate publishes today.
3519
+ */
3520
+ interface AuthzInvalidatedPayload {
3521
+ /**
3522
+ * Publishing node, for loopback suppression — a node must not act on its own
3523
+ * hint. Mirrors `ClusterMetadataChangedPayload.originNode`.
3524
+ */
3525
+ originNode?: string;
3526
+ /** The publisher's local epoch after the bump. Diagnostic only. */
3527
+ epoch: number;
3528
+ /** What advanced the epoch. Diagnostic only — see the type's doc. */
3529
+ reason: AuthzInvalidationReason;
3530
+ /** Wall-clock publish time, ms since epoch. Best-effort. */
3531
+ at: number;
3532
+ }
3533
+
3534
+ /**
3535
+ * ── The boot-time authorization-cache posture statement (#11968, #11633 §3) ──
3536
+ *
3537
+ * ⭐ **Non-optional**, by the 2026-08-25 ruling on #11633 (Fork 2 → B): whenever
3538
+ * a grants cache is enabled and there is **no** cross-node invalidation bus, the
3539
+ * deployment is told so, **out loud**, at boot.
3540
+ *
3541
+ * ## Why a statement and not a refusal
3542
+ *
3543
+ * A per-process cache bounded only by its TTL is a legitimate configuration —
3544
+ * #11633 §3 rules the TTL, not the bus, as the correctness contract, so a
3545
+ * single-node deployment (or one that simply accepts the window) is correct
3546
+ * with no bus at all. What is NOT acceptable is arriving there **without
3547
+ * noticing**: that is the shape of #4785, where a control was silently disabled
3548
+ * by configuration and nothing said so. The metadata bridge logs its own
3549
+ * absence at `debug` and that is right for metadata — a missed
3550
+ * `metadata.changed` costs a stale schema until reload and loses no data. Here
3551
+ * the same silence would cost a permission honoured past its revocation.
3552
+ *
3553
+ * ⇒ Enabled cache + no bus is a `warn`, every boot, naming the window it just
3554
+ * accepted. Not a refusal — a statement.
3555
+ *
3556
+ * ## The three postures, and the reason `disabled` is silent
3557
+ *
3558
+ * - `disabled` — no cache is enabled. **Silent.** There is no window to
3559
+ * state, and a line every boot on the shipped default
3560
+ * (TTL `0`, Fork 4) would train operators to ignore it —
3561
+ * which is how the loud line stops being loud.
3562
+ * - `ttl-only` — cache enabled, no cross-node bus. **LOUD (`warn`).**
3563
+ * - `bus-narrowed` — cache enabled, bus bridged. `info`, so the bridge's
3564
+ * presence is on the record next to its absence.
3565
+ *
3566
+ * Both arms are pinned in `authz-cache-posture.test.ts`: a statement that
3567
+ * appears always is no more useful than one that never appears.
3568
+ */
3569
+ /** Deployment variable that turns the grants cache on. `0` (default) = off. */
3570
+ declare const AUTHZ_GRANTS_CACHE_TTL_ENV = "OS_AUTHZ_GRANTS_CACHE_TTL_MS";
3571
+ /**
3572
+ * What the local node has, in cross-node terms, for delivering
3573
+ * `authz.invalidated`.
3574
+ *
3575
+ * ⚠️ `in-process` is a distinct state on purpose, and it is the one that would
3576
+ * otherwise go unnoticed: `Runtime` auto-registers a **memory** cluster service
3577
+ * by default, so "is a `cluster` service registered?" answers *yes* on the
3578
+ * shipped default while the bus fans out to exactly nobody
3579
+ * (`service-cluster/src/memory/pubsub.ts`: *"No cross-process delivery"*; the
3580
+ * split-brain guard calls the same set `IN_PROCESS_DRIVERS`). A posture check
3581
+ * that asked only whether a service exists would therefore stay silent in
3582
+ * precisely the multi-replica deployment it exists to warn.
3583
+ */
3584
+ type AuthzInvalidationBusState =
3585
+ /** A cross-node transport is attached and carrying the channel. */
3586
+ 'bridged'
3587
+ /** A cluster service exists, but its driver does not cross a process boundary. */
3588
+ | 'in-process'
3589
+ /** No cluster service, or no engine seam to attach one to. */
3590
+ | 'absent';
3591
+ /** The posture a boot resolves to. */
3592
+ type AuthzCachePosture = 'disabled' | 'ttl-only' | 'bus-narrowed';
3593
+ interface AuthzCachePostureInput {
3594
+ /** Configured grants-cache TTL in ms. `<= 0` means the cache is off. */
3595
+ ttlMs: number;
3596
+ /** What the node has for cross-node invalidation. */
3597
+ bus: AuthzInvalidationBusState;
3598
+ /** Cluster driver name, when one is registered. Surfaced in the message. */
3599
+ driver?: string;
3600
+ }
3601
+ interface AuthzCachePostureStatement {
3602
+ posture: AuthzCachePosture;
3603
+ /** True when this must be said at `warn`. See the module doc. */
3604
+ loud: boolean;
3605
+ /** The statement itself. Empty only for the silent `disabled` posture. */
3606
+ message: string;
3607
+ }
3608
+ /**
3609
+ * Resolve the posture. Pure — it reads its inputs and nothing else, so both
3610
+ * arms of the acceptance criterion ("appears exactly when a cache flag is on
3611
+ * without a bus, and not otherwise") are testable without a boot.
3612
+ */
3613
+ declare function resolveAuthzCachePosture(input: AuthzCachePostureInput): AuthzCachePostureStatement;
3614
+ /** The reading of {@link AUTHZ_GRANTS_CACHE_TTL_ENV}, malformed input included. */
3615
+ interface AuthzGrantsCacheTtlReading {
3616
+ /** The effective TTL. `0` whenever the cache is off — including malformed. */
3617
+ ttlMs: number;
3618
+ /** The raw value read, when one was set. */
3619
+ raw?: string;
3620
+ /** True when a value was set but could not be read as a non-negative number. */
3621
+ malformed: boolean;
3622
+ }
3623
+ /**
3624
+ * Read the grants-cache TTL from deployment config.
3625
+ *
3626
+ * Deployment config, never a settings row (#11633 §5): the knob that bounds a
3627
+ * cache must not itself be served through a cached path, and operator-level
3628
+ * configuration comes from the environment.
3629
+ *
3630
+ * Default `0` — the grants cache is **off** unless a deployment turns it on and
3631
+ * accepts the staleness window explicitly (#11633 Fork 4, ruled 2026-08-25).
3632
+ *
3633
+ * ⚠️ A malformed value resolves to `0` but is reported as malformed rather than
3634
+ * folded into "off": `OS_AUTHZ_GRANTS_CACHE_TTL_MS=5OOO` (letter O) silently
3635
+ * meaning "disabled" is the same silent-disable class the posture statement
3636
+ * exists to prevent.
3637
+ */
3638
+ declare function readAuthzGrantsCacheTtlMs(env?: Record<string, string | undefined>): AuthzGrantsCacheTtlReading;
3639
+ /** Minimal sink shape — `warn` is the member every logger in this repo has. */
3640
+ interface AuthzPostureSink {
3641
+ warn(message: string, meta?: Record<string, unknown>): void;
3642
+ info?(message: string, meta?: Record<string, unknown>): void;
3643
+ debug?(message: string, meta?: Record<string, unknown>): void;
3644
+ }
3645
+ /**
3646
+ * State the posture at boot. `warn` for the loud arm, `info` for the bridged
3647
+ * one, and nothing at all when no cache is enabled (see the module doc for why
3648
+ * silence is the right default rather than a courtesy line).
3649
+ *
3650
+ * A malformed TTL value is warned about on its own, because "we read your
3651
+ * setting as off" is exactly what a deployment must not have to infer.
3652
+ */
3653
+ declare function reportAuthzCachePosture(input: AuthzCachePostureInput & {
3654
+ malformedTtl?: {
3655
+ raw?: string;
3656
+ };
3657
+ }, sink: AuthzPostureSink): AuthzCachePostureStatement;
3658
+
2789
3659
  /**
2790
3660
  * Environment utilities for universal (Node/Browser) compatibility.
2791
3661
  */
@@ -3292,6 +4162,71 @@ declare function runMigrationJournal(engine: IObjectQLEngine, plan: MigrationPla
3292
4162
  /** Resume a run the journal says was interrupted. Thin alias for intent at call sites. */
3293
4163
  declare function resumeMigrationJournal(engine: IObjectQLEngine, plan: MigrationPlan, runId: string, options?: Omit<RunMigrationJournalOptions, 'runId'>): Promise<MigrationRunResult>;
3294
4164
 
4165
+ /** Example row references a group carries, so the summary can point at real rows. */
4166
+ declare const ADVISORY_SAMPLE_ROWS = 5;
4167
+ /** One advisory rule hit, as the evaluator reports it. */
4168
+ interface AdvisoryHit {
4169
+ /** Object the row belongs to. */
4170
+ object: string;
4171
+ /** The declared rule's `name`. */
4172
+ rule: string;
4173
+ /** The rule's declared severity — `'warning'` or `'info'`; never `'error'`. */
4174
+ severity: string;
4175
+ /** The rule's author-written message, in the caller's locale. */
4176
+ message: string;
4177
+ /**
4178
+ * A reference to the row, when the write carries one.
4179
+ *
4180
+ * NOT necessarily an id: on the path this exists for — a seed INSERT — the
4181
+ * driver has not issued an id yet at validation time, so an id-only reference
4182
+ * would be empty for exactly the case the aggregation was built for. The
4183
+ * producer sends the best stable handle it has (`id`, else `name=<value>`,
4184
+ * the same way the seed loader names a row in its own errors).
4185
+ */
4186
+ recordRef?: string;
4187
+ }
4188
+ /** Every hit for one `(object, rule)` pair, folded. */
4189
+ interface AdvisoryGroup {
4190
+ object: string;
4191
+ rule: string;
4192
+ severity: string;
4193
+ /** The first message seen for this group (they differ only by interpolation). */
4194
+ message: string;
4195
+ /** How many ROWS tripped this rule during the scope. */
4196
+ rows: number;
4197
+ /** Up to {@link ADVISORY_SAMPLE_ROWS} example row references. */
4198
+ sampleRows: string[];
4199
+ }
4200
+ /**
4201
+ * Offer one advisory hit to the active aggregation scope.
4202
+ *
4203
+ * @returns `true` when a scope captured it — the caller must then NOT log its
4204
+ * own per-row line, because the scope owner reports the whole group. `false`
4205
+ * when no scope is active, which is the ordinary interactive case: the caller
4206
+ * logs exactly as it always did. A caller that ignores the return value
4207
+ * degrades to today's behaviour rather than losing the report.
4208
+ */
4209
+ declare function recordAdvisoryHit(hit: AdvisoryHit): boolean;
4210
+ /**
4211
+ * Whether an advisory aggregation scope is active on this async context.
4212
+ *
4213
+ * Exported for tests and for a caller that wants to skip building a message it
4214
+ * is about to discard; {@link recordAdvisoryHit}'s return value is the one that
4215
+ * decides.
4216
+ */
4217
+ declare function isAggregatingAdvisories(): boolean;
4218
+ /**
4219
+ * Run `fn` with advisory hits aggregated, then hand the folded groups to
4220
+ * `report`.
4221
+ *
4222
+ * `report` runs in a `finally`, so a load that throws still reports what it
4223
+ * tripped before failing — the diagnostics of a half-finished seed are the ones
4224
+ * most worth having. It is called only when there is something to report, and
4225
+ * its own failure is never allowed to replace the caller's outcome: a reporting
4226
+ * bug must not turn a successful seed load into a failed one.
4227
+ */
4228
+ declare function runWithAdvisoryAggregation<T>(fn: () => Promise<T>, report: (groups: AdvisoryGroup[]) => void): Promise<T>;
4229
+
3295
4230
  /**
3296
4231
  * The slice of an execution context the resolver reads. Structural on purpose —
3297
4232
  * see {@link filterTokenContextFrom}.
@@ -3407,6 +4342,204 @@ declare function temporalComparandKind(fieldType: unknown): TemporalComparandKin
3407
4342
  */
3408
4343
  declare function isUninterpretableTemporalComparand(kind: TemporalComparandKind, value: unknown): boolean;
3409
4344
 
4345
+ /**
4346
+ * [ADR-0126 §4] THE activation-ledger row contract — one implementation,
4347
+ * parameterized by `metadata_type`.
4348
+ *
4349
+ * ## Why this file exists (#12350)
4350
+ *
4351
+ * ADR-0126 §4 declares ONE activation ledger for the whole disable+clone
4352
+ * family. It briefly had two implementations of that one row contract:
4353
+ *
4354
+ * | Implementation | Package | Landed |
4355
+ * | :-------------------------------- | :--------------------------------- | :----- |
4356
+ * | `ObjectStoreFlowActivationStore` | `@objectstack/service-automation` | #12296 |
4357
+ * | `ObjectStoreActionActivationStore`| `@objectstack/objectql` | #12348 |
4358
+ *
4359
+ * They agreed on every load-bearing detail because the second was written from
4360
+ * the first — and nothing structurally held them together. ADR-0126 §8
4361
+ * pre-charts `tool`, `skill` and `position` as later consumers, and a third
4362
+ * and fourth copy is where the row semantics start drifting: the org-row skip
4363
+ * and the `0`-is-false read are exactly the kind of detail a copy loses
4364
+ * quietly, in a direction (an artifact silently re-arming) nothing else
4365
+ * measures.
4366
+ *
4367
+ * ## Why the code lives HERE and the object does not
4368
+ *
4369
+ * Neither consumer could import the other: `@objectstack/service-automation`
4370
+ * does not depend on `@objectstack/objectql` (devDependency only), and the
4371
+ * engine must not depend on a service — the dependency arrow points the other
4372
+ * way. `@objectstack/core` is the package BOTH already depend on, so this is
4373
+ * the one home that needs no new edge. ⛔ NOT `@objectstack/platform-objects`,
4374
+ * which declares the OBJECT: `objectql` does not depend on it and adding that
4375
+ * edge would invert the tiering, since platform-objects is a catalog the
4376
+ * engine serves. That is a MODULE-IMPORT question, and it is independent of
4377
+ * where the object's REGISTRATION lives (a composition question, ruled
4378
+ * separately on #12359 — `PlatformObjectsPlugin`).
4379
+ *
4380
+ * The table is reached by NAME, never by importing the declaration, exactly as
4381
+ * the engine already reaches `sys_metadata` / `sys_secret`.
4382
+ *
4383
+ * ## The row shape — ⛔ this module writes COLUMNS, never schema
4384
+ *
4385
+ * `metadata_type` · `name` · `package_id` · `active`, exactly the four
4386
+ * ADR-0126 §4 declares. Three properties are load-bearing and each is pinned on
4387
+ * both consumers' sides (`flow-activation-ledger.test.ts`,
4388
+ * `action-activation.test.ts` — unchanged by the consolidation, which is what
4389
+ * makes them the proof it lost nothing):
4390
+ *
4391
+ * - **The ledger is DEPLOYMENT-level, and carries no tenant column at all.**
4392
+ * A row says "this environment switched this managed item off" — a fact no
4393
+ * organization owns. The table briefly declared a nullable tenant column
4394
+ * marked RESERVED and never written, and this module correspondingly
4395
+ * filtered reads to the NULL ones and skipped any row carrying an
4396
+ * organization. Both are gone: a reserved nullable tenant
4397
+ * column is the shape the total-organization-ownership record proposed in
4398
+ * PR #14976 rules out, so the column was dropped before it ever shipped
4399
+ * (17.2.0 predates the table). There is no filter here any more because
4400
+ * there is no column to filter on — `list()` is simply every activation
4401
+ * row of this type. Should a per-organization dimension ever be wanted, it
4402
+ * returns as a separate org-owned object, never as a column here.
4403
+ * - **Absence of a row means ACTIVE.** Nothing here ever writes a row to say
4404
+ * "active by default", and `list()` returning nothing is the normal
4405
+ * stock-boot state, not an error. Re-enabling UPDATES the row to
4406
+ * `active: true` rather than deleting it, so the ledger records the
4407
+ * administrator's CHOICE instead of erasing it (§6 wall 3) — which is why
4408
+ * {@link MetadataActivationStoreEngine} deliberately has no `delete`.
4409
+ * - **A driver `0` reads as false.** SQLite/libsql round-trip booleans as
4410
+ * 0/1; a `!== false` test alone would read a disabled artifact as armed.
4411
+ *
4412
+ * ## The discriminator is never optional
4413
+ *
4414
+ * The ledger is generic and shared — flow rows and action rows live in the
4415
+ * same table today, and §8 charts more. Every read and write below is scoped
4416
+ * by `metadata_type`, so no consumer can touch a neighbour's state through a
4417
+ * table all of them are told to treat as generic. It is a constructor
4418
+ * argument, not a per-call one, so a caller cannot forget it at a single site.
4419
+ */
4420
+ /**
4421
+ * The ledger table. A NAME, not an import: the object is declared in
4422
+ * `@objectstack/platform-objects` and this package must not depend on it.
4423
+ */
4424
+ declare const METADATA_ACTIVATION_TABLE = "sys_metadata_activation";
4425
+ /**
4426
+ * [ADR-0126 §4] One packaged artifact's install-level activation row.
4427
+ *
4428
+ * The ledger's own columns are `metadata_type` / `name` / `package_id` /
4429
+ * `active`; `metadata_type` is fixed by the store, so it never reaches a
4430
+ * consumer's projection.
4431
+ */
4432
+ interface MetadataActivationRow {
4433
+ /** The packaged artifact's declarative machine name (ADR-0126 §4). */
4434
+ name: string;
4435
+ /** The package that ships the base artifact. */
4436
+ packageId: string;
4437
+ /** Is the packaged artifact armed for this installation. */
4438
+ active: boolean;
4439
+ }
4440
+ /**
4441
+ * [ADR-0126 §4] The durable off-switch for one class of packaged artifact.
4442
+ *
4443
+ * Absence of a row means the packaged default — ACTIVE — so a runtime with no
4444
+ * store attached, or a store with no rows, behaves exactly as a stock boot
4445
+ * always has.
4446
+ */
4447
+ interface MetadataActivationStore {
4448
+ /** Every activation row for this type — the ledger is deployment-wide. */
4449
+ list(): Promise<MetadataActivationRow[]>;
4450
+ /** Insert or update the row for one packaged artifact. */
4451
+ setActive(row: MetadataActivationRow): Promise<void>;
4452
+ }
4453
+ /**
4454
+ * The exact engine slice this store needs: a keyed read, an insert and an
4455
+ * update. Deliberately WITHOUT `delete` — re-enabling updates the `active`
4456
+ * bit, it never removes the row (see the module header), and demanding only
4457
+ * what is used keeps every test double honest about that.
4458
+ */
4459
+ interface MetadataActivationStoreEngine {
4460
+ find(object: string, options?: any): Promise<any[]>;
4461
+ insert(object: string, data: any, options?: any): Promise<any>;
4462
+ update(object: string, data: any, options?: any): Promise<any>;
4463
+ }
4464
+ /**
4465
+ * In-memory {@link MetadataActivationStore} — process-lifetime only, for tests
4466
+ * and for hosts with no durable plane. What it lacks versus the ObjectStore
4467
+ * implementation is DURABILITY, which is exactly the property ADR-0126 §6
4468
+ * wall 3 asks for; it is not a sanctioned production off-switch.
4469
+ *
4470
+ * No discriminator: an in-memory map is per-instance, so there is no shared
4471
+ * table for a neighbouring type's rows to be in.
4472
+ */
4473
+ declare class InMemoryMetadataActivationStore implements MetadataActivationStore {
4474
+ private readonly rows;
4475
+ list(): Promise<MetadataActivationRow[]>;
4476
+ setActive(row: MetadataActivationRow): Promise<void>;
4477
+ }
4478
+ /**
4479
+ * Durable {@link MetadataActivationStore} backed by the
4480
+ * `sys_metadata_activation` object (ADR-0126 §4), scoped to one
4481
+ * `metadata_type`.
4482
+ *
4483
+ * All access uses a system context: the object is `managedBy: 'engine-owned'`
4484
+ * and declares `apiMethods: ['get', 'list']`, i.e. the generic data API cannot
4485
+ * write it at all — these rows are written by the ADR-0126 enable/disable
4486
+ * doors and by nothing else.
4487
+ */
4488
+ declare class ObjectStoreMetadataActivationStore implements MetadataActivationStore {
4489
+ private readonly engine;
4490
+ /**
4491
+ * The ledger's `metadata_type` discriminator for this consumer —
4492
+ * `'flow'`, `'action'`, … Required, and fixed for the store's
4493
+ * lifetime: see the module header on why it is never a per-call
4494
+ * argument.
4495
+ */
4496
+ private readonly metadataType;
4497
+ constructor(engine: MetadataActivationStoreEngine,
4498
+ /**
4499
+ * The ledger's `metadata_type` discriminator for this consumer —
4500
+ * `'flow'`, `'action'`, … Required, and fixed for the store's
4501
+ * lifetime: see the module header on why it is never a per-call
4502
+ * argument.
4503
+ */
4504
+ metadataType: string);
4505
+ /**
4506
+ * Every row of this type. Read once at boot to hydrate the consumer's
4507
+ * projection.
4508
+ *
4509
+ * The only scoping is the `metadata_type` discriminator: the ledger is
4510
+ * deployment-wide and has no tenant column, so there is no second axis to
4511
+ * filter on (see the module header).
4512
+ */
4513
+ list(): Promise<MetadataActivationRow[]>;
4514
+ /**
4515
+ * Insert or update the row for one packaged artifact.
4516
+ *
4517
+ * Read-then-write rather than a blind upsert because the object's
4518
+ * uniqueness is a DECLARED index (`unique: 'global'` over
4519
+ * `(metadata_type, name)`), not a primary key this store controls: there is
4520
+ * no id to collide on, so an insert-and-catch could not tell "already
4521
+ * there" from a real store failure.
4522
+ *
4523
+ * That index is also why taking the FIRST match is taking the only one: the
4524
+ * read below is keyed on exactly the index's two columns, so it can match
4525
+ * at most one row. It used to pick the first row with a NULL organization
4526
+ * out of the result, back when the table carried a reserved tenant column;
4527
+ * with no such column the set it was choosing from can no longer hold more
4528
+ * than one member.
4529
+ */
4530
+ setActive(row: MetadataActivationRow): Promise<void>;
4531
+ /**
4532
+ * Read the backing table once so a misconfiguration surfaces at BOOT
4533
+ * rather than as a failed toggle later. Throws the driver error verbatim —
4534
+ * `no such table: sys_metadata_activation` means the object was never
4535
+ * registered (or its schema never synced) in this composition.
4536
+ *
4537
+ * ⚠️ Unscoped by design: the question is "does the TABLE read at all",
4538
+ * which is a property of the composition, not of one `metadata_type`.
4539
+ */
4540
+ probe(): Promise<void>;
4541
+ }
4542
+
3410
4543
  /**
3411
4544
  * [#4435] The 404 a single-record operation answers when the id names no row.
3412
4545
  *
@@ -3619,6 +4752,19 @@ declare function createMemoryI18n(): {
3619
4752
  setSupportedLocales(locales: readonly string[] | undefined): void;
3620
4753
  getDefaultLocale(): string;
3621
4754
  setDefaultLocale(locale: string): void;
4755
+ /**
4756
+ * @see II18nService.setFallbackLocale — [#15694]
4757
+ *
4758
+ * ⛔ There is deliberately NO `getFallbackLocale()` beside this. The two
4759
+ * are different questions: this one is what the provider was TOLD, the
4760
+ * accessor is what the serving layer ASKS it in order to build the
4761
+ * metadata-document translators' fallback chain (#14882). Answering the
4762
+ * second from `defaultLocale` — the only value that was always available
4763
+ * here — would settle the default-locale contract question #14882 leaves
4764
+ * deliberately open, from a degraded provider. Without the accessor those
4765
+ * reads keep the resolvers' own default, which is known and intentional.
4766
+ */
4767
+ setFallbackLocale(locale: string): void;
3622
4768
  };
3623
4769
 
3624
4770
  /**
@@ -3710,7 +4856,7 @@ declare const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
3710
4856
  * (#7378 row 2). Folds a plural manifest spelling to the singular metadata
3711
4857
  * type name (`'objects'` → `'object'`, `'views'` → `'view'`, …) through the
3712
4858
  * platform's one plural↔singular map (`PLURAL_TO_SINGULAR`,
3713
- * `@objectstack/spec/shared`); a name with no plural mapping — which includes
4859
+ * `@objectstack/spec/meta-spelling`); a name with no plural mapping — which includes
3714
4860
  * every canonical singular type — passes through unchanged.
3715
4861
  */
3716
4862
  declare function canonicalMetadataServiceType(type: string): string;
@@ -3739,8 +4885,37 @@ declare function assertMetadataRegisterContract(type: string, name: string, data
3739
4885
  /**
3740
4886
  * Plugin Health Monitor
3741
4887
  *
3742
- * Monitors plugin health status and performs automatic recovery actions.
3743
- * Implements the advanced lifecycle health monitoring protocol.
4888
+ * Monitors plugin health status. It REPORTS; it does not act on what it finds.
4889
+ *
4890
+ * ## The monitor no longer "restarts" anything (#12032)
4891
+ *
4892
+ * It used to claim it did. `attemptRestart` called `plugin.destroy()` and
4893
+ * stopped there — the comment above the call read "Call destroy and init to
4894
+ * restart", and `init` appeared in this file ONLY inside that comment. What a
4895
+ * plugin got was: destroy, a log line reading 'Plugin restarted', status
4896
+ * `recovering`, and periodic checks continuing against the destroyed instance.
4897
+ * The default check when no `checkMethod` resolves is
4898
+ * `{ name: 'plugin-loaded', status: 'passed' }`, which a destroyed plugin
4899
+ * passes indefinitely, so the TERMINAL report on a destroyed, never
4900
+ * re-initialised plugin was `healthy` — and #11955 made that MORE convincing
4901
+ * rather than less, because reaching `healthy` now costs `successThreshold`
4902
+ * consecutive passing rounds.
4903
+ *
4904
+ * The restart could not be repaired in place. `Plugin.init(ctx)` needs a
4905
+ * `PluginContext`, and the only two `plugin.init(...)` call sites in the tree
4906
+ * are the kernel's own boot loops, over the full plugin list, with a context
4907
+ * that is `private` on `ObjectKernel` and `protected` on `KernelBase`. No host
4908
+ * can obtain one, so there was nothing for a re-init hook to call. ADR-0049
4909
+ * enforce-or-remove, with no roadmap to point EXPERIMENTAL at, therefore
4910
+ * removed the declaration: `autoRestart`, `maxRestartAttempts` and
4911
+ * `restartBackoff` are tombstoned in `@objectstack/spec` 18, and this class
4912
+ * refuses a config that still carries one instead of accepting it and doing
4913
+ * something else.
4914
+ *
4915
+ * What a failing plugin gets now is the truth: `degraded`, `unhealthy` or
4916
+ * `failed`, and no destroy. Acting on that is the HOST's job — this is a
4917
+ * host-driven library (#11825 route 2), and the host is the only party that
4918
+ * owns the plugin's lifetime.
3744
4919
  */
3745
4920
  declare class PluginHealthMonitor {
3746
4921
  private logger;
@@ -3750,7 +4925,6 @@ declare class PluginHealthMonitor {
3750
4925
  private checkIntervals;
3751
4926
  private failureCounters;
3752
4927
  private successCounters;
3753
- private restartAttempts;
3754
4928
  constructor(logger: ObjectLogger);
3755
4929
  /**
3756
4930
  * Register a plugin for health monitoring
@@ -3769,13 +4943,28 @@ declare class PluginHealthMonitor {
3769
4943
  */
3770
4944
  private performHealthCheck;
3771
4945
  /**
3772
- * Attempt to restart a plugin
3773
- */
3774
- private attemptRestart;
3775
- /**
3776
- * Calculate backoff delay for restarts
4946
+ * Handle one failed round — the single path BOTH failure routes take.
4947
+ *
4948
+ * `performHealthCheck` can fail two disjoint ways: the check *returns* a
4949
+ * failure (`false` or `{ status: 'unhealthy' }`), or it *throws* — which by
4950
+ * `raceCheckTimeout` includes every `timeout` overrun, the severest case of
4951
+ * the two. The routes used to be handled in separate blocks, and only the
4952
+ * returned one cleared `successCounters`, so the counters a declared
4953
+ * `failureThreshold` / `successThreshold` are counted with depended on which
4954
+ * way the round happened to fail (#11852).
4955
+ *
4956
+ * What stays route-specific is the *status label*, deliberately. A throw is
4957
+ * the separate `failed` status applied immediately with no threshold — that
4958
+ * is the documented contract (`content/docs/protocol/kernel/lifecycle.mdx`,
4959
+ * "Custom Health Checks") and is pinned by the timeout test. Only the
4960
+ * counters are shared, because that is what `failureThreshold` declares, and
4961
+ * it does not name a route.
4962
+ *
4963
+ * This round ENDS here. Nothing is done TO the plugin — see the #12032 note
4964
+ * on the class: a monitor that cannot re-initialise a plugin has no business
4965
+ * destroying one.
3777
4966
  */
3778
- private calculateBackoff;
4967
+ private recordFailedRound;
3779
4968
  /**
3780
4969
  * Get current health status of a plugin
3781
4970
  */
@@ -3857,7 +5046,6 @@ declare class HotReloadManager {
3857
5046
  private logger;
3858
5047
  private stateManager;
3859
5048
  private reloadConfigs;
3860
- private watchHandles;
3861
5049
  private reloadTimers;
3862
5050
  constructor(logger: ObjectLogger);
3863
5051
  /**
@@ -3865,11 +5053,35 @@ declare class HotReloadManager {
3865
5053
  */
3866
5054
  registerPlugin(pluginName: string, config: HotReloadConfigParsed): void;
3867
5055
  /**
3868
- * Start watching for changes (requires file system integration)
5056
+ * Refuse the file-watching call this class never implemented (#12428).
5057
+ *
5058
+ * The body used to be a guard plus `logger.info('File watching started')`
5059
+ * over an in-source note saying real watching "would require chokidar or
5060
+ * similar". Nothing was ever watched, so an operator who set
5061
+ * `enabled: true` and read that line at INFO had been told the opposite of
5062
+ * the truth — positive confirmation of a capability that did not exist.
5063
+ * ADR-0049 leaves three states and this surface qualified for none of the
5064
+ * other two: no runtime composes this class, so ENFORCE would build for a
5065
+ * caller that does not exist, and no roadmap entry anywhere claims the
5066
+ * feature, so EXPERIMENTAL would be a promise nobody made.
5067
+ *
5068
+ * Kept as a throwing door rather than deleted: removing the method leaves a
5069
+ * JavaScript host a bare `TypeError: not a function` with no prescription,
5070
+ * and this is the one place a caller of the old placeholder is guaranteed
5071
+ * to arrive. The refusal carries an ADR-0112 envelope so it can be asserted
5072
+ * rather than merely caught.
3869
5073
  */
3870
- startWatching(pluginName: string): void;
5074
+ startWatching(pluginName: string): never;
3871
5075
  /**
3872
- * Stop watching for changes
5076
+ * Cancel a pending debounced reload for a plugin.
5077
+ *
5078
+ * The name is historical (#12428). This never stopped a watcher, because
5079
+ * nothing in this class ever started one: its `watchHandles` cleanup branch
5080
+ * read a Map that had no writer anywhere in the tree, so the branch was
5081
+ * structurally unreachable rather than merely untaken, and it left with
5082
+ * `startWatching`'s placeholder. What survives is the half that always did
5083
+ * something — the debounce timer armed by `scheduleReload` is cleared, so a
5084
+ * reload that was scheduled but has not fired yet is cancelled.
3873
5085
  */
3874
5086
  stopWatching(pluginName: string): void;
3875
5087
  /**
@@ -4053,4 +5265,4 @@ declare class NamespaceResolver {
4053
5265
  private suggestAlternative;
4054
5266
  }
4055
5267
 
4056
- export { ADMIN_STANDING_SURFACE, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, API_KEY_PREFIX, AUDIENCE_BINDING_SUGGESTION_STATUSES, AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES, type ActivatableRow, type AdminStandingTable, type AnonymousDenyInput, type ApiKeyAdmission, type ApiKeyPrincipal, type ApiKeyRefusalReason, type AudienceBindingSuggestionStatus, type AuthGate, type BucketGranularity, type BulkWriteOptions, type BulkWriteRowResult, CORE_FALLBACK_FACTORIES, type CalendarParts, DependencyResolver, ENTRY_EXECUTION_CONTEXT_FIELDS, type EngineWithTransaction, type EntryExecutionContextField, type EntryLocalization, type ExecutionContextAssemblyInput, type ExecutionContextEntryFields, type ExecutionContextLike, type FilterTokenResolutionContext, type GeneratedApiKey, type GrantValidityWindow, HotReloadManager, type InterruptedRun, type KernelState, type KeyInput, type LadderPrincipal, type LadderRow, LiteKernel, type MigrationChunk, type MigrationChunkContext, MigrationJournalRefusal, type MigrationPlan, type MigrationPlanProvider, MigrationPlanRegistry, type MigrationPlanStep, type MigrationRunResult, type NamespaceCheckResult, type NamespaceConflict, type NamespaceEntry, NamespaceResolver, type OAuthTokenProvenance, OPERATION_PRIVATE_KEY_PREFIX, ObjectKernel, ObjectKernelBase, type ObjectKernelConfig, ObjectLogger, type OrderablePlugin, POSTURE_INJECTION_RULE, POSTURE_LADDER, POSTURE_RANK, type ParsedSignature, type PermissionCheckResult$1 as PermissionCheckResult, type PermissionGrant, type Plugin, type PluginArtifactVerifyResult, PluginConfigValidator, type PluginContext, PluginHealthMonitor, type PluginHealthStatus, type PluginLoadResult, PluginLoader, type PluginMetadata, type PermissionCheckResult as PluginPermissionCheckResult, PluginPermissionEnforcer, PluginPermissionManager, type PluginPermissions, PluginSandboxRuntime, PluginSecurityScanner, type PluginSignatureConfig, PluginSignatureVerifier, type PluginStartupResult, type PostureEvidence, type PublisherVerifyResult, index as QA, type ResolveAuthzInput, type ResolveLocalizationInput, type ResolveUserAuthzGrantsOptions, type ResolvedAuthzContext, type ResourceUsage, type RetryOptions, type RunMigrationJournalOptions, SIGNATURE_ALG, type SandboxContext, type ScanTarget, SecurePluginContext, type SecurityIssue, SemanticVersionManager, type ServiceFactory, ServiceLifecycle, type ServiceRegistration, type SignatureVerificationResult, type TemporalComparandKind, type TenancyPostureSource, UnknownFilterTokenError, UnresolvedFilterTokenError, type UserAuthzGrants, type VersionCompatibility, type WallClockParts, adminStandingColumns, adminStandingTables, assembleExecutionContext, assembleExecutionContextOrGuest, assertInitServiceRequirements, assertMetadataRegisterContract, bucketKeyToCalendarRange, buildPermissionsFromGrants, bulkWrite, calendarPartsInTz, calendarPartsInTzOrUtc, canonicalMetadataServiceType, collectInternalWriteResponseFields, counterSignPayload, createMemoryCache, createMemoryI18n, createMemoryJob, createMemoryMetadata, createMemoryQueue, createPluginConfigValidator, createPluginPermissionEnforcer, deepMerge, defaultIsTransientError, derivePosture, describeInitOrderFault, effectiveTenancyPosture, engineCanRollBack, evaluateAuthGate, extractApiKey, filterTokenContextFrom, findInterruptedRuns, generateApiKey, generateEd25519KeyPair, getEnv, getMemoryUsage, hashApiKey, hashMigrationPlan, isAudienceBindingSuggestionStatus, isAuthGateAllowlisted, isExpired, isGrantActive, isGrantExpired, isNode, isRowActive, isUninterpretableTemporalComparand, normalizeAuthGate, omitInternalFieldsFromWriteResponse, parseScopes, parseSignature, planChunks, postureVisibleRows, readAuthoredTranslationLayer, readRunJournal, recordNotFoundError, resolveApiKeyAdmission, resolveApiKeyPrincipal, resolveAuthzContext, resolveFilterToken, resolveFilterTokens, resolveLocale, resolveLocalizationContext, resolvePluginOrder, resolveUserAuthzGrants, resumeMigrationJournal, runMigrationJournal, safeExit, shouldDenyAnonymous, signPayload, temporalComparandKind, unknownAudienceBindingSuggestionStatusMessage, validateInitServiceContract, verifyPayload, verifyPlatformSignature, verifyPluginArtifact, verifyPublisherSignature, wireAuthoredTranslationSync, withTransientRetry, withoutOperationPrivateKeys, zonedDateStartToUtcMs, zonedWallClockToUtcMs };
5268
+ export { ADMIN_STANDING_NON_TABLE_INPUTS, ADMIN_STANDING_SURFACE, ADVISORY_SAMPLE_ROWS, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_CODE, ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, API_KEY_PREFIX, AUDIENCE_BINDING_SUGGESTION_STATUSES, AUDIENCE_BINDING_SUGGESTION_STATUS_VALUES, AUTHZ_GRANTS_CACHE_TTL_ENV, AUTHZ_INVALIDATED_CHANNEL, AUTHZ_STORE_UNAVAILABLE_CODE, AUTHZ_STORE_UNAVAILABLE_MESSAGE, AUTHZ_STORE_UNAVAILABLE_STATUS, type ActivatableRow, type AdminStandingNonTableInput, type AdminStandingTable, type AdvisoryGroup, type AdvisoryHit, type AnonymousDenyInput, type ApiKeyAdmission, type ApiKeyPrincipal, type ApiKeyRefusalReason, type ArtifactPackageError, type AudienceBindingSuggestionStatus, type AuthGate, type AuthzCachePosture, type AuthzCachePostureInput, type AuthzCachePostureStatement, type AuthzGrantsCacheTtlReading, type AuthzInvalidatedPayload, type AuthzInvalidationBusState, type AuthzInvalidationReason, type AuthzPostureSink, AuthzStoreUnavailableError, type BucketGranularity, type BulkWriteOptions, type BulkWriteRowResult, CORE_FALLBACK_FACTORIES, type CalendarParts, DependencyResolver, ENTRY_EXECUTION_CONTEXT_FIELDS, type EngineWithTransaction, type EntryExecutionContextField, type EntryLocalization, type ExecutionContextAssemblyInput, type ExecutionContextEntryFields, type ExecutionContextLike, type FilterTokenResolutionContext, type GeneratedApiKey, type GrantValidityWindow, HotReloadManager, InMemoryMetadataActivationStore, type IntegrityFile, type IntegrityViolation, type IntegrityViolationKind, type InterruptedRun, type KernelState, type KeyInput, type LadderPrincipal, type LadderRow, LiteKernel, METADATA_ACTIVATION_TABLE, type MetadataActivationRow, type MetadataActivationStore, type MetadataActivationStoreEngine, type MigrationChunk, type MigrationChunkContext, MigrationJournalRefusal, type MigrationPlan, type MigrationPlanProvider, MigrationPlanRegistry, type MigrationPlanStep, type MigrationRunResult, type NamespaceCheckResult, type NamespaceConflict, type NamespaceEntry, NamespaceResolver, type OAuthTokenProvenance, OPERATION_PRIVATE_KEY_PREFIX, ObjectKernel, ObjectKernelBase, type ObjectKernelConfig, ObjectLogger, ObjectStoreMetadataActivationStore, type OrderablePlugin, PLATFORM_ADMIN_EMAIL_SEPARATOR, POSTURE_INJECTION_RULE, POSTURE_LADDER, POSTURE_RANK, type ParsedSignature, type PermissionCheckResult$1 as PermissionCheckResult, type PermissionGrant, type PlatformAdminConfigSink, type PlatformAdminEmailConfig, type Plugin, type PluginArtifactVerifyResult, type PluginContext, PluginHealthMonitor, type PluginHealthStatus, type PluginLoadResult, PluginLoader, type PluginMetadata, type PermissionCheckResult as PluginPermissionCheckResult, PluginPermissionEnforcer, PluginPermissionManager, type PluginPermissions, PluginSandboxRuntime, type PluginSignatureConfig, PluginSignatureVerifier, type PluginStartupResult, type PluginType, type PostureEvidence, type PublisherVerifyResult, index as QA, type ResolveAuthzInput, type ResolveLocalizationInput, type ResolveUserAuthzGrantsOptions, type ResolvedAuthzContext, type ResourceUsage, type RetryOptions, type RunMigrationJournalOptions, SERVICE_NOT_REGISTERED_CODE, SIGNATURE_ALG, type SandboxContext, SecurePluginContext, SemanticVersionManager, type ServiceFactory, ServiceLifecycle, type ServiceRegistration, type SignatureVerificationResult, type TemporalComparandKind, type TenancyPostureSource, UnknownFilterTokenError, UnresolvedFilterTokenError, type UserAuthzGrants, type VerifyIntegrityResult, type VersionCompatibility, type WallClockParts, adminStandingColumns, adminStandingTables, artifactPackageId, assembleExecutionContext, assembleExecutionContextOrGuest, assertInitServiceRequirements, assertMetadataRegisterContract, bucketKeyToCalendarRange, buildPermissionsFromGrants, bulkWrite, calendarPartsInTz, calendarPartsInTzOrUtc, canonicalMetadataServiceType, collectInternalWriteResponseFields, counterSignPayload, createMemoryCache, createMemoryI18n, createMemoryJob, createMemoryMetadata, createMemoryQueue, createPluginPermissionEnforcer, deepMerge, defaultIsTransientError, derivePosture, describeInitOrderFault, effectiveTenancyPosture, engineCanRollBack, evaluateAuthGate, extractApiKey, filterTokenContextFrom, findInterruptedRuns, formatIntegrityViolation, generateApiKey, generateEd25519KeyPair, getEnv, getMemoryUsage, hasPlatformAdminStanding, hashApiKey, hashMigrationPlan, isAggregatingAdvisories, isAudienceBindingSuggestionStatus, isAuthGateAllowlisted, isAuthzStoreUnavailableError, isConfiguredPlatformAdminEmail, isExpired, isGrantActive, isGrantExpired, isNode, isRowActive, isServiceNotRegisteredError, isUninterpretableTemporalComparand, matchesConfiguredPlatformAdmin, normalizeAuthGate, normalizePlatformAdminEmail, omitInternalFieldsFromWriteResponse, parsePlatformAdminEmails, parseScopes, parseSignature, planChunks, postureVisibleRows, readAuthoredTranslationLayer, readAuthzGrantsCacheTtlMs, readRunJournal, recordAdvisoryHit, recordNotFoundError, reportAuthzCachePosture, reportLegacyPlatformAdminGrant, resetLegacyPlatformAdminGrantReport, resetPlatformAdminEmailMemo, resolveApiKeyAdmission, resolveApiKeyPrincipal, resolveArtifactPackageOrder, resolveAuthzCachePosture, resolveAuthzContext, resolveFilterToken, resolveFilterTokens, resolveLocale, resolveLocalizationContext, resolvePlatformAdminEmails, resolvePluginOrder, resolveUserAuthzGrants, resumeMigrationJournal, rethrowAuthzStoreUnavailable, runMigrationJournal, runWithAdvisoryAggregation, safeExit, setPlatformAdminConfigSink, shouldDenyAnonymous, signPayload, temporalComparandKind, unknownAudienceBindingSuggestionStatusMessage, validateInitServiceContract, verifyIntegrity, verifyPayload, verifyPlatformSignature, verifyPluginArtifact, verifyPublisherSignature, wireAuthoredTranslationSync, withTransientRetry, withoutOperationPrivateKeys, zonedDateStartToUtcMs, zonedWallClockToUtcMs };