@substrat-run/adapter-cloudflare 0.161.0 → 0.162.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.
@@ -1,4 +1,5 @@
1
1
  import { DurableObject } from 'cloudflare:workers';
2
+ import { type TenantId } from '@substrat-run/contracts';
2
3
  import { type DeclaredPermissions, type SwitchSql, type ModuleRegistration, type OperationHandler } from '@substrat-run/kernel';
3
4
  import { type DoReply } from './do-reply.js';
4
5
  import { splitSqlStatements } from '@substrat-run/kernel';
@@ -42,6 +43,32 @@ export interface ScopeDoEnv {
42
43
  }
43
44
  /** #2016: how a tenant reads against a scope's record (`ScopeDO.tenantVerdict`). */
44
45
  export type TenantVerdict = 'recorded' | 'inferred' | 'foreign' | 'unknown';
46
+ /**
47
+ * #2531: what a unit that writes a role set judges it with, beyond what this scope holds: the
48
+ * reservations of the caller's host (its registered modules and its `permissions`). This class's own
49
+ * are joined in the unit. The set itself is the one the unit writes.
50
+ */
51
+ export type RoleJudge = {
52
+ reserved: string[];
53
+ declared: string[];
54
+ };
55
+ /** A role row as `applyProjection` writes it. */
56
+ type ProjectedRoleRow = {
57
+ role_key: string;
58
+ permissions: string;
59
+ source: string;
60
+ holder?: 'person' | 'system' | null;
61
+ };
62
+ /**
63
+ * #2531: a load's role repair, judged and written in the load's own transaction after the dump's
64
+ * rows are in: the vertical's role set for `tenantId`, projected as `projectRolesLocal` projects it.
65
+ * #2555's second step can pass the directory's set through the same field.
66
+ */
67
+ export type LoadRoleRepair = {
68
+ tenantId: TenantId;
69
+ roles: ProjectedRoleRow[];
70
+ judge: RoleJudge;
71
+ };
45
72
  /**
46
73
  * What a capability attachment verb (#1686) answers: its value, or its failure as DATA —
47
74
  * `invoke`'s envelope discipline, since a throw across the RPC keeps only its message.
@@ -1 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAgEnD,OAAO,EA2DL,KAAK,mBAAmB,EASxB,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAmHtB,MAAM,sBAAsB,CAAC;AAoD9B,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAGtD,OAAO,EAwBL,kBAAkB,EAiFnB,MAAM,sBAAsB,CAAC;AA2B9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAoFD,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AA2rB5E;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAmBtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,gHAAgH;AAChH,OAAO,EAAE,kBAAkB,EAAE,CAAC;AA2B9B,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;AACzD;;;;GAIG;AACH,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,mBAAmB,CAAA;CAAO,GAClD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAAC,UAAU,CAAC,CA00P7E"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAmDL,KAAK,QAAQ,EAWd,MAAM,yBAAyB,CAAC;AACjC,OAAO,EA8DL,KAAK,mBAAmB,EASxB,KAAK,SAAS,EAsBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAmHtB,MAAM,sBAAsB,CAAC;AAoD9B,OAAO,EAAW,KAAK,OAAO,EAAE,MAAM,eAAe,CAAC;AAGtD,OAAO,EAwBL,kBAAkB,EAiFnB,MAAM,sBAAsB,CAAC;AA2B9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;;OAKG;IACH,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC;AAoFD,oFAAoF;AACpF,MAAM,MAAM,aAAa,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,GAAG,SAAS,CAAC;AAgB5E;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC;AAEnE,iDAAiD;AACjD,KAAK,gBAAgB,GAAG;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,IAAI,CAAA;CAAE,CAAC;AAEvH;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG;IAAE,QAAQ,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAAC,KAAK,EAAE,SAAS,CAAA;CAAE,CAAC;AA4rBjG;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AAmBtD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,gHAAgH;AAChH,OAAO,EAAE,kBAAkB,EAAE,CAAC;AA2B9B,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;AACzD;;;;GAIG;AACH,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,mBAAmB,CAAA;CAAO,GAClD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAAC,UAAU,CAAC,CA67P7E"}
package/dist/scope-do.js CHANGED
@@ -2,7 +2,7 @@ import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  import { SYSTEM_DOOR_MOVED } from './system-door.js';
3
3
  import { DurableObject } from 'cloudflare:workers';
4
4
  import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, principalBecomeCapabilityInput, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_DUMP_PAGE_MAX_BYTES, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, pageOf, countedPageOf, requestFingerprint, substratError, errorCodeOf, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
5
- import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
5
+ import { ulid, DO_SQL_LIMITS, unknownRoleError, createUlid, assertAllowed, ConnectionSealingKeyUnavailableError, noSealingKeyMessage, sealTo, assertReadOnlyQuery, entitlementDenial, platformRequestHistoryQuery, platformRequestOf, PLATFORM_REQUEST_COLUMNS, PLATFORM_REQUEST_REDACTION_SQL, platformRequestRedactionParams, platformRequestRedactionQuery, intentPayloadCarriesSubject, redactSubjectJobRuns, eraseSubjectFromModules, moduleErasurePlan, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_RUN_CLAIM_SQL, JOB_RUN_RENEW_SQL, JOB_RUN_BEGIN_SQL, JOB_RUN_MISS_SQL, JOB_RUN_MISS_SETTLE_SQL, admissionMissOutcome, JOB_LEASE_EXPIRED_NOTE, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, delegatedRevokeSql, writeExplicitTupleIn, grantEntityShapeIn, topUpEntityGrantShapes, applyScopeRoleChange, changeScopeRole, revokeScopeRoles, scopeRoleHolders, assertModuleDeclared, noteDeclaredKeys, noteReservedKeys, SCOPE_SEATED_ROLES_SQL, inUnitRoleRefusal, reservedRoleSetRefusedRole, reservedGrantRefusal, reservedRoleRefusal, reservedShapeRefusal, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, refusalListQuery, mapRefusalRow, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, MIGRATION_DIGEST_FENCE_DDL, MIGRATION_DIGEST_MARK_LEGACY, assertJournalDumpCoherent, assertMigrationSql, migrationDivergence, migrationFailedError, migrationSetDivergence, migrationSteps, planMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, ATTACHMENT_BYTES_SQL, ATTACHMENT_CONDEMNED_DDL, ATTACHMENT_SWEEP_LICENCE_DDL, ATTACHMENT_SWEEP_LICENCE_TABLE, clearOrphanSweepLicence, condemnLicensedOrphanAttachments, consumeOrphanSweepLicence, licensedOrphanIds, recordOrphanSweepLicence, refuseUnlicensedOrphanSweep, refuseCondemnedAttachment, refuseCondemnedInDump, rowlessAttachmentIds, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, queueAttachmentTextBackfill, startAttachmentTextBackfill, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_TABLE_DDL, REFUSALS_INDEX, refusalInsert, refusalOf, markGuardRefusal, REFUSALS_REBUILD, refusalsAdmitGuards, assertIdempotencyKey, assertPermissionKey, idempotencyLookupQuery, idempotencyPruneStatement, idempotencyRecordStatement, idempotencyOptedOutMessage, replayFor, entityVersionQuery, entityVersionOf, assertIfMatch, OUTBOX_ENTITY_INDEX, SCHEDULE_STATE_DDL, SCHEDULE_STATE_REBUILD, scheduleStateHasKind, VERTICAL_EVENTS_DDL, EXPORT_HOPS_SQL, emptyImportResult, IMPORT_CURSORS_SQL, IMPORT_CURSOR_OF_SQL, OUTBOX_MARK_SQL, exportedSinceQuery, emittedSinceQuery, emittedReportOf, EMITTED_REPORT_CAP, IMPORT_CURSOR_ADVANCE_SQL, IMPORT_RECORD_SQL, moveImportCursor, CrossVerticalRegistry, exportReadPlan, exportReadQuery, planExportBatch, withheldNote, JOB_RUN_DDL, JOB_RUN_DUE_AT, jobRunListLimit, SYSTEM_DOOR_WAIT, ancestorsWithin, } from '@substrat-run/kernel';
6
6
  import { isUpgradeRequest, readSubscription, LIVE_DECIDE_ATTEMPTS, LIVE_FANOUT_LIMIT, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, LIVE_WITHIN_HEADER, LIVE_EXPIRES_HEADER, LIVE_CLOSE, LIVE_SOCKETS_PER_PRINCIPAL, decodeLiveWithin, liveInstant, liveRefusal, liveRefusalStatus, } from './live-reads.js';
7
7
  import { replyOf } from './do-reply.js';
8
8
  import { OperationQueue } from './serialization.js';
@@ -44,6 +44,14 @@ const tenantRefusals = new WeakSet();
44
44
  /** #1722: a load refused because its copy move's lease ran out (`CopyRestoreFence`), told apart
45
45
  * from a store that moved, since the host must not read it as an applied retry. */
