@objectstack/types 17.2.0 → 17.3.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.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { TenancyPosture } from '@objectstack/spec/security';
2
2
  import { ErrorCode, ApiError, FieldErrorCode } from '@objectstack/spec/api';
3
+ import { Logger } from '@objectstack/spec/contracts';
3
4
 
4
5
  /**
5
6
  * Degraded-boot reporting, shared by every subsystem that can be told to boot
@@ -44,6 +45,47 @@ import { ErrorCode, ApiError, FieldErrorCode } from '@objectstack/spec/api';
44
45
  */
45
46
  declare function emitDegradedBootBanner(message: string): void;
46
47
 
48
+ /**
49
+ * [#11343 / #12751] Verified-email predicate over a stored `sys_user` row — a
50
+ * fail-closed ALLOW-LIST over the representations a driver may hand back for
51
+ * the `sys_user.email_verified` boolean column (JS `true`, SQLite `1`, and
52
+ * their stringified forms). Everything else — `false`/`0`, `null`, an ABSENT
53
+ * field on an imported/legacy row, or any representation not listed — reads
54
+ * as UNVERIFIED. Absent-means-unverified is deliberate: treating a missing
55
+ * column as verified would re-open the exact hole this predicate closes for
56
+ * every row that predates the column.
57
+ *
58
+ * ONE resolution, several consumers, by design (#12751) — and since the
59
+ * #11663 platform-admin re-anchor (leg L4) the walled platform-admin
60
+ * ELEVATION GATE this paragraph used to name first is RETIRED: under a
61
+ * walled posture `bootstrapPlatformAdmin` writes no grant row and elevates
62
+ * nobody, it reports. Standing is derived PER REQUEST instead — from a
63
+ * config-anchored verified email, or the legacy unscoped grant row — so the
64
+ * consumer set now includes the authorization derivation itself:
65
+ *
66
+ * - `matchesConfiguredPlatformAdmin` (`@objectstack/core`
67
+ * `security/platform-admin.ts`), read at the one derivation site
68
+ * (`resolve-authz-context.ts` §6b-config), where an UNVERIFIED account
69
+ * holding a declared address confers nothing — and, through it,
70
+ * `plugin-auth`'s last-admin guard, whose administrator enumeration must
71
+ * answer the same question the resolver does;
72
+ * - `resolvePlatformAdminStanding` (`plugin-security`
73
+ * `platform-admin-service.ts`), the read-only standing/audit answer the
74
+ * walled boot reports from, and `isVerifiedPlatformOwnerRow` beside it
75
+ * (`platform-owner-wall-bypass.ts`), the Layer 0 wall bypass;
76
+ * - the walled owner-verification boot diagnostic (`plugin-auth`
77
+ * `walled-owner-verification-path.ts`, where the check decides whether the
78
+ * declared owner's account is already past needing a verification path).
79
+ *
80
+ * They must all answer "is this row verified?" identically — a drift is no
81
+ * longer just a boot warning forecasting a refusal that will not be made, it
82
+ * is a diagnostic, an audit surface or a guard disagreeing with who actually
83
+ * resolves PLATFORM_ADMIN on the next request. `@objectstack/types` is the
84
+ * shared home every one of those packages already resolves
85
+ * `OS_PLATFORM_OWNER_EMAIL` from (`env.ts`).
86
+ */
87
+ declare function isEmailVerifiedUserRow(row: unknown): boolean;
88
+
47
89
  /**
48
90
  * Environment-variable helpers shared across `@objectstack/*` packages.
49
91
  *
@@ -142,6 +184,49 @@ declare function resolveMultiOrgEnabled(): boolean;
142
184
  * enforced is the `tenancy` service's answer (`isolationActive` / `degraded`).
143
185
  */
144
186
  declare function resolveTenancyPosture(): TenancyPosture;
187
+ /**
188
+ * The env variable naming the deployment's PLATFORM OWNER account
189
+ * (#11184, the framework leg of cloud#1509).
190
+ *
191
+ * Exported as a constant so every message that names it quotes exactly one
192
+ * spelling: the walled boot guard in plugin-auth, and plugin-security's
193
+ * `bootstrapPlatformAdmin` — its fail-closed backstop for an undeclared or
194
+ * refused config, and the config-derived standing it logs beside it.
195
+ *
196
+ * ⚠️ That second site is no longer an ELEVATION refusal. Since the #11663
197
+ * platform-admin re-anchor (leg L4) the walled `bootstrapPlatformAdmin` writes
198
+ * no grant row and elevates nobody — it reports. Standing is derived PER
199
+ * REQUEST at `resolve-authz-context.ts` §6b-config, from a declared address
200
+ * held on a VERIFIED `sys_user` row.
201
+ */
202
+ declare const PLATFORM_OWNER_EMAIL_ENV = "OS_PLATFORM_OWNER_EMAIL";
203
+ /**
204
+ * [#11184 / cloud#1509] Resolve the env-declared platform OWNER email —
205
+ * `OS_PLATFORM_OWNER_EMAIL`.
206
+ *
207
+ * Under a WALLED tenancy posture (`group` / `isolated`) the "first registrant
208
+ * becomes owner/platform admin" bootstrap path is REMOVED (maintainer ruling
209
+ * 2026-08-23, verbatim: 「1509 选择 env 指定 owner 邮箱」): on a walled
210
+ * deployment with self-registration reachable, whoever curls the sign-up
211
+ * endpoint first would otherwise receive the cross-tenant `admin_full_access`
212
+ * grant — measured on a real walled SaaS in cloud#1509. Platform admin is
213
+ * granted ONLY to the account whose email matches this variable, and a walled
214
+ * posture with no value declared REFUSES STARTUP (fail-closed, same reasoning
215
+ * as {@link resolveTenancyPosture}'s throw and ADR-0093 D5) rather than
216
+ * silently reverting to first-registrant elevation.
217
+ *
218
+ * The `single` posture never consults this: "first user is owner" is ruled
219
+ * reasonable there and unchanged.
220
+ *
221
+ * Returns the operator's value trimmed, or `undefined` when unset/blank.
222
+ * Comparison against `sys_user.email` is the CONSUMER's job and must be
223
+ * case-insensitive (this resolver echoes what the operator typed so refusal
224
+ * messages can quote it verbatim).
225
+ *
226
+ * Reads `process.env` live on each call, through `globalThis` like the other
227
+ * resolvers here (this package targets non-Node runtimes too).
228
+ */
229
+ declare function resolvePlatformOwnerEmail(): string | undefined;
145
230
  /**
146
231
  * Escape hatch for the degraded-tenancy boot guard (ADR-0093 D5).
147
232
  *
@@ -257,6 +342,27 @@ declare function resolveMcpStdioAutoStart(): {
257
342
  * Deployments that let users self-create orgs SHOULD set a generous cap.
258
343
  */
