diff --git a/packages/objectql/src/engine-migration-flags.ts b/packages/objectql/src/engine-migration-flags.ts new file mode 100644 index 00000000000..2f875977c1f --- /dev/null +++ b/packages/objectql/src/engine-migration-flags.ts @@ -0,0 +1,295 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25522 cut 13] The migration-flag and attestation bodies of `ObjectQL`, + * moved out of `engine.ts`: `supplyTenantKeyCertifiedResolver` (hand a driver + * the "is this database certified" question), `isOrganizationOwnershipCertified` + * (that question's answer), `readFileReferencesFlagRow` and + * `isValueShapesMigrationVerified` (the two memoized flag readers), + * `readMigrationFlagVerified` (the one `sys_migration` row read behind them), + * `noteAdmittedValueShapeViolation` (tally a violation this boot admitted), + * `announceOpenMigrationGates` (the once-per-boot open-gate advisory) and + * `wasDatastoreCreatedFromEmpty` (did this process create its store). + * + * A MOVE, not a redesign. Each exported function below is the body of the + * `ObjectQL` method of the same name as it stood, re-indented one level and + * otherwise unedited. Each method's docblock stays on the method in + * `engine.ts`, which is still the contract. ADR-0104 and ADR-0131 govern what + * these bodies decide; their decisions are cited on those docblocks. + * + * What stays in `engine.ts`: `isFileReferencesMigrationVerified` (ADR-0104 + * names it by path), `recordObservedDeviation` and `retractCreationAttestation` + * (each writes through `this.update`), the bodies too short to pay for a host + * (`haveFileColumnsMoved`, `readMigrationFlagMemoized`, + * `readMigrationFlagRowMemoized`, `valueShapeViolationsAdmitted`, + * `admittedViolationSink`, `invalidateDataMigrationFlags`), and every field + * of the section, each declared where it was. + * + * ## How the moved bodies reach the class + * + * The shape #23050 set for `rest-server.ts`, applied per method as + * `engine-driver-options.ts` applies it. The class keeps all eight methods + * with name, modifiers, signature, docblock and position, and each body is + * one literal of exactly the {@link MigrationFlagsHost} members that method + * reads, plus a `.call`. The fields a body reads (`logger`, `_registry`, + * `admittedValueShapeViolations`, `migrationGatesAnnounced`, `drivers`) go as + * GETTERS, so each read happens when the body makes it, as it did. The one + * field a body ASSIGNS, `migrationGatesAnnounced`, goes as a getter and + * SETTER pair over the instance, so the write lands on the engine and the + * next read, from either side, sees it. The methods they call go as + * late-bound closures over the instance, so a double or spy set on the engine + * is still the one a call reaches. + * + * This module imports the engine only as a TYPE, so there is no module cycle + * at run time, and nothing re-exports it, so the published surface does not + * move. + */ + +import { + DATA_MIGRATION_FLAG_OBJECT, + FILE_REFERENCES_MIGRATION_ID, + VALUE_SHAPES_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, + organizationOwnershipShapeGenerationOf, + isDataMigrationFlagVerified, + hasMovedFileColumns, +} from '@objectstack/spec/system'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import type { IDataDriver } from '@objectstack/core'; +import { isMissingTableError } from '@objectstack/metadata/errors'; +import { valueShapePostureSetByEnv, mediaPostureSetByEnv } from './validation/record-validator.js'; +import type { AdmittedValueShapeViolation } from './validation/record-validator.js'; +import type { MigrationFlagRead, ObjectQL } from './engine.js'; + +/** + * The `ObjectQL` members the functions below read — every one of them, and no + * other; each function's `this` is the `Pick` of the members IT reads. Each is + * typed AS the class member (an indexed access on the class), so a change to a + * member's type on the class is a change here too, and naming a member the + * class does not have is a compile error. See the module header for which + * members are getters, and which one also has a setter. + */ +export interface MigrationFlagsHost { + readonly logger: ObjectQL['logger']; + readonly _registry: ObjectQL['_registry']; + readonly admittedValueShapeViolations: ObjectQL['admittedValueShapeViolations']; + migrationGatesAnnounced: ObjectQL['migrationGatesAnnounced']; + readonly drivers: ObjectQL['drivers']; + isOrganizationOwnershipCertified: ObjectQL['isOrganizationOwnershipCertified']; + findOne: ObjectQL['findOne']; + readMigrationFlagRowMemoized: ObjectQL['readMigrationFlagRowMemoized']; + readMigrationFlagMemoized: ObjectQL['readMigrationFlagMemoized']; + recordObservedDeviation: ObjectQL['recordObservedDeviation']; + retractCreationAttestation: ObjectQL['retractCreationAttestation']; + objectHasMediaField: ObjectQL['objectHasMediaField']; + objectHasCoveredValueField: ObjectQL['objectHasCoveredValueField']; + readMigrationFlagVerified: ObjectQL['readMigrationFlagVerified']; +} + +/** The body of `ObjectQL#supplyTenantKeyCertifiedResolver`; the method's docblock there is its contract. */ +export function supplyTenantKeyCertifiedResolver( + this: Pick, + driver: IDataDriver, +): void { + const sink = driver as unknown as { + setTenantKeyCertifiedResolver?: (resolve: () => Promise) => void; + }; + if (typeof sink.setTenantKeyCertifiedResolver !== 'function') return; + try { + sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified()); + } catch (e: any) { + // A driver that did not take the resolver answers "not certified": it + // keeps the two forms distinct and REPORTS a destructive recreate_index, + // which is never applied unattended (safe-only boot reconcile, the + // artifact gate, `os migrate apply --allow-destructive`). + this.logger.debug('Driver declined the ADR-0131 certification resolver', { + driverName: driver.name, + error: e?.message ?? String(e), + }); + } +} + +/** The body of `ObjectQL#isOrganizationOwnershipCertified`; the method's docblock there is its contract. */ +export async function isOrganizationOwnershipCertified( + this: Pick, +): Promise { + if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) return false; + try { + const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, + context: { isSystem: true } as ExecutionContext, + }); + if (!row || row.id !== ORGANIZATION_OWNERSHIP_MIGRATION_ID) return false; + const verified = isDataMigrationFlagVerified({ + id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, + last_run_at: String(row.last_run_at ?? ''), + verified_at: row.verified_at == null ? null : String(row.verified_at), + blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), + }); + return verified && organizationOwnershipShapeGenerationOf(row.details) >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION; + } catch (error) { + // A store whose ledger table does not exist yet has no certificate. + if (isMissingTableError(error, DATA_MIGRATION_FLAG_OBJECT)) return false; + throw error; + } +} + +/** The body of `ObjectQL#readFileReferencesFlagRow`; the method's docblock there is its contract. */ +export function readFileReferencesFlagRow( + this: Pick, +): Promise { + return this.readMigrationFlagRowMemoized( + 'fileReferencesMigrationVerified', + FILE_REFERENCES_MIGRATION_ID, + '[value-shape] this deployment has verified the file-as-reference migration — ' + + 'media value shapes are enforced and released field files may be collected ' + + '(ADR-0104)', + ); +} + +/** The body of `ObjectQL#isValueShapesMigrationVerified`; the method's docblock there is its contract. */ +export async function isValueShapesMigrationVerified( + this: Pick, +): Promise { + return this.readMigrationFlagMemoized( + 'valueShapesMigrationVerified', + VALUE_SHAPES_MIGRATION_ID, + '[value-shape] this deployment has verified the value-shape scan — reference and ' + + 'structured-JSON value shapes are enforced (ADR-0104)', + ); +} + +/** The body of `ObjectQL#readMigrationFlagVerified`; the method's docblock there is its contract. */ +export async function readMigrationFlagVerified( + this: Pick, + migrationId: string, + verifiedLog?: string, +): Promise { + if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) { + return { verified: false, conclusive: false, columnsMoved: false }; + } + try { + // [#20648] The row by primary key, through the single-row route. ⛔ Not + // `find` with `limit: 1`: the SQL driver reads that as page one of a + // walk, and this read runs at boot BEFORE `sys_migration` is registered + // with the driver — so every boot of an existing deployment printed the + // driver's "Paged read ... is NOT deterministic" warning for a lookup + // that cannot return two rows. `findOne` is the route it exempts. + const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: migrationId }, + context: { isSystem: true } as ExecutionContext, + }); + if (!row || row.id !== migrationId) { + return { verified: false, conclusive: true, columnsMoved: false }; + } + const flag = { + id: migrationId, + last_run_at: String(row.last_run_at ?? ''), + verified_at: row.verified_at == null ? null : String(row.verified_at), + // A non-numeric count must read as "not zero", not as 0 — a bad + // coercion lands on NaN, which fails the === 0 test. + blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), + // [#15989] Read here so both questions come off ONE row: an absent or + // empty stamp is the JSON encoding, which is what every row written + // before the column step existed holds. + columns_moved_at: row.columns_moved_at == null ? null : String(row.columns_moved_at), + }; + const verified = isDataMigrationFlagVerified(flag); + if (verified && verifiedLog) this.logger.info(verifiedLog); + return { verified, conclusive: true, columnsMoved: hasMovedFileColumns(flag) }; + } catch { + // unreadable evidence → stay lenient, keep asking; and the columns read + // as NOT moved, which is the encoding every deployment already writes. + return { verified: false, conclusive: false, columnsMoved: false }; + } +} + +/** The body of `ObjectQL#noteAdmittedValueShapeViolation`; the method's docblock there is its contract. */ +export function noteAdmittedValueShapeViolation( + this: Pick, + object: string, + violation: AdmittedValueShapeViolation, +): void { + const migrationId = + violation.gate === 'media' ? FILE_REFERENCES_MIGRATION_ID : VALUE_SHAPES_MIGRATION_ID; + const existing = this.admittedValueShapeViolations.get(migrationId); + if (existing) { + existing.count += 1; + } else { + this.admittedValueShapeViolations.set(migrationId, { + count: 1, + first: { object, field: violation.field, type: violation.type, detail: violation.detail }, + }); + } + // Two consumers of the same counterexample, answering different questions. + // The deviation marker goes first because it is the one that applies to + // EVERY verified deployment (#4797); the retraction below applies only to + // the narrow fresh-datastore case (#4769) and closes the gate outright + // there. Each is the other's backstop if one write fails. + this.recordObservedDeviation(migrationId); + this.retractCreationAttestation(migrationId); +} + +/** The body of `ObjectQL#announceOpenMigrationGates`; the method's docblock there is its contract. */ +export async function announceOpenMigrationGates( + this: Pick, +): Promise { + if (this.migrationGatesAnnounced) return; + this.migrationGatesAnnounced = true; + try { + const mediaByEnv = mediaPostureSetByEnv(); + const coveredByEnv = valueShapePostureSetByEnv(); + if (mediaByEnv && coveredByEnv) return; + + let media = false; + let covered = false; + for (const obj of this._registry.getAllObjects() as any[]) { + if (!media && !mediaByEnv && this.objectHasMediaField(obj)) media = true; + if (!covered && !coveredByEnv && this.objectHasCoveredValueField(obj)) covered = true; + if ((media || mediaByEnv) && (covered || coveredByEnv)) break; + } + + // [#4769] Read the ledger, not the memo. This advisory runs once, at + // `kernel:bootstrapped`, and the fresh-datastore attestation may have + // written its rows moments earlier — after the boot's first write had + // already memoized "not verified". Reporting from that memo tells a + // brand-new deployment to run a migration whose gate is already closed. + // One query per applicable gate, once per boot. + if (media && !(await this.readMigrationFlagVerified(FILE_REFERENCES_MIGRATION_ID)).verified) { + this.logger.info( + '[value-shape] media values are checked but NOT enforced here, and released files are ' + + 'never collected — this deployment has not verified its file migration. Run ' + + '`os migrate files-to-references` (dry run) to see what it would do, then `--apply` ' + + 'to close the gate (ADR-0104).', + ); + } + if (covered && !(await this.readMigrationFlagVerified(VALUE_SHAPES_MIGRATION_ID)).verified) { + this.logger.info( + '[value-shape] reference and structured-JSON values are checked but NOT enforced here — ' + + 'this deployment has not verified its value-shape scan. Run `os migrate value-shapes` ' + + '(dry run) to see what it would report, then `--apply` to close the gate ' + + '(ADR-0104).', + ); + } + } catch { + // An advisory must never be the reason a boot fails. + } +} + +/** The body of `ObjectQL#wasDatastoreCreatedFromEmpty`; the method's docblock there is its contract. */ +export function wasDatastoreCreatedFromEmpty( + this: Pick, +): boolean { + let created = 0; + let existing = 0; + let reporting = 0; + for (const driver of this.drivers.values()) { + const stats = (driver as any).getSchemaSyncStats?.(); + if (!stats) continue; + reporting += 1; + created += Number(stats.created) || 0; + existing += Number(stats.existing) || 0; + } + return reporting > 0 && existing === 0 && created > 0; +} diff --git a/packages/objectql/src/engine-realtime-publish.ts b/packages/objectql/src/engine-realtime-publish.ts index 02e17ff9f83..a8ff206ffaf 100644 --- a/packages/objectql/src/engine-realtime-publish.ts +++ b/packages/objectql/src/engine-realtime-publish.ts @@ -16,9 +16,9 @@ * docblocks included; no other code in `engine.ts` called them. * * What stays in `engine.ts`: `setRealtimeService` (a two-line field setter), - * the `realtimeService` field itself and the three write-path call sites, and + * the `realtimeService` field itself and the three write-path call sites. * `accessibleOrganizationsOf`, which sat between the helpers but is read by the - * tenant wall, not by an event. + * tenant wall, not by an event, now lives in `engine-driver-options.ts`. * * ## How the moved bodies reach the class * diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index a17c9e9b93e..4e64ab23e37 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -89,12 +89,7 @@ import { keysetWalk, type KeysetPageQuery } from '@objectstack/types'; import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, - VALUE_SHAPES_MIGRATION_ID, - ORGANIZATION_OWNERSHIP_MIGRATION_ID, - ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, - organizationOwnershipShapeGenerationOf, isDataMigrationFlagVerified, - hasMovedFileColumns, renderOperationMessage, SystemFieldName, } from '@objectstack/spec/system'; @@ -152,6 +147,10 @@ import { callerMayReadSysFile, readParentDerivedFiles, resolveFileReferences, se // posture and Layer 0 verdict they are scoped by; the methods of the same names // below hand each body the members it reads (`DriverOptionsHost`). import { buildDriverOptions, driverReadScope, driverWriteTarget, engineLayer0Verdict, resolveEnginePosture, wallExemptedOperation } from './engine-driver-options.js'; +// [#25522 cut 13] The `sys_migration` flag reads, the certification question a +// driver is handed, and the admitted-violation tally and boot advisory; the +// methods of the same names below hand each body the members it reads (`MigrationFlagsHost`). +import { announceOpenMigrationGates, isOrganizationOwnershipCertified, isValueShapesMigrationVerified, noteAdmittedValueShapeViolation, readFileReferencesFlagRow, readMigrationFlagVerified, supplyTenantKeyCertifiedResolver, wasDatastoreCreatedFromEmpty } from './engine-migration-flags.js'; import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel'; import { isStoredMetadataBodyObject } from '@objectstack/spec/kernel'; import type { FlowFunctionEffect } from '@objectstack/spec/automation'; @@ -320,7 +319,7 @@ import { deriveViewContainerObject } from '@objectstack/metadata/view-container' import { viewContainerNameRefusal } from './view-container-name-refusal.js'; import { bindHooksToEngine, type BindHooksOptions } from './hook-binder.js'; import { declaredHookCondition } from './hook-wrappers.js'; -import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, resolveFieldLabel, valueShapePostureSetByEnv, mediaPostureSetByEnv, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; +import { validateRecord, validateRecordInScope, normalizeMultiValueFields, normalizeBlankTypedValues, normalizeNumericStringValues, coerceBooleanFields, ValidationError, type FieldValidationError, resolveFieldLabel, isScannableValueShapeField, valueShapeStrictEffective, mediaStrictEffective } from './validation/record-validator.js'; import type { AdmittedValueShapeViolation, AdmittedValueShapeViolationSink } from './validation/record-validator.js'; import type { RelatedFieldBinding, RelatedRecordBinding } from './validation/rule-validator.js'; import { collectPredicateRelationships, collectReferenceTraversals, referenceTraversalConflicts, evaluateValidationRules, emitValidationAdvisories, optionVisibilityReadsPermissions, readsPermissionPredicate, referentialClearBinding, needsPriorRecord, stripReadonlyWhenFields, stripReadonlyWhenFieldsMulti, hasReadonlyWhenInPayload, hasParentScopedReadonlyWhenInPayload, hasParentScopedRequiredWhen, stripReadonlyFields, stripRuntimeOwnedFields, staticReadonlyInsertSubject, preserveAuditIgnoredOnInsertWarning } from './validation/rule-validator.js'; @@ -393,7 +392,7 @@ import { * could actually be consulted to reach it (#4769). Both negatives keep the * gate closed; only the conclusive one is worth remembering. */ -interface MigrationFlagRead { +export interface MigrationFlagRead { verified: boolean; conclusive: boolean; /** @@ -11896,22 +11895,13 @@ export class ObjectQL implements IObjectQLEngine { * no such seam is left alone. */ private supplyTenantKeyCertifiedResolver(driver: IDataDriver): void { - const sink = driver as unknown as { - setTenantKeyCertifiedResolver?: (resolve: () => Promise) => void; + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readLogger = () => this.logger; + const host: ThisParameterType = { + isOrganizationOwnershipCertified: (...a) => this.isOrganizationOwnershipCertified(...a), + get logger() { return readLogger(); }, }; - if (typeof sink.setTenantKeyCertifiedResolver !== 'function') return; - try { - sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified()); - } catch (e: any) { - // A driver that did not take the resolver answers "not certified": it - // keeps the two forms distinct and REPORTS a destructive recreate_index, - // which is never applied unattended (safe-only boot reconcile, the - // artifact gate, `os migrate apply --allow-destructive`). - this.logger.debug('Driver declined the ADR-0131 certification resolver', { - driverName: driver.name, - error: e?.message ?? String(e), - }); - } + return supplyTenantKeyCertifiedResolver.call(host, driver); } /** @@ -11925,25 +11915,13 @@ export class ObjectQL implements IObjectQLEngine { * asking reports it (`SqlDriver.isTenantKeyCertified`). */ async isOrganizationOwnershipCertified(): Promise { - if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) return false; - try { - const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, - context: { isSystem: true } as ExecutionContext, - }); - if (!row || row.id !== ORGANIZATION_OWNERSHIP_MIGRATION_ID) return false; - const verified = isDataMigrationFlagVerified({ - id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, - last_run_at: String(row.last_run_at ?? ''), - verified_at: row.verified_at == null ? null : String(row.verified_at), - blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), - }); - return verified && organizationOwnershipShapeGenerationOf(row.details) >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION; - } catch (error) { - // A store whose ledger table does not exist yet has no certificate. - if (isMissingTableError(error, DATA_MIGRATION_FLAG_OBJECT)) return false; - throw error; - } + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readRegistry = () => this._registry; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + findOne: (...a) => this.findOne(...a), + }; + return isOrganizationOwnershipCertified.call(host); } /** @@ -11954,13 +11932,11 @@ export class ObjectQL implements IObjectQLEngine { * second copy of them is a second way to read a different row. */ private readFileReferencesFlagRow(): Promise { - return this.readMigrationFlagRowMemoized( - 'fileReferencesMigrationVerified', - FILE_REFERENCES_MIGRATION_ID, - '[value-shape] this deployment has verified the file-as-reference migration — ' + - 'media value shapes are enforced and released field files may be collected ' + - '(ADR-0104)', - ); + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const host: ThisParameterType = { + readMigrationFlagRowMemoized: (...a) => this.readMigrationFlagRowMemoized(...a), + }; + return readFileReferencesFlagRow.call(host); } /** @@ -12003,12 +11979,11 @@ export class ObjectQL implements IObjectQLEngine { * classes. */ async isValueShapesMigrationVerified(): Promise { - return this.readMigrationFlagMemoized( - 'valueShapesMigrationVerified', - VALUE_SHAPES_MIGRATION_ID, - '[value-shape] this deployment has verified the value-shape scan — reference and ' + - 'structured-JSON value shapes are enforced (ADR-0104)', - ); + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const host: ThisParameterType = { + readMigrationFlagMemoized: (...a) => this.readMigrationFlagMemoized(...a), + }; + return isValueShapesMigrationVerified.call(host); } /** @@ -12090,43 +12065,15 @@ export class ObjectQL implements IObjectQLEngine { migrationId: string, verifiedLog?: string, ): Promise { - if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) { - return { verified: false, conclusive: false, columnsMoved: false }; - } - try { - // [#20648] The row by primary key, through the single-row route. ⛔ Not - // `find` with `limit: 1`: the SQL driver reads that as page one of a - // walk, and this read runs at boot BEFORE `sys_migration` is registered - // with the driver — so every boot of an existing deployment printed the - // driver's "Paged read ... is NOT deterministic" warning for a lookup - // that cannot return two rows. `findOne` is the route it exempts. - const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { - where: { id: migrationId }, - context: { isSystem: true } as ExecutionContext, - }); - if (!row || row.id !== migrationId) { - return { verified: false, conclusive: true, columnsMoved: false }; - } - const flag = { - id: migrationId, - last_run_at: String(row.last_run_at ?? ''), - verified_at: row.verified_at == null ? null : String(row.verified_at), - // A non-numeric count must read as "not zero", not as 0 — a bad - // coercion lands on NaN, which fails the === 0 test. - blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), - // [#15989] Read here so both questions come off ONE row: an absent or - // empty stamp is the JSON encoding, which is what every row written - // before the column step existed holds. - columns_moved_at: row.columns_moved_at == null ? null : String(row.columns_moved_at), - }; - const verified = isDataMigrationFlagVerified(flag); - if (verified && verifiedLog) this.logger.info(verifiedLog); - return { verified, conclusive: true, columnsMoved: hasMovedFileColumns(flag) }; - } catch { - // unreadable evidence → stay lenient, keep asking; and the columns read - // as NOT moved, which is the encoding every deployment already writes. - return { verified: false, conclusive: false, columnsMoved: false }; - } + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readRegistry = () => this._registry; + const readLogger = () => this.logger; + const host: ThisParameterType = { + get _registry() { return readRegistry(); }, + findOne: (...a) => this.findOne(...a), + get logger() { return readLogger(); }, + }; + return readMigrationFlagVerified.call(host, migrationId, verifiedLog); } // ──────────────────────────────────────────────────────────────────── @@ -12196,24 +12143,14 @@ export class ObjectQL implements IObjectQLEngine { } private noteAdmittedValueShapeViolation(object: string, violation: AdmittedValueShapeViolation): void { - const migrationId = - violation.gate === 'media' ? FILE_REFERENCES_MIGRATION_ID : VALUE_SHAPES_MIGRATION_ID; - const existing = this.admittedValueShapeViolations.get(migrationId); - if (existing) { - existing.count += 1; - } else { - this.admittedValueShapeViolations.set(migrationId, { - count: 1, - first: { object, field: violation.field, type: violation.type, detail: violation.detail }, - }); - } - // Two consumers of the same counterexample, answering different questions. - // The deviation marker goes first because it is the one that applies to - // EVERY verified deployment (#4797); the retraction below applies only to - // the narrow fresh-datastore case (#4769) and closes the gate outright - // there. Each is the other's backstop if one write fails. - this.recordObservedDeviation(migrationId); - this.retractCreationAttestation(migrationId); + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readAdmittedValueShapeViolations = () => this.admittedValueShapeViolations; + const host: ThisParameterType = { + get admittedValueShapeViolations() { return readAdmittedValueShapeViolations(); }, + recordObservedDeviation: (...a) => this.recordObservedDeviation(...a), + retractCreationAttestation: (...a) => this.retractCreationAttestation(...a), + }; + return noteAdmittedValueShapeViolation.call(host, object, violation); } /** @@ -12471,46 +12408,20 @@ export class ObjectQL implements IObjectQLEngine { * that declares neither class of field. */ async announceOpenMigrationGates(): Promise { - if (this.migrationGatesAnnounced) return; - this.migrationGatesAnnounced = true; - try { - const mediaByEnv = mediaPostureSetByEnv(); - const coveredByEnv = valueShapePostureSetByEnv(); - if (mediaByEnv && coveredByEnv) return; - - let media = false; - let covered = false; - for (const obj of this._registry.getAllObjects() as any[]) { - if (!media && !mediaByEnv && this.objectHasMediaField(obj)) media = true; - if (!covered && !coveredByEnv && this.objectHasCoveredValueField(obj)) covered = true; - if ((media || mediaByEnv) && (covered || coveredByEnv)) break; - } - - // [#4769] Read the ledger, not the memo. This advisory runs once, at - // `kernel:bootstrapped`, and the fresh-datastore attestation may have - // written its rows moments earlier — after the boot's first write had - // already memoized "not verified". Reporting from that memo tells a - // brand-new deployment to run a migration whose gate is already closed. - // One query per applicable gate, once per boot. - if (media && !(await this.readMigrationFlagVerified(FILE_REFERENCES_MIGRATION_ID)).verified) { - this.logger.info( - '[value-shape] media values are checked but NOT enforced here, and released files are ' + - 'never collected — this deployment has not verified its file migration. Run ' + - '`os migrate files-to-references` (dry run) to see what it would do, then `--apply` ' + - 'to close the gate (ADR-0104).', - ); - } - if (covered && !(await this.readMigrationFlagVerified(VALUE_SHAPES_MIGRATION_ID)).verified) { - this.logger.info( - '[value-shape] reference and structured-JSON values are checked but NOT enforced here — ' + - 'this deployment has not verified its value-shape scan. Run `os migrate value-shapes` ' + - '(dry run) to see what it would report, then `--apply` to close the gate ' + - '(ADR-0104).', - ); - } - } catch { - // An advisory must never be the reason a boot fails. - } + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readMigrationGatesAnnounced = () => this.migrationGatesAnnounced; + const writeMigrationGatesAnnounced = (v: ObjectQL['migrationGatesAnnounced']) => { this.migrationGatesAnnounced = v; }; + const readRegistry = () => this._registry; + const readLogger = () => this.logger; + const host: ThisParameterType = { + get migrationGatesAnnounced() { return readMigrationGatesAnnounced(); }, set migrationGatesAnnounced(v) { writeMigrationGatesAnnounced(v); }, + get _registry() { return readRegistry(); }, + objectHasMediaField: (...a) => this.objectHasMediaField(...a), + objectHasCoveredValueField: (...a) => this.objectHasCoveredValueField(...a), + readMigrationFlagVerified: (...a) => this.readMigrationFlagVerified(...a), + get logger() { return readLogger(); }, + }; + return announceOpenMigrationGates.call(host); } private migrationGatesAnnounced = false; @@ -12531,17 +12442,12 @@ export class ObjectQL implements IObjectQLEngine { * counts describe a moment that has passed. */ wasDatastoreCreatedFromEmpty(): boolean { - let created = 0; - let existing = 0; - let reporting = 0; - for (const driver of this.drivers.values()) { - const stats = (driver as any).getSchemaSyncStats?.(); - if (!stats) continue; - reporting += 1; - created += Number(stats.created) || 0; - existing += Number(stats.existing) || 0; - } - return reporting > 0 && existing === 0 && created > 0; + // [#25522 cut 13] The body moved to `engine-migration-flags.ts` (see `MigrationFlagsHost`). + const readDrivers = () => this.drivers; + const host: ThisParameterType = { + get drivers() { return readDrivers(); }, + }; + return wasDatastoreCreatedFromEmpty.call(host); } async destroy() { diff --git a/scripts/adr-anchors/packages__objectql__src__engine-execute-transactions.ts.json b/scripts/adr-anchors/packages__objectql__src__engine-execute-transactions.ts.json index 4659546952b..6fd4325936a 100644 --- a/scripts/adr-anchors/packages__objectql__src__engine-execute-transactions.ts.json +++ b/scripts/adr-anchors/packages__objectql__src__engine-execute-transactions.ts.json @@ -5,5 +5,5 @@ "ADR-0067", "ADR-0119" ], - "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `buildDriverOptions` threads the ambient handle ONLY to the driver that owns it, compared by instance identity via `TransactionScope` — handing it to another driver does not put that driver inside the transaction, it runs the statement on the wrong connection. `enforceTransactionOrigin` then decides the write: an append-only system ledger (`lifecycle.class` in `SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads — never a second copy) is CARVED OUT and executed outside the transaction, surviving rollback; any other cross-driver write is REFUSED before hooks run. Never add a companion transaction on the second driver (no two-phase commit in IDataDriver) and never restore the unconditional handle lift." + "invariant": "The transaction seam is same-origin by construction (#5351/#5696, 2026-08-06 ruling). `enforceTransactionOrigin` then decides the write: an append-only system ledger (`lifecycle.class` in `SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads — never a second copy) is CARVED OUT and executed outside the transaction, surviving rollback; any other cross-driver write is REFUSED before hooks run. Never add a companion transaction on the second driver (no two-phase commit in IDataDriver)." }