46
46
  const fenceRefusals = new WeakSet();
47
+ /** #2531: a role set refused inside the unit writing it (`refuseRoleSetInUnit`), told apart by identity. */
48
+ const roleRefusals = new WeakSet();
49
+ /** `ProjectedRoleRow` as the reserved-key rule reads it. */
50
+ const roleShapeOfRow = (r) => ({
51
+ key: r.role_key,
52
+ permissions: JSON.parse(r.permissions),
53
+ ...(r.holder === 'system' ? { holder: 'system' } : r.holder == null ? {} : { holder: 'person' }),
54
+ });
47
55
  /**
48
56
  * The scope spine, as this adapter builds it — one of two hand-written copies (#969).
49
57
  *
@@ -3860,6 +3868,10 @@ options = {}) {
3860
3868
  * set defined on this scope, or a role definition fanned out to it, is judged against.
3861
3869
  */
3862
3870
  async roleStateHere(tenantId) {
3871
+ return this.roleStateSync(tenantId);
3872
+ }
3873
+ /** `roleStateHere`, read synchronously, so a unit that writes roles or seats can judge on what it holds (#2531). */
3874
+ roleStateSync(tenantId) {
3863
3875
  // Spine tables only (built in the constructor), so no module migration runs ahead of a provision's.
3864
3876
  const now = new Date().toISOString();
3865
3877
  const rows = this.hasTable('_substrat_roles')
@@ -3875,6 +3887,25 @@ options = {}) {
3875
3887
  const seated = new Set(this.sql.exec(SCOPE_SEATED_ROLES_SQL, now).toArray().map((r) => r.role_key));
3876
3888
  return { roles, seated: [...seated], dropped };
3877
3889
  }