259
344
  declare function resolveOrgLimit(): number | undefined;
345
+ /**
346
+ * Maximum number of MEMBERS a single organization may hold, from
347
+ * `OS_ORG_MEMBERSHIP_LIMIT`. A different question from {@link resolveOrgLimit},
348
+ * which caps how many organizations one user may create.
349
+ *
350
+ * Unset → `undefined`, which the auth plugin forwards as "no cap". That default
351
+ * is a product decision, not an omission: seat entitlements are metered on AI
352
+ * seats, and plain membership is not a billed axis, so nothing about the
353
+ * platform wants a member ceiling.
354
+ *
355
+ * It has to be stated explicitly because better-auth's organization plugin
356
+ * substitutes a vendor default of **100** for an absent `membershipLimit`
357
+ * (`count >= (membershipLimit || 100)`), which reaches the operator as
358
+ * `Organization membership limit reached` — a refusal nobody in this codebase
359
+ * ever chose, on an axis the product does not limit.
360
+ *
361
+ * A deployment that DOES want a ceiling (a pilot, a trial tenant) sets a
362
+ * positive integer here. Non-positive or unparsable values read as unset rather
363
+ * than as zero: a typo must not be the thing that locks an organization.
364
+ */
365
+ declare function resolveOrgMembershipLimit(): number | undefined;
260
366
  /**
261
367
  * SINGLE decision point for "is pinyin search recall on?" (#2486).
262
368
  *
@@ -667,7 +773,8 @@ declare function sendOk(res: EnvelopeResponse, data: unknown, status?: number):
667
773
  * ## `extra` is `ApiError`'s own optional fields, not a `Record`
668
774
  *
669
775
  * Merged into `error`, and typed as exactly what `ApiErrorSchema` declares
670
- * beside `code` and `message` — `details`, `category`, `requestId`, `httpStatus`.
776
+ * beside `code` and `message` — `details`, `category`, `requestId`,
777
+ * `httpStatus`, `declaredCode`, `userMessage`.
671
778
  * `details` is the slot for structured context: `package-routes` puts a partial
672
779
  * delete's per-item failures there, `settings-routes` the whole
673
780
  * `SettingsActionResult`.
@@ -683,8 +790,80 @@ declare function sendOk(res: EnvelopeResponse, data: unknown, status?: number):
683
790
  * Closing it at the shared builder is the part that lasts: an undeclared sibling
684
791
  * is now a compile error in every module at once, rather than a key that quietly
685
792
  * evaporates at the schema boundary in whichever module reintroduces it.
793
+ *
794
+ * ## `declaredCode` — declared by the schema, barred by this writer
795
+ *
796
+ * ADR-0112's 2026-08-17 amendment (#9106, extended to the flat `/data` door by
797
+ * #9232) rules the demote at EVERY door: `code` stays the closed vocabulary,
798
+ * and a thrown code that is not a member is demoted to a declared sibling,
799
+ * `ApiError.declaredCode` — the open, author-authored channel that carries a
800
+ * metadata app's OWN `.code` across the QuickJS boundary (#7867) and onto the
801
+ * wire.
802
+ *
803
+ * `ApiErrorSchema` has declared that field since #9106 and the flat door emits
804
+ * it, but it was absent from the `Pick` above — so it was a COMPILE ERROR for
805
+ * any route answering the NESTED envelope to pass one, and every such route
806
+ * dropped the producer's spelling. Nothing invalid shipped (the closed `code`
807
+ * still carried the derived member), which is what made the loss silent and
808
+ * one-directional: the author's spelling gone, and a consumer told by the ADR
809
+ * to read `declaredCode` finding nothing there. Declared-but-unemittable is a
810
+ * `declared = enforced` gap, and admitting the field closes it at the ONE
811
+ * writer rather than in each module that later notices.
812
+ *
813
+ * ⛔ Presence MEANS demotion, and this writer does not re-derive that — the
814
+ * CALLER does, with `demotedDeclaredCode` (`thrown-http-error.ts`, one file
815
+ * over), exactly as the flat door's `thrownCodeFields` already does. That
816
+ * helper answers `undefined` when the producer's spelling IS the vocabulary
817
+ * member already sitting in `code`, which is what stops a registered refusal
818
+ * from carrying two spellings of one fact — `ApiErrorSchema.declaredCode`'s
819
+ * documented invariant. Passing a raw `thrown.declaredCode` re-opens exactly
820
+ * that, and no type here can catch it: vocabulary and position stay two
821
+ * decisions (#9232), so the demotion rule stays with the resolver that owns
822
+ * it rather than being restated in the envelope writer.
823
+ *
824
+ * ## `userMessage` — the second declared channel, and why this `Pick` stays explicit
825
+ *
826
+ * #9934's producer-side opt-in (maintainer ruling 2026-08-19 on objectui#5210,
827
+ * option 1) declares `ApiError.userMessage`: the text a producer marked, AT
828
+ * THROW TIME, as addressed to the END USER. Presence IS the marking — a
829
+ * consumer that sees the field renders it verbatim and keeps its generic
830
+ * substitution (#3821) for everything unmarked.
831
+ *
832
+ * The schema declared it and this writer barred it, with the same
833
+ * one-directional silence `declaredCode` had: the other two doors already emit
834
+ * it — the flat `/data` door through `withDeclaredUserMessage`
835
+ * (`rest/error-response.ts`) and the dispatcher door through
836
+ * `thrown.userMessage` (`runtime/http-dispatcher.ts`) — while a route
837
+ * answering the NESTED envelope could not, so an author's deliberate,
838
+ * localized refusal text was dropped on this door alone. Nothing invalid
839
+ * shipped; the text simply was not there.
840
+ *
841
+ * The channel is live on both ends, which is what makes admitting it a repair
842
+ * rather than a new declared-but-dead surface: a hook sets it at throw time —
843
+ * host-side, or a metadata app's sandboxed body whose `e.userMessage` crosses
844
+ * the QuickJS boundary through `SANDBOX_ERROR_PASSTHROUGH`
845
+ * (`runtime/sandbox/quickjs-runner.ts`) — and `resolveThrownHttpError` already
846
+ * carries it onto `ThrownHttpError` for every caller of the shared resolver.
847
+ *
848
+ * ⛔ Unlike `declaredCode`, this field carries NO invariant for the caller to
849
+ * re-derive. `declaredCode`'s presence MEANS demotion, so its caller passes
850
+ * `demotedDeclaredCode(thrown)` rather than the raw field; `userMessage`'s
851
+ * presence means only that the producer opted in, and `declaredUserMessage`
852
+ * has already decided that (a non-empty string, or nothing at all). The caller
853
+ * passes `thrown.userMessage` straight through, exactly as the dispatcher door
854
+ * does.
855
+ *
856
+ * That difference is why `extra` stays an explicit `Pick` rather than becoming
857
+ * "every optional field of `ApiError`". A derivation would admit each future
858
+ * optional on the day it lands, with nobody asked whether that channel should
859
+ * cross this door or what obligation it hands the caller — and the two fields
860
+ * above needed opposite answers to exactly that question. Recorded for the next
861
+ * reader, because it is the honest cost: with `userMessage` admitted the `Pick`
862
+ * now names ALL SIX of `ApiError`'s optional fields, so this gate has to date
863
+ * rejected none. What it has produced is a different caller obligation per
864
+ * field, which a derivation cannot produce at all.
686
865
  */
687
- declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCode, message: string, extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId'>): void;
866
+ declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCode, message: string, extra?: Pick<ApiError, 'category' | 'httpStatus' | 'details' | 'requestId' | 'declaredCode' | 'userMessage'>): void;
688
867
 
689
868
  /**
690
869
  * The ONE rule for "what HTTP answer does a THROWN error declare?" (#8016).
@@ -756,6 +935,14 @@ declare function sendError(res: EnvelopeResponse, status: number, code: ErrorCod
756
935
  * answers come from ONE function, which is what keeps agreement a construction
757
936
  * rather than two suites agreeing about literals.
758
937
  *
938
+ * [#12509] And the channel has a SCOPE, ruled 2026-08-27 (option D): on a 5xx
939
+ * the producer did not declare, the demoted spelling came off an undeclared
940
+ * producer and is withheld with the prose, while an author-declared code
941
+ * survives. The discriminator is {@link serverFaultProvenance} — one function,
942
+ * read by {@link demotedDeclaredCode}, which every door already calls, so no
943
+ * registrar carries a variant. Read that function's note for why the status
944
+ * channel is the only honest signal here.
945
+ *
759
946
  * ## What this deliberately does NOT decide
760
947
  *
761
948
  * - **Message disclosure.** A 5xx message may name physical tables or carry a
@@ -814,6 +1001,11 @@ interface ThrownHttpError {
814
1001
  * every door (#9106) — but for the wire's `declaredCode` channel when the
815
1002
  * spelling is not a vocabulary member ({@link demotedDeclaredCode}). See the
816
1003
  * module note on why there are two.
1004
+ *
1005
+ * ⚠️ This field records what the producer WROTE, not what a boundary may
1006
+ * emit: since #12509 a demoted spelling is withheld on an undeclared 5xx.
1007
+ * ⛔ Read {@link demotedDeclaredCode}, never this field, when deciding what
1008
+ * goes on a wire.
817
1009
  */