3890
+ /**
3891
+ * #2531: the in-unit judgement. Throws when `roles`, the set the caller's unit is writing for
3892
+ * `tenantId`, is refused by the reserved-key rule (`reservedRoleSetRefusal`, one call) against the
3893
+ * roles, tombstones and seats this scope holds at that moment, with the caller's reservations
3894
+ * joined with this class's. Synchronous, so the unit it runs in cannot interleave with a write
3895
+ * between this read and its own writes; a refusal rolls the whole unit back.
3896
+ *
3897
+ * The answer is a fixed sentence naming the role key only, whoever reads it. The rule's own
3898
+ * wording is the pre-check's, which a caller still asks first for the common case.
3899
+ */
3900
+ refuseRoleSetInUnit(tenantId, roles, judge) {
3901
+ const state = this.roleStateSync(tenantId);
3902
+ const refused = reservedRoleSetRefusedRole(roles, { reserved: new Set([...judge.reserved, ...this.reservedKeys]), declared: new Set([...judge.declared, ...this.declaredKeys]) }, { roles: state.roles, seated: new Set(state.seated), ...(state.dropped ? { dropped: state.dropped } : {}) });
3903
+ if (refused === null)
3904
+ return;
3905
+ const error = substratError('forbidden', inUnitRoleRefusal(refused.key));
3906
+ roleRefusals.add(error);
3907
+ throw error;
3908
+ }
3878
3909
  /** #2447: what this class's modules reserve and declare — a coordinator judging a role definition joins its own. */