818
1010
  declaredCode?: string;
819
1011
  /** The thrown message, UNSANITISED — see the module note on disclosure. */
@@ -888,21 +1080,206 @@ declare function resolveThrownHttpError(error: unknown, fallbackStatus?: number)
888
1080
  * consumer keeps its generic substitution (#3821 preserved by construction).
889
1081
  */
890
1082
  declare function declaredUserMessage(error: unknown): string | undefined;
1083
+ /**
1084
+ * [#12509] WHO named this 5xx — the producer, or this resolver's fallback.
1085
+ * `undefined` for anything below 500, where nothing is sanitised at all.
1086
+ *
1087
+ * This is the ONE definition of the distinction ADR-0112's 5xx-sanitisation
1088
+ * scope turns on (maintainer ruling 2026-08-27, option D), and it exists as a
1089
+ * named function rather than as an inline conjunction because TWO rules read
1090
+ * it and they read opposite limbs:
1091
+ *
1092
+ * - `'undeclared'` — the throw declared no HTTP answer, so
1093
+ * {@link ThrownHttpError.status} is the caller's `fallbackStatus` and
1094
+ * EVERYTHING this resolver picked up off that throw is the producer's
1095
+ * internals rather than an answer it composed. A driver errno
1096
+ * (`SQLITE_ERROR`, `42P01`) is the measured case, and it is why
1097
+ * {@link demotedDeclaredCode} withholds the code here: the spelling names
1098
+ * the backend, which is one of the two disclosures the 5xx message
1099
+ * withhold exists to prevent (`looksLikeInternalErrorLeak`; the other,
1100
+ * identifiers, is already covered).
1101
+ * - `'declared'` — the producer named a 5xx ITSELF, so its code is authored
1102
+ * and survives. #11718's `{ status: 503, code: 'SERVICE_UNAVAILABLE' }`
1103
+ * relay is this limb, and so is a metadata app's own 5xx refusal spelling
1104
+ * (#7867), which the ADR-0112 amendment wrote `declaredCode` for.
1105
+ *
1106
+ * ⚠️ The DISCRIMINATOR is the status channel, not the code's shape. There is
1107
+ * no other structural signal: a driver errno and an app's own spelling both
1108
+ * arrive on `.code` as a plain string, so anything that told them apart by
1109
+ * LOOKING at the string would be a heuristic over an open channel — the
1110
+ * consumer-side tolerance ADR-0112 exists to forbid, and unfalsifiable besides
1111
+ * (nothing stops an app from spelling `SQLITE_ERROR`). The cost of the
1112
+ * structural answer is stated rather than hidden: a producer that spells a
1113
+ * code but declares NO status loses that code on a 5xx. It keeps it by
1114
+ * declaring the status it means, which is the shape the ADR already asks for.
1115
+ *
1116
+ * ⛔ NOT gated on whether `looksLikeInternalErrorLeak` actually fired on the
1117
+ * message. That predicate is a heuristic over a DIFFERENT channel, and gating
1118
+ * here on it would leak the errno for exactly the dialects whose prose the
1119
+ * heuristic misses — the ceiling `sendThrownError`'s note records. The 5xx
1120
+ * sanitisation REGIME is the condition, not one of its two outcomes.
1121
+ *
1122
+ * ⭐ #12281 — the prose axis of the same 2026-08-27 ruling — is the
1123
+ * `'declared'` limb of this same function: the dispatcher door withholds the
1124
+ * message of EVERY declared 5xx, aligning to `/data`. It is a separate card
1125
+ * with its own measurement-first step, so nothing here applies it; this
1126
+ * function is the shape it will read rather than a second copy it would have
1127
+ * to grow.
1128
+ */
1129
+ type ServerFaultProvenance = 'declared' | 'undeclared';
1130
+ /** See {@link ServerFaultProvenance}. */
1131
+ declare function serverFaultProvenance(thrown: ThrownHttpError): ServerFaultProvenance | undefined;
891
1132
  /**
892
1133
  * The producer's spelling a boundary should surface as the wire's
893
1134
  * `declaredCode` beside the closed `code` — or `undefined` when there is
894
1135
  * nothing to surface (#9106).
895
1136
  *
896
1137
  * Present exactly when the throw spelled a code that did NOT survive into
897
- * {@link ThrownHttpError.code} — i.e. the demote happened. A registered code
898
- * is already in `code`, so emitting it again would put two spellings of one
899
- * fact on every refusal; a throw with no code has nothing to declare. Spelled
900
- * once here rather than as three `!==` comparisons at three exits, so
901
- * "presence means demotion" (`ApiErrorSchema.declaredCode`'s documented
902
- * semantics) has one definition.
1138
+ * {@link ThrownHttpError.code} — i.e. the demote happened AND the answer is
1139
+ * not an undeclared server fault. A registered code is already in `code`, so
1140
+ * emitting it again would put two spellings of one fact on every refusal; a
1141
+ * throw with no code has nothing to declare. Spelled once here rather than as
1142
+ * three `!==` comparisons at three exits, so "presence means demotion"
1143
+ * (`ApiErrorSchema.declaredCode`'s documented semantics) has one definition.
1144
+ *
1145
+ * [#12509] The withhold limb, ruled 2026-08-27 (option D): on a 5xx the
1146
+ * producer did NOT declare, the spelling this resolver demoted came off an
1147
+ * undeclared producer — a driver errno, measured on the wire at three of this
1148
+ * repo's doors — and it is withheld along with the prose. An AUTHOR-declared
1149
+ * code survives at every status. The judgement lives in
1150
+ * {@link serverFaultProvenance}; it is applied HERE, in the one read every
1151
+ * boundary already makes, so all of them inherit it without a door growing a
1152
+ * rule of its own. ⛔ Do not re-derive the condition at a door: a per-door
1153
+ * variant is the divergence this channel has now been repaired for twice.
903
1154
  */
904
1155
  declare function demotedDeclaredCode(thrown: ThrownHttpError): string | undefined;
905
1156
 
1157
+ /**
1158
+ * [#14310] The one rule for "a 5xx must never be silent", shared by every
1159
+ * transport that turns a fault into an HTTP envelope.
1160
+ *
1161
+ * ## The hole this closes
1162
+ *
1163
+ * A 500 that leaves no server-side line is diagnosed from the browser or not
1164
+ * at all. Measured on `main`: a plain `Error` thrown out of a dispatcher route
1165
+ * answered `500 INTERNAL_ERROR` with **zero** log records at any level — the
1166
+ * only evidence was the client's console and the response body. The failure
1167
+ * that motivated this had been reachable for a week and nobody saw it, which
1168
+ * is AGENTS.md "Route & surface ownership §3 — absence must be loud" inverted.
1169
+ *
1170
+ * The reporting that DID exist was not a substitute, in two independent ways:
1171
+ *
1172
+ * 1. `ErrorReporter.captureException` is an APM channel and defaults to
1173
+ * `NoopErrorReporter`. A dev server — the surface an operator actually
1174
+ * watches — wires no reporter, so the capture was a no-op every time.
1175
+ * 2. It is fed by `res.__obsRecordedError`, which only the THROWN exit sets.
1176
+ * A dispatcher route that catches its own fault and RETURNS a 5xx envelope
1177
+ * (`deps.errorFromThrown`, which is how every `/packages` handler answers)
1178
+ * records nothing, so even a wired reporter never saw those.
1179
+ *
1180
+ * This module is the log half, and it is deliberately not the reporter half:
1181
+ * an APM capture is opt-in telemetry, a log line is the operator's floor.
1182
+ *
1183
+ * ## Why it lives here
1184
+ *
1185
+ * Same argument, and the same package, as `resolveThrownHttpError` one file
1186
+ * over: a rule two doors must agree on cannot live inside one of them.
1187
+ * `@objectstack/runtime` depends on `@objectstack/rest`, so an import between
1188
+ * the two doors could only ever point one way — which is exactly why the
1189
+ * "what status does this throw mean" rule was moved here in #8016. "Is this
1190
+ * answer worth an operator's attention" is the same kind of rule, read by the
1191
+ * same two doors, so it gets the same home rather than a second one.
1192
+ *
1193
+ * Living beside {@link sendError} is what makes the REST side automatic: that
1194
+ * writer is the single exit for every nested-envelope 5xx, so the direct-mount
1195
+ * registrars need no per-door call and cannot forget one. Each transport logs
1196
+ * at its own single exit, so a fault costs one line and never two.
1197
+ *
1198
+ * ## `error` level, and why that clears the default
1199
+ *
1200
+ * The requirement is that the line survives `--log-level`'s DEFAULT. The CLI
1201
+ * default is `warn` (`packages/cli/src/utils/log-level.ts`) and `error` (40)
1202
+ * outranks `warn` (30) in `LEVEL_PRIORITY`, so an `error` record passes the
1203
+ * default threshold without any bypass of the level system. An operator who
1204
+ * asks for `--log-level silent` still gets silence: that is a deliberate
1205
+ * instruction, not the default this issue is about.
1206
+ *
1207
+ * ## 5xx only
1208
+ *
1209
+ * 4xx stays quiet, deliberately and at this one gate rather than at each call
1210
+ * site. A client error is the caller's mistake and the response already
1211
+ * explains it; logging them is how the `/meta` `?state=draft` probe once
1212
+ * printed 45 stack traces in one browsing session. `isServerFault` is the
1213
+ * whole rule: at or above 500.
1214
+ */
1215
+
1216
+ /** The request coordinates an operator needs to find the failing call. */
1217
+ interface ServerFaultRequest {
1218
+ /** HTTP method, e.g. `GET`. */
1219
+ method?: string;
1220
+ /** Request path as served, e.g. `/api/v1/packages`. */
1221
+ path?: string;
1222
+ /** Correlation id — the `X-Request-Id` echoed on the response. */
1223
+ requestId?: string;
1224
+ }
1225
+ /** One fault, as the emitting door knows it. */
1226
+ interface ServerFaultLogInput {
1227
+ /** The HTTP status about to be written. Below 500 nothing is logged. */
1228
+ status: number;
1229
+ /**
1230
+ * The original thrown value, when the door still holds it. Carries the
1231
+ * stack; the wire body never does, because a 5xx message is withheld.
1232
+ */
1233
+ error?: unknown;
1234
+ /** The envelope's `code`, when the door resolved one. */
1235
+ code?: string;
1236
+ /**
1237
+ * The message to print when {@link ServerFaultLogInput.error} carries
1238
+ * none — a declared fault built from a string rather than a throw.
1239
+ */
1240
+ message?: string;
1241
+ /** Where the call came in. */
1242
+ request?: ServerFaultRequest;
1243
+ }
1244
+ /** The prefix every fault line carries, so an operator can grep one token. */
1245
+ declare const SERVER_FAULT_LOG_PREFIX = "[5xx]";
1246
+ /**
1247
+ * THE predicate. A response is a server fault worth a line exactly when its
1248
+ * status is 5xx. Exported so a door can decide without restating `>= 500`.
1249
+ */
1250
+ declare function isServerFault(status: number): boolean;
1251
+ /**
1252
+ * The human half of the line: `[5xx] 500 GET /api/v1/packages — <message>`.
1253
+ * Split out so both the emitted record and a test can name the same string.
1254
+ */
1255
+ declare function serverFaultLogMessage(input: ServerFaultLogInput): string;
1256
+ /**
1257
+ * The structured half. `status`/`code`/`requestId` are what a log search keys
1258
+ * on; `method`/`path` repeat the message's coordinates because a JSON sink
1259
+ * indexes fields, not prose.
1260
+ */
1261
+ declare function serverFaultLogMeta(input: ServerFaultLogInput): Record<string, unknown>;
1262
+ /**
1263
+ * Emit EXACTLY ONE `error`-level record for a 5xx, or nothing at all.
1264
+ *
1265
+ * Returns whether a record was emitted, so a caller that must not double-log
1266
+ * can branch on the answer rather than re-deriving the 5xx test.
1267
+ *
1268
+ * `logger` is optional: a door with no injected logger falls back to
1269
+ * `console.error`, because the point of this function is that the line exists
1270
+ * even on a surface nobody configured. Emission never throws — a logging
1271
+ * failure must not become a second fault on top of the one being reported.
1272
+ */
1273
+ declare function logServerFault(input: ServerFaultLogInput, logger?: Logger): boolean;
1274
+ /**
1275
+ * Read request coordinates off whatever request object the transport hands
1276
+ * the door. Adapters disagree on the spelling (`path` / `url` /
1277
+ * `originalUrl`), and the request id may be on the object (set by
1278
+ * `instrumentRouteHandler`) or only on the incoming header — so both are
1279
+ * read here, once, instead of at each call site.
1280
+ */
1281
+ declare function describeFaultRequest(req: unknown): ServerFaultRequest;
1282
+
906
1283
  /** The HTTP status a validation failure maps to when the error names none. */
907
1284
  declare const VALIDATION_FAILED_STATUS = 400;
908
1285
  interface ValidationFailureDetails {
@@ -1154,6 +1531,153 @@ declare function isUniqueViolationError(error: unknown): boolean;
1154
1531
  */
1155
1532
  declare function uniqueViolationColumn(error: unknown): string | undefined;
1156
1533
 
1534
+ /**
1535
+ * [#13438] The physical table a driver's statement TARGETED, declared on the
1536
+ * error envelope by the producer that knows it.
1537
+ *
1538
+ * `readObject` closed the #13324 hole for callers that can name what they read
1539
+ * — and left a residual one layer down. A caller names its OBJECT (the API
1540
+ * name); a driver compiles the statement against the PHYSICAL table, and for a
1541
+ * federated object (ADR-0015, `external.remoteName`) those are two different
1542
+ * names. `driver-sql` reads `crm_order` from `legacy_orders`, so when that
1543
+ * remote is genuinely absent the dialect phrase names `legacy_orders`, the
1544
+ * caller names `crm_order`, and the comparison called a real missing table
1545
+ * "about something else" — loud, for the one case the licence was built for.
1546
+ *
1547
+ * Nothing at a call site can fold that away: the mapping lives on the driver
1548
+ * instance, and asking every caller to consult it is the guessing this channel
1549
+ * exists to remove (maintainer ruling 2026-09-01, option 2 on the card). So the
1550
+ * fact is declared where it is known — the driver that composed the envelope
1551
+ * stamps the table its statement targeted onto it — and the predicate PREFERS
1552
+ * a declared table over the caller-supplied `readObject`. The caller never
1553
+ * needs to know a federated object's remote name, and a driver that declares
1554
+ * nothing gets exactly the #13324 behaviour.
1555
+ *
1556
+ * A symbol key from the global registry, held non-enumerable: the carrier
1557
+ * discipline `driver-sql` already applies to its withheld-diagnostic symbols
1558
+ * and to the envelope's own `cause`. Readable by code; invisible to
1559
+ * `JSON.stringify`, `{ ...err }`, `Object.keys`, `for…in` and the
1560
+ * structured-clone boundary — so the physical table name, the very thing the
1561
+ * envelope's composed message withholds, can never ride back onto a wire that
1562
+ * serialises the error. `Symbol.for` so a duplicated copy of this package
1563
+ * resolves the same key.
1564
+ *
1565
+ * ⚠️ A declaration is EVIDENCE, so it also narrows the one-argument form: an
1566
+ * envelope declaring `legacy_orders` whose dialect phrase names some other
1567
+ * relation reads not-benign even with no `readObject` — the driver supplied
1568
+ * the fact the caller could not. That is the #13324 verdict reached without
1569
+ * the caller's help, in the direction the module docblock calls cheap.
1570
+ */
1571
+ declare const DRIVER_TARGETED_TABLE: symbol;
1572
+ /**
1573
+ * Declare, on `error`, the physical table the statement that raised it targeted.
1574
+ *
1575
+ * The producer's half of {@link DRIVER_TARGETED_TABLE} — for a driver composing
1576
+ * an error envelope over a dialect failure. `table` is the name the statement
1577
+ * was compiled against (a federated object's `external.remoteName`, otherwise
1578
+ * the object's own table), bare: the comparison folds away schema and database
1579
+ * qualifiers on both sides, so none is needed here.
1580
+ *
1581
+ * Non-enumerable and non-writable, and the FIRST declaration wins: the actor
1582
+ * that compiled the statement is the one that knows its target, and a later,
1583
+ * more distant wrapper re-declaring it would be re-introducing the guess. (The
1584
+ * predicate applies the same rule across a `cause` chain: the declaration
1585
+ * NEAREST the dialect phrase is the one compared.) An empty or non-string
1586
+ * `table` declares nothing — silently, because this runs on an error path
1587
+ * where a thrown `TypeError` would replace the envelope it was meant to
1588
+ * annotate; the predicate then falls back to `readObject` exactly as if no
1589
+ * driver had spoken.
1590
+ *
1591
+ * @returns `error`, for chaining.
1592
+ */
1593
+ declare function declareTargetedTable<E extends object>(error: E, table: string): E;
1594
+ /**
1595
+ * The table `error` declares its statement targeted, or `null` when it declares
1596
+ * none — the reading half of {@link declareTargetedTable}. Tolerant of bare
1597
+ * input: any non-object, and any object without a non-empty string under the
1598
+ * key, is "no declaration".
1599
+ */
1600
+ declare function targetedTableOf(error: unknown): string | null;
1601
+ /**
1602
+ * Is this DDL error the benign "already provisioned" case?
1603
+ *
1604
+ * @param error - The value thrown by `syncSchema()` (or any DDL call).
1605
+ * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.
1606
+ * @returns `true` only when the error positively identifies as
1607
+ * table/column/index-already-exists. Anything else — including an
1608
+ * unrecognised error, `undefined`, or a permission/connection failure —
1609
+ * returns `false` and MUST be reported loudly by the caller.
1610
+ */
1611
+ declare function isSchemaAlreadyExistsError(error: unknown, depth?: number): boolean;
1612
+ /**
1613
+ * Is this READ error the benign "table has not been provisioned yet" case?
1614
+ *
1615
+ * The only failure that licenses a caller to treat an empty table as the truth
1616
+ * — there are no rows, so there is nothing to be inconsistent with. A
1617
+ * connection drop, a timeout, a permission denial or a query error all mean the
1618
+ * rows may well exist and simply were not seen; those return `false` and the
1619
+ * caller must report the consequence and give up rather than compute an answer
1620
+ * from data it never read (#4825).
1621
+ *
1622
+ * A failure about a **column** of a relation is never this case, in either of
1623
+ * Postgres' two phrasings — the relation is right there in the message because
1624
+ * it exists (#6347). See {@link MISSING_TABLE}'s `excludes`.
1625
+ *
1626
+ * [#13324] Neither is a failure that names a **different relation**, and that
1627
+ * one cannot be seen without `readObject`. The message test asks what the
1628
+ * phrase LOOKS like and never which table it names, so a read of a view whose
1629
+ * base table has been dropped — `no such table: main.<base>`, measured on
1630
+ * libsql for a view that itself exists — answered benign for a relation that is
1631
+ * present and may be backed by rows. Naming the read closes it: the phrase must
1632
+ * be about the table the caller asked for, or it is not evidence about it.
1633
+ *
1634
+ * Pass `readObject` from every in-repo call site. It is **optional** so that
1635
+ * omitting it is exactly the pre-#13324 behaviour rather than a new loud
1636
+ * failure — this is a published export (`@objectstack/types`, and still
1637
+ * `@objectstack/metadata/errors` by re-export), and a required parameter would
1638
+ * be a breaking change to it. The cost of the choice
1639
+ * is that the narrowing is opt-in per call site: a new caller that forgets it
1640
+ * silently gets the old, wider verdict.
1641
+ *
1642
+ * [#13440] That last sentence is no longer only a warning. In-repo callers are
1643
+ * held to it by `driver-error-classification.callers.test.ts`, which walks every
1644
+ * TypeScript source under `packages/` and fails any call of this function that
1645
+ * omits `readObject` or passes it as `undefined`/`null`. The exemption is this
1646
+ * module's own contract tests, which exercise the one-argument PUBLISHED form on
1647
+ * purpose; read that file's header before adding to the exemption, because
1648
+ * widening it is how the enforcement becomes prose again. External consumers are
1649
+ * untouched: the signature below is unchanged, and the gate binds only callers
1650
+ * inside this repository.
1651
+ *
1652
+ * [#13438] `readObject` is the caller's name for what it read, and for a
1653
+ * federated object (ADR-0015) that is not the name the driver put in the
1654
+ * statement — `crm_order` reads `external.remoteName: 'legacy_orders'`, so a
1655
+ * genuinely absent remote raised a phrase naming `legacy_orders` against a
1656
+ * caller naming `crm_order`, and the #13324 comparison read it loud. A driver
1657
+ * that knows the table it targeted now DECLARES it on the envelope
1658
+ * ({@link declareTargetedTable}), and a declared table is preferred over
1659
+ * `readObject` outright: the phrase is compared against the declared name, and
1660
+ * the caller-supplied one is not consulted at that node or below it. Absent a
1661
+ * declaration the comparison is the #13324 one, unchanged. Two consequences,
1662
+ * both pinned: a genuinely absent federated remote reads benign again without
1663
+ * the caller learning the mapping; and — because a declaration is evidence the
1664
+ * caller did not have — an envelope whose phrase names a relation other than
1665
+ * its declared table reads NOT benign even through the one-argument form.
1666
+ *
1667
+ * @param error - The value thrown by a driver/engine read (`find`, `findOne`, …).
1668
+ * @param readObject - The object/table whose emptiness the caller is about to
1669
+ * treat as the truth — its own API name is fine, the comparison folds
1670
+ * away schema qualifiers, the legacy `ns__short` prefix and case.
1671
+ * Omitted (or not a string) means "cannot say", never "be loud".
1672
+ * Superseded, at any node of the `cause` chain that declares the
1673
+ * table its statement targeted, by that declaration (#13438).
1674
+ * @param depth - Internal `cause`-chain recursion counter; callers pass nothing.
1675
+ * @returns `true` only when the error positively identifies as
1676
+ * table/relation-does-not-exist **for the table that was read** —
1677
+ * the declared target where a driver supplied one, else `readObject`.
1678
+ */
1679
+ declare function isMissingTableError(error: unknown, readObject?: string, depth?: number): boolean;
1680
+
1157
1681
  /**
1158
1682
  * Whether a thrown driver error says the `ON CONFLICT` target it was given is
1159
1683
  * backed by no PRIMARY KEY or UNIQUE index.
@@ -1407,4 +1931,4 @@ interface RuntimePlugin {
1407
1931
  onStart?: (ctx: RuntimeContext) => void | Promise<void>;
1408
1932
  }
1409
1933
 
1410
- export { type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, type RuntimeContext, type RuntimePlugin, type ThrownHttpError, VALIDATION_FAILED_STATUS, type ValidationFailureDetails, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declaredIndexUniqueIsGlobal, declaredUserMessage, declaresServerFault, demotedDeclaredCode, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, fieldsFromZodIssues, globalUniqueFindingId, isMcpServerEnabled, isModuleNotFoundError, isPlatformOwnedObject, isRelationSubObjectPhrase, isUnbackedConflictTargetError, isUniqueViolationError, keysetWalk, looksLikeInternalErrorLeak, matchMissingColumnOfRelation, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, resolveThrownHttpError, sendError, sendOk, stampSearchPinyinEnabled, unconfirmedGlobalUniques, uniqueViolationColumn, validationFailure, validationFailureDetails };
1934
+ export { DRIVER_TARGETED_TABLE, type EnvelopeResponse, GLOBAL_UNIQUE_CONFIRMATION_REQUIRED, GLOBAL_UNIQUE_ISOLATED_PRESCRIPTION, type GlobalUniqueAttestation, type GlobalUniqueFinding, type IKernel, INTERNAL_ERROR_MESSAGE, type KeysetPageQuery, type KeysetWalk, type KeysetWalkOptions, PLATFORM_OWNER_EMAIL_ENV, type RuntimeContext, type RuntimePlugin, SERVER_FAULT_LOG_PREFIX, type ServerFaultLogInput, type ServerFaultProvenance, type ServerFaultRequest, type ThrownHttpError, VALIDATION_FAILED_STATUS, type ValidationFailureDetails, _resetEnvDeprecationWarnings, buildGlobalUniqueStopMessage, collectConfiguredLocales, collectGlobalUniques, declareTargetedTable, declaredIndexUniqueIsGlobal, declaredUserMessage, declaresServerFault, demotedDeclaredCode, describeFaultRequest, describeGlobalUniqueFinding, emitDegradedBootBanner, fieldUniqueIsGlobal, fieldsFromZodIssues, globalUniqueFindingId, isEmailVerifiedUserRow, isMcpServerEnabled, isMissingTableError, isModuleNotFoundError, isPlatformOwnedObject, isRelationSubObjectPhrase, isSchemaAlreadyExistsError, isServerFault, isUnbackedConflictTargetError, isUniqueViolationError, keysetWalk, logServerFault, looksLikeInternalErrorLeak, matchMissingColumnOfRelation, postureGatesGlobalUniques, readEnvWithDeprecation, recordGlobalUniqueAttestation, resolveAllowDegradedTenancy, resolveAllowDevPlugin, resolveAllowDriverConnectFailure, resolveMcpStdioAutoStart, resolveMultiOrgEnabled, resolveOrgLimit, resolveOrgMembershipLimit, resolvePlatformOwnerEmail, resolveSandboxTimeoutMs, resolveSearchPinyinEnabled, resolveTenancyPosture, resolveThrownHttpError, sendError, sendOk, serverFaultLogMessage, serverFaultLogMeta, serverFaultProvenance, stampSearchPinyinEnabled, targetedTableOf, unconfirmedGlobalUniques, uniqueViolationColumn, validationFailure, validationFailureDetails };