3879
3910
  async reservationHere() {
3880
3911
  return { reserved: [...this.reservedKeys], declared: [...this.declaredKeys] };
@@ -5578,7 +5609,7 @@ options = {}) {
5578
5609
  * in memory at once. `stageId` names the staged load the rows come from: checked inside the
5579
5610
  * load's transaction, and its tables dropped in it once the rows are in.
5580
5611
  */
5581
- async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, } = {}, stageId) {
5612
+ async loadTables(tables, destScopeId, { switchOff, sourceScopeId, exact, loadStamp, expect, resolveKept, markCopy, provisionedFor, fence, roleRepair, onRolesProjected, } = {}, stageId) {
5582
5613
  // The WHOLE drop-then-replay runs under deferred foreign keys, in one transaction.
5583
5614
  //
5584
5615
  // Two distinct FK hazards, and both need the deferral — the second is why it cannot
@@ -5615,6 +5646,10 @@ options = {}) {
5615
5646
  if (exact && !sourceScopeId) {
5616
5647
  throw substratError('validation_failed', 'restore refused: `exact` needs the scope the dump came from');
5617
5648
  }
5649
+ // #2531: a reaped store takes no role repair, judged or not, as `projectInUnit` drops a
5650
+ // projection there: the load comes back with no roles, no receipt and no enforcement flip,
5651
+ // as it did before the repair rode the load. Read before the unit, as `projectInUnit` reads it.
5652
+ const repair = roleRepair && !(await this.isReaped()) ? roleRepair : undefined;
5618
5653
  const switched = await this.revision.transaction(async () => {
5619
5654
  // #1722: first, before any read: a store a reap has since emptied must refuse the late load
5620
5655
  // too, and the check is against this store's clock, inside the load's own transaction.
@@ -5705,7 +5740,8 @@ options = {}) {
5705
5740
  // `_substrat_tenant_tuples` in its DIRECTORY database, so its per-scope dump omits
5706
5741
  // them, and without them the very next permission check would raise a bare `no such
5707
5742
  // table: _substrat_roles`. Roles land empty here and are re-projected by the
5708
- // restore's repair leg (host.projectRolesLocal) — the spine's job is only to exist so
5743
+ // restore's repair leg (`roleRepair` below, or host.projectRolesLocal after the load,
5744
+ // #2531) — the spine's job is only to exist so
5709
5745
  // the checker can read it. The column pass follows, for the one outbox index KERNEL_DDL
5710
5746
  // leaves to it.
5711
5747
  for (const stmt of executableSqlStatements(KERNEL_DDL))
@@ -5786,9 +5822,28 @@ options = {}) {
5786
5822
  // #1742: the recorded-off modules go back off INSIDE the replay's transaction, with
5787
5823
  // the spine and the re-point. A switch that throws rolls the whole restore back, so
5788
5824
  // the dump's grants never commit live without the switch that should cover them.
5789
- return destScopeId && switchOff
5825
+ const switchedOff = destScopeId && switchOff
5790
5826
  ? switchRecordedOff(this.switchSql(), { scopeId: destScopeId, ...switchOff })
5791
5827
  : [];
5828
+ // #2531: the role repair, last, in this same transaction: judged on the roles, tombstones and
5829
+ // seats the dump has just brought (`refuseRoleSetInUnit`), then projected exactly as
5830
+ // `projectRolesLocal` projects it. A refusal rolls the whole load back, so the scope keeps
5831
+ // its rows and a staged load keeps its stage; an admitted load lands with its repair.
5832
+ if (repair) {
5833
+ try {
5834
+ this.projectionBody(repair.judge, repair.tenantId, repair.roles, []);
5835
+ }
5836
+ catch (e) {
5837
+ // The projection's own tenant refusal (its receipt check) is a tenant refusal of the
5838
+ // load, not the kept-copy `conflict` `checkedLoad` would otherwise read it as.
5839
+ if (e instanceof Error && !roleRefusals.has(e) && errorCodeOf(e) === 'conflict')
5840
+ tenantRefusals.add(e);
5841
+ throw e;
5842
+ }
5843
+ }
5844
+ if (repair)
5845
+ onRolesProjected?.();
5846
+ return switchedOff;
5792
5847
  }
5793
5848
  finally {
5794
5849
  this.revisionSuspended = false;
@@ -5849,6 +5904,28 @@ options = {}) {
5849
5904
  async importDumpChecked(tables, destScopeId, opts) {
5850
5905
  return this.checkedLoad(() => this.importDump(tables, destScopeId, opts));
5851
5906
  }
5907
+ /**
5908
+ * #2531: `importDumpChecked` with the role repair judged and projected in the load's own
5909
+ * transaction (`roleRepair`), answered `rolesProjected: true` when it landed there. A reaped
5910
+ * store takes no repair (`loadTables`), so its load answers without it. A method of its
5911
+ * own, so a host meeting a ScopeDO from before it is refused by the runtime, nothing loaded,
5912
+ * rather than loading with the repair silently dropped.
5913
+ */
5914
+ async importDumpJudged(tables, destScopeId, opts, roleRepair) {
5915
+ return this.judgedLoad((onRolesProjected) => this.checkedLoad(() => this.importDump(tables, destScopeId, { ...opts, roleRepair, onRolesProjected })));
5916
+ }
5917
+ /** #2531: `commitStagedDumpChecked` with the role repair in the load's transaction (`importDumpJudged`). */
5918
+ async commitStagedDumpJudged(stageId, destScopeId, opts, roleRepair) {
5919
+ return this.judgedLoad((onRolesProjected) => this.commitStagedDumpChecked(stageId, destScopeId, { ...opts, roleRepair, onRolesProjected }));
5920
+ }
5921
+ /** A judged load, answered `rolesProjected: true` only when its repair was written in it. */
5922
+ async judgedLoad(run) {
5923
+ let projected = false;
5924
+ const out = await run(() => {
5925
+ projected = true;
5926
+ });
5927
+ return out.refused === false && projected ? { ...out, rolesProjected: true } : out;
5928
+ }
5852
5929
  /**
5853
5930
  * Stage one page of a dump for a later `commitStagedDump` (#2450). The rows go into this
5854
5931
  * store's own staging tables, untyped, so every cell is kept as it arrived; nothing of the
@@ -6011,6 +6088,8 @@ options = {}) {
6011
6088
  catch (e) {
6012
6089
  if (e instanceof Error && fenceRefusals.has(e))
6013
6090
  return { refused: 'lapsed' };
6091
+ if (e instanceof Error && roleRefusals.has(e))
6092
+ return { refused: 'roles', message: e.message };
6014
6093
  const code = errorCodeOf(e);
6015
6094
  if (code === 'precondition_failed')
6016
6095
  return { refused: 'changed' };
@@ -7028,6 +7107,17 @@ options = {}) {
7028
7107
  applyProjectionReply(...args) {
7029
7108
  return replyOf(() => this.applyProjection(...args));
7030
7109
  }
7110
+ /**
7111
+ * #2531: `applyProjectionReply` with the role set judged INSIDE the projection's unit, against
7112
+ * the roles, tombstones and seats this scope holds as it writes (`refuseRoleSetInUnit`). A
7113
+ * refusal writes nothing: no role row, tombstone, receipt, seat, switch or enforcement flip.
7114
+ * A method of its own, not an argument, so a host meeting a ScopeDO from before it is refused
7115
+ * by the runtime (no such method) before anything is written, rather than projecting unjudged.
7116
+ * The directory fan-out does not call it (K-55, #2531).
7117
+ */
7118
+ applyJudgedProjectionReply(judge, ...args) {
7119
+ return replyOf(() => this.projectInUnit(judge, ...args));
7120
+ }
7031
7121
  /**
7032
7122
  * Replace this scope's projected view of a tenant's roles + tenant-level tuples
7033
7123
  * with a fresh snapshot, and make it authoritative (source = 'local'). A full
@@ -7072,6 +7162,10 @@ options = {}) {
7072
7162
  switchOff,
7073
7163
  /** Declared service subjects excluded only from human lockout repair (#1896). */
7074
7164
  serviceSubjects) {
7165
+ return this.projectInUnit(undefined, tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects);
7166
+ }
7167
+ /** `applyProjection`'s unit, its role set judged in it when `judge` is given (#2531). */
7168
+ async projectInUnit(judge, ...args) {
7075
7169
  if (await this.isReaped())
7076
7170
  return [];
7077
7171
  // A projection that arrives after this scope was reaped is dropped, not
@@ -7082,123 +7176,131 @@ options = {}) {
7082
7176
  // The whole body is synchronous storage work, so it is ONE transaction (#1738): the queue
7083
7177
  // serializes but does not roll back, and a role insert that throws part-way must not leave
7084
7178
  // the receipt (or a half-replaced role set) behind for `servesTenant` to trust.
7085
- return this.queue.enqueue(() => this.revision.transactionSync(() => {
7086
- // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
7087
- // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
7088
- // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
7089
- // A restore does not trip this: `importDump` keeps the destination's own receipt and
7090
- // never the dump's (#2016), so the repair projection that follows finds its own tenant.
7091
- // #2016: a scope with no receipt yet takes its first one only where its role rows agree
7092
- // (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
7093
- // another tenant cannot pin a legacy scope to it.
7094
- const { verdict, held } = this.tenantVerdict(tenantId);
7095
- if (verdict === 'foreign') {
7096
- throw substratError('conflict', held !== null
7097
- ? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
7098
- : `applyProjection ${foreignTenant(held, tenantId)}`);
7099
- }
7100
- // Written before any guard below can return early, so every projection leaves it.
7101
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
7102
- // #2531: a role the new set leaves out is TOMBSTONED, not deleted: it keeps the keys and the
7103
- // holder it had, and grants nothing (every reader skips a revoked row). Kept for good, as
7104
- // evidence (K-21): the reserved-key rule reads a system-held tombstone as the stored
7105
- // definition it was, so a release that drops a role and a later one that adds it back
7106
- // are judged as one that kept it. A row already tombstoned keeps its first `revoked_at`;
7107
- // a role the set names is written live again below, which clears it.
7108
- this.sql.exec(`UPDATE _substrat_roles SET revoked_at = ? WHERE tenant_id = ? AND revoked_at IS NULL`, new Date().toISOString(), tenantId);
7109
- for (const r of roles) {
7110
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder, revoked_at)
7111
- VALUES (?, ?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source, r.holder === 'system' ? 'system' : r.holder == null ? null : 'person');
7112
- }
7113
- this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
7114
- for (const t of tuples) {
7115
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
7116
- (tenant_id, subject, relation, object, expires_at, revoked_at)
7117
- VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
7118
- }
7119
- if (entitlements !== undefined) {
7120
- this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
7121
- for (const e of entitlements) {
7122
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
7123
- (tenant_id, entitlement_key, expires_at, quota, plan)
7124
- VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
7125
- }
7126
- // #304: once a scope has been projected WITH entitlements even once, its gate
7127
- // switches from trust-upstream to strict fail-closed (a missing/expired key
7128
- // denies). Left unset, a scope provisioned before #304 keeps trusting upstream
7129
- // until a projection (fanOut / reconcile / re-provision) back-fills it — so the
7130
- // enforcement flip is per-scope and never strands an un-back-filled scope.
7131
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('entitlements_enforced', '1')`);
7179
+ return this.queue.enqueue(() => this.revision.transactionSync(() => this.projectionBody(judge, ...args)));
7180
+ }
7181
+ /**
7182
+ * The projection's writes, synchronous, for a unit the caller holds: `projectInUnit`'s, or a
7183
+ * load's (`loadTables`, #2531), so a restore's role repair lands in the load's transaction.
7184
+ */
7185
+ projectionBody(judge, ...[tenantId, roles, tuples, entitlements, scopeTuples, identities, connectionKeys, switchOff, serviceSubjects]) {
7186
+ // #1738: the receipt `servesTenant` reads. First writer wins: absent or equal is written,
7187
+ // a receipt for ANOTHER tenant refuses the whole projection (K-3), before a single row
7188
+ // moves, so a misdirected projection can never re-point a scope or leave its roles behind.
7189
+ // A restore does not trip this: `importDump` keeps the destination's own receipt and
7190
+ // never the dump's (#2016), so the repair projection that follows finds its own tenant.
7191
+ // #2016: a scope with no receipt yet takes its first one only where its role rows agree
7192
+ // (`tenantVerdict`, the same reading the lifecycle back-fill uses), so a projection for
7193
+ // another tenant cannot pin a legacy scope to it.
7194
+ const { verdict, held } = this.tenantVerdict(tenantId);
7195
+ if (verdict === 'foreign') {
7196
+ throw substratError('conflict', held !== null
7197
+ ? `applyProjection refused: this scope was provisioned for tenant ${held}, and a projection for tenant ${tenantId} would re-point it`
7198
+ : `applyProjection ${foreignTenant(held, tenantId)}`);
7199
+ }
7200
+ // #2531: judged before the first write of the unit, on what the scope holds now.
7201
+ if (judge)
7202
+ this.refuseRoleSetInUnit(tenantId, roles.map(roleShapeOfRow), judge);
7203
+ // Written before any guard below can return early, so every projection leaves it.
7204
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES (?, ?)`, PROVISIONED_FOR_KEY, tenantId);
7205
+ // #2531: a role the new set leaves out is TOMBSTONED, not deleted: it keeps the keys and the
7206
+ // holder it had, and grants nothing (every reader skips a revoked row). Kept for good, as
7207
+ // evidence (K-21): the reserved-key rule reads a system-held tombstone as the stored
7208
+ // definition it was, so a release that drops a role and a later one that adds it back
7209
+ // are judged as one that kept it. A row already tombstoned keeps its first `revoked_at`;
7210
+ // a role the set names is written live again below, which clears it.
7211
+ this.sql.exec(`UPDATE _substrat_roles SET revoked_at = ? WHERE tenant_id = ? AND revoked_at IS NULL`, new Date().toISOString(), tenantId);
7212
+ for (const r of roles) {
7213
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_roles (tenant_id, role_key, permissions, source, holder, revoked_at)
7214
+ VALUES (?, ?, ?, ?, ?, NULL)`, tenantId, r.role_key, r.permissions, r.source, r.holder === 'system' ? 'system' : r.holder == null ? null : 'person');
7215
+ }
7216
+ this.sql.exec(`DELETE FROM _substrat_tenant_tuples WHERE tenant_id = ?`, tenantId);
7217
+ for (const t of tuples) {
7218
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tenant_tuples
7219
+ (tenant_id, subject, relation, object, expires_at, revoked_at)
7220
+ VALUES (?, ?, ?, ?, ?, ?)`, tenantId, t.subject, t.relation, t.object, t.expires_at, t.revoked_at);
7221
+ }
7222
+ if (entitlements !== undefined) {
7223
+ this.sql.exec(`DELETE FROM _substrat_entitlements WHERE tenant_id = ?`, tenantId);
7224
+ for (const e of entitlements) {
7225
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_entitlements
7226
+ (tenant_id, entitlement_key, expires_at, quota, plan)
7227
+ VALUES (?, ?, ?, ?, ?)`, tenantId, e.entitlement_key, e.expires_at, e.quota, e.plan);
7132
7228
  }
7133
- // #406: identity links ride the same snapshot. Full replace, so an unlink is
7134
- // durable against every later projection — unlike the compiled-in map this
7135
- // replaces, where a version rollback silently resurrected a removed login.
7136
- if (identities !== undefined) {
7137
- this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
7138
- for (const i of identities) {
7139
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
7140
- (tenant_id, provider, external_id, principal_id, scope_id)
7141
- VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
7142
- }
7229
+ // #304: once a scope has been projected WITH entitlements even once, its gate
7230
+ // switches from trust-upstream to strict fail-closed (a missing/expired key
7231
+ // denies). Left unset, a scope provisioned before #304 keeps trusting upstream
7232
+ // until a projection (fanOut / reconcile / re-provision) back-fills it — so the
7233
+ // enforcement flip is per-scope and never strands an un-back-filled scope.
7234
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('entitlements_enforced', '1')`);
7235
+ }
7236
+ // #406: identity links ride the same snapshot. Full replace, so an unlink is
7237
+ // durable against every later projection — unlike the compiled-in map this
7238
+ // replaces, where a version rollback silently resurrected a removed login.
7239
+ if (identities !== undefined) {
7240
+ this.sql.exec(`DELETE FROM _substrat_identity_links WHERE tenant_id = ?`, tenantId);
7241
+ for (const i of identities) {
7242
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_identity_links
7243
+ (tenant_id, provider, external_id, principal_id, scope_id)
7244
+ VALUES (?, ?, ?, ?, ?)`, tenantId, i.provider, i.external_id, i.principal_id, i.scope_id);
7143
7245
  }
7144
- // #687: connection sealing keys ride the same snapshot. Full replace, so a
7145
- // revoked connection's key stops being projected and `sealToConnection` starts
7146
- // refusing — the same fail-closed direction an unlinked identity takes.
7147
- if (connectionKeys !== undefined) {
7148
- this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
7149
- for (const k of connectionKeys) {
7150
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
7151
- (tenant_id, connection_id, provider, key_id, public_key)
7152
- VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
7153
- }
7246
+ }
7247
+ // #687: connection sealing keys ride the same snapshot. Full replace, so a
7248
+ // revoked connection's key stops being projected and `sealToConnection` starts
7249
+ // refusing — the same fail-closed direction an unlinked identity takes.
7250
+ if (connectionKeys !== undefined) {
7251
+ this.sql.exec(`DELETE FROM _substrat_connection_keys WHERE tenant_id = ?`, tenantId);
7252
+ for (const k of connectionKeys) {
7253
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_connection_keys
7254
+ (tenant_id, connection_id, provider, key_id, public_key)
7255
+ VALUES (?, ?, ?, ?, ?)`, tenantId, k.connection_id, k.provider, k.key_id, k.public_key);
7154
7256
  }
7155
- // #332: scope-level grants (the owner's role tuple at provision) are written in the
7156
- // SAME enqueued unit as the projection and the enforcement flip below. Additive upsert
7157
- // — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
7158
- // scope from ever being left "roles projected, permission_source=local, zero tuples" by
7159
- // a write that lands the projection but drops before a follow-up owner grant.
7160
- //
7161
- // #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
7162
- // used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
7163
- // of the owner seat or of a `system:` schedule grant on the next reconcile. And a
7164
- // module switched off (#1666) gets no `system:` grant seated, new or old.
7257
+ }
7258
+ // #332: scope-level grants (the owner's role tuple at provision) are written in the
7259
+ // SAME enqueued unit as the projection and the enforcement flip below. Additive upsert
7260
+ // — NOT a full replace — so existing scope tuples are preserved. This is what keeps a
7261
+ // scope from ever being left "roles projected, permission_source=local, zero tuples" by
7262
+ // a write that lands the projection but drops before a follow-up owner grant.
7263
+ //
7264
+ // #1659: SEATED, so a reconcile creates what is missing and leaves a revoke alone. It
7265
+ // used to be `INSERT OR REPLACE … revoked_at = NULL`, which undid an operator's revoke
7266
+ // of the owner seat or of a `system:` schedule grant on the next reconcile. And a
7267
+ // module switched off (#1666) gets no `system:` grant seated, new or old.
7268
+ for (const st of scopeTuples ?? []) {
7269
+ const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
7270
+ this.sql.exec(seat.sql, ...seat.params);
7271
+ }
7272
+ // #1659's one exception: the owner-of-record's seat comes back over a revoke when
7273
+ // No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
7274
+ // That is the #332 lockout this path exists to repair. The flip guard below still
7275
+ // counts service authority; only the repair excludes the declared service subjects.
7276
+ // With any other effective holder, the revoke stands: a hand-over that seats a
7277
+ // successor before unseating the owner is not undone by the next promote. A holder of
7278
+ // a role the vertical no longer defines is NOT one — it passes no check, so it must
7279
+ // not stand in for the holder this repair exists to restore.
7280
+ if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
7165
7281
  for (const st of scopeTuples ?? []) {
7166
- const seat = seatScopeTuple(st.subject, st.relation, st.object, st.expires_at);
7167
- this.sql.exec(seat.sql, ...seat.params);
7168
- }
7169
- // #1659's one exception: the owner-of-record's seat comes back over a revoke when
7170
- // No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
7171
- // That is the #332 lockout this path exists to repair. The flip guard below still
7172
- // counts service authority; only the repair excludes the declared service subjects.
7173
- // With any other effective holder, the revoke stands: a hand-over that seats a
7174
- // successor before unseating the owner is not undone by the next promote. A holder of
7175
- // a role the vertical no longer defines is NOT one — it passes no check, so it must
7176
- // not stand in for the holder this repair exists to restore.
7177
- if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
7178
- for (const st of scopeTuples ?? []) {
7179
- if (!st.lockout_reseat)
7180
- continue;
7181
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
7182
- VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
7183
- }
7282
+ if (!st.lockout_reseat)
7283
+ continue;
7284
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object, expires_at, revoked_at)
7285
+ VALUES (?, ?, ?, ?, NULL)`, st.subject, st.relation, st.object, st.expires_at);
7184
7286
  }
7185
- // #1742: the directory's recorded-off modules go back off HERE, after the seat and in
7186
- // its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
7187
- // a re-assert arriving later from the control plane left a window in which this
7188
- // deployment's own sweeper could fire them. Nothing runs between these two lines.
7189
- const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
7190
- // #332: only switch on strict local enforcement when SOMEONE actually holds a role.
7191
- // A projection that leaves role definitions but no effective principal→role grant would
7192
- // make every check fail closed — a scope serving nothing but denials, unfixable from
7193
- // inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
7194
- // grant re-runs this and flips safely. (A CP-less vertical uses the local reader
7195
- // regardless of this flag, so the owner grant is written above in the same unit — this
7196
- // guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
7197
- if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
7198
- return switched;
7199
- this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
7287
+ }
7288
+ // #1742: the directory's recorded-off modules go back off HERE, after the seat and in
7289
+ // its unit. A wiped scope's seat has just re-created their `system:` grants (#1659), and
7290
+ // a re-assert arriving later from the control plane left a window in which this
7291
+ // deployment's own sweeper could fire them. Nothing runs between these two lines.
7292
+ const switched = switchOff ? switchRecordedOff(this.switchSql(), switchOff) : [];
7293
+ // #332: only switch on strict local enforcement when SOMEONE actually holds a role.
7294
+ // A projection that leaves role definitions but no effective principal→role grant would
7295
+ // make every check fail closed — a scope serving nothing but denials, unfixable from
7296
+ // inside. Leave `permission_source` as-is instead; a reconcile that restores the owner
7297
+ // grant re-runs this and flips safely. (A CP-less vertical uses the local reader
7298
+ // regardless of this flag, so the owner grant is written above in the same unit — this
7299
+ // guard is the belt to that suspenders, and it protects the CP-backed flip outright.)
7300
+ if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId))
7200
7301
  return switched;
7201
- }));
7302
+ this.sql.exec(`INSERT OR REPLACE INTO _substrat_meta (key, value) VALUES ('permission_source', 'local')`);
7303
+ return switched;
7202
7304
  }
7203
7305
  /**
7204
7306
  * Resolve an external identity from this scope's projected links (#406) — the