@substrat-run/adapter-cloudflare 0.132.1 → 0.134.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 +1 @@
1
- {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAcL,KAAK,WAAW,EAsCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAoCL,KAAK,SAAS,EAmBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EAuEtB,MAAM,sBAAsB,CAAC;AAsF9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AA0bD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAklKjE"}
1
+ {"version":3,"file":"scope-do.d.ts","sourceRoot":"","sources":["../src/scope-do.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAcL,KAAK,WAAW,EAsCjB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAuCL,KAAK,SAAS,EAmBd,KAAK,kBAAkB,EAEvB,KAAK,gBAAgB,EA+EtB,MAAM,sBAAsB,CAAC;AAsF9B;;;;;;;;;;;;;GAaG;AAEH,MAAM,WAAW,UAAU;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,sBAAsB,CAAC;IACvC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AA6bD;;;GAGG;AACH,MAAM,MAAM,yBAAyB,CAAC,CAAC,IAAI;IAAE,KAAK,EAAE,CAAC,CAAC;IAAC,OAAO,CAAC,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAgDxG;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,UAAU,GAAG,SAAS,CAOxD;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,EAAE,CAwDxD;AAED,wBAAgB,aAAa,CAC3B,OAAO,EAAE,kBAAkB,EAAE,EAC7B,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,GACxD,KAAK,GAAG,EAAE,kBAAkB,EAAE,GAAG,EAAE,UAAU,KAAK,aAAa,CAupKjE"}
package/dist/scope-do.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { REWIND_REFUSED } from './rewind-refusal.js';
2
2
  import { DurableObject } from 'cloudflare:workers';
3
3
  import { ATTACHMENT_ADDED, ATTACHMENT_REMOVED, attachmentRecord, domainEvent, domainEventInput, eventId, instant, objectRef, entityObjectRef, assertModuleEmittableType, assertKernelAuthoredType, toWireFailure, grantRefFromProof, principalId, scopeId as scopeIdOf, tenantId as tenantIdOf, platformRequestInput, platformRequestId, MAX_PENDING_PLATFORM_REQUESTS, MAX_PENDING_SWEEP_RUNS, SWEEP_RUNS_KIND, SCOPE_TABLE_PAGE_MAX, SCOPE_QUERY_ROW_MAX, listLimitOf, requestFingerprint, substratError, assertReplayableDump, REDRAIN_BATCH, } from '@substrat-run/contracts';
4
- 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, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, 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, 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, jobRunListLimit, } from '@substrat-run/kernel';
4
+ 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, redactSubjectScopeText, assertRowLimit, assertRowOffset, JOB_RUN_PATCH_SQL, JOB_STEP_RECORD_SQL, DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, seatScopeTuple, effectiveRoleGrantQuery, switchRecordedOff, switchSystemSchedules, peerGrantsStatus, systemGrantsStatus, systemScheduleState, systemSwitchedOff, denialListQuery, denialSummaryQuery, denialTotalsQuery, DENIAL_WINDOW_QUERY, mapDenialRow, mapDenialSummaryBuckets, PermissionDenied, assertImpersonationWrites, assertModuleEnqueueableKind, impersonationStampOf, createAtomic, NotSearchable, isSearchIndexTable, searchIndexDdl, searchIndexPlans, NotListable, listIndexPlans, moduleMigrations, listQuery, cursorOf, searchLimit, searchMatchExpression, searchQuery, ATTACHMENT_TEXT_DDL, attachmentRecordOfRow, enqueueAttachmentText, reconcileAttachmentText, recordAttachmentText, searchAttachments, IDEMPOTENCY_DDL, REFUSALS_DDL, refusalInsert, refusedTransitionOf, 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, 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, jobRunListLimit, } from '@substrat-run/kernel';
5
5
  import { isUpgradeRequest, readSubscription, LIVE_FANOUT_LIMIT, LIVE_MODE_HEADER, LIVE_PRINCIPAL_HEADER, LIVE_SCOPE_HEADER, LIVE_SUBSCRIBE_PATH, LIVE_TENANT_HEADER, } from './live-reads.js';
6
6
  import { OperationQueue } from './serialization.js';
7
- import { doScopedSql, doBuiltColumnsOf, doSpineSql } from './sql.js';
7
+ import { doScopedSql, doBuiltColumnsOf, doRedactionSql, doSpineSql } from './sql.js';
8
8
  import { actorOf, admitPeer, collectPeers, switchPeer, assertNoSecret, CAPABILITY_DDL, CAPABILITY_EXCHANGE_OPERATION, capabilityAttachmentWriteRefused, createCapabilityVerbs, createEntityEdgeVerbs, exchangeCapability, guardSecrets, mintBecomeCapability, redactSecrets, redactSecretText, moduleLog, resolveCapabilitySession, revokeCapabilityAsPlatform, domainEventOf, facetEvents, readDeadLetters, readLifecycleFlow, readHistory, readInvocation, readUndrainedOutbox, walkEventCause, walkEventEffects, } from '@substrat-run/kernel';
9
9
  import { createDoTupleChecker, createLocalControlPlaneReader } from './checker.js';
10
10
  import { assertNoSpineReference, assertSpineTablesBuilt, dumpRowsInsert, isSpineTable, repointScopeGrants, spineColumnAdditions } from '@substrat-run/kernel';
@@ -312,6 +312,9 @@ const KERNEL_DDL = `
312
312
  );
313
313
  CREATE INDEX IF NOT EXISTS _substrat_attachments_entity
314
314
  ON _substrat_attachments (entity_type, entity_id);
315
+ -- #1575: attachment text and its FTS5 index, shared from @substrat-run/kernel. After
316
+ -- _substrat_attachments, whose delete trigger it adds.
317
+ ${ATTACHMENT_TEXT_DDL}
315
318
  -- #901: an entity's version is the ULID of the last event about it, so
316
319
  -- MAX(id) per (entity_type, entity_id) is the read. The id column sits last so
317
320
  -- SQLite walks to the end of the matched range instead of aggregating over it.
@@ -2089,19 +2092,7 @@ export function defineScopeDO(modules, bareOps) {
2089
2092
  const row = this.sql
2090
2093
  .exec('SELECT * FROM _substrat_attachments WHERE id = ?', attachmentId)
2091
2094
  .toArray()[0];
2092
- if (!row)
2093
- return null;
2094
- return attachmentRecord.parse({
2095
- id: row.id,
2096
- entity: { entityType: row.entity_type, entityId: row.entity_id },
2097
- filename: row.filename,
2098
- contentType: row.content_type,
2099
- size: Number(row.size),
2100
- sha256: row.sha256,
2101
- visibility: row.visibility,
2102
- createdBy: row.created_by,
2103
- createdAt: row.created_at,
2104
- });
2095
+ return row ? attachmentRecordOfRow(row) : null;
2105
2096
  }
2106
2097
  /** Record an uploaded attachment: write gate + row + `attachment.added`, one txn. */
2107
2098
  async attachmentAdd(record, principal, tenantId, scopeId,
@@ -2131,6 +2122,9 @@ export function defineScopeDO(modules, bareOps) {
2131
2122
  piiClass: 'none',
2132
2123
  payload: { attachment: parsed },
2133
2124
  });
2125
+ // #1575: queue its text extraction in the same transaction — a `pending` row
2126
+ // and a job run. Nothing is extracted here, so nothing here can fail the upload.
2127
+ enqueueAttachmentText(doSpineSql(this.sql), parsed.id, ulid(), new Date().toISOString());
2134
2128
  });
2135
2129
  }
2136
2130
  catch (err) {
@@ -2263,6 +2257,49 @@ export function defineScopeDO(modules, bareOps) {
2263
2257
  return record;
2264
2258
  });
2265
2259
  }
2260
+ // -- attachment text (#1575) ---------------------------------------------------
2261
+ /**
2262
+ * Extracted-text search as `ctx`'s subject: authorized first — the check `open` makes,
2263
+ * the target's read key on the owning entity — then matched over readable owners only
2264
+ * (`searchAttachments`). The term and the limit are judged here again, never trusted
2265
+ * from the coordinator.
2266
+ */
2267
+ searchAttachmentsAs(ctx, term, limit) {
2268
+ return searchAttachments(doSpineSql(this.sql), { targets: this.attachmentTargets, check: (permission, entity) => ctx.check(permission, entity) }, term, limit);
2269
+ }
2270
+ /**
2271
+ * #1575: `ScopeAttachments.search` for a principal or a connection. Its failure travels as
2272
+ * DATA, the capability verbs' envelope, because a throw across this RPC keeps only its
2273
+ * message — and the too-many-owners refusal's `reason` is what a UI explains it by.
2274
+ */
2275
+ async attachmentSearch(term, limit, principal, tenantId, scopeId, connectionId) {
2276
+ await this.ensureMigrations();
2277
+ try {
2278
+ const ctx = this.operationContext(principal, tenantId, scopeId, undefined, connectionId, undefined, undefined, undefined, 'attachments.search');
2279
+ return { value: await this.searchAttachmentsAs(ctx, term, limit) };
2280
+ }
2281
+ catch (err) {
2282
+ return { failure: toWireFailure(err) };
2283
+ }
2284
+ }
2285
+ /**
2286
+ * #1575: the extraction job's read of one record. No gate, deliberately — extraction
2287
+ * is the kernel's own derivation and the coordinator is its only caller; what a person
2288
+ * learns is gated at search.
2289
+ */
2290
+ async attachmentTextSource(attachmentId) {
2291
+ await this.ensureMigrations();
2292
+ return this.attachmentRow(attachmentId);
2293
+ }
2294
+ /**
2295
+ * #1575: write one extraction outcome, guarded on the attachment still existing.
2296
+ * Through the queue, so it can never land inside an invoke's or an upload's open
2297
+ * transaction and roll back with it.
2298
+ */
2299
+ async attachmentTextRecord(attachmentId, outcome) {
2300
+ await this.ensureMigrations();
2301
+ return this.queue.enqueue(async () => this.ctx.storage.transactionSync(() => recordAttachmentText(doSpineSql(this.sql), attachmentId, outcome, new Date().toISOString())));
2302
+ }
2266
2303
  // -- attachments through a capability (#1686) -------------------------------
2267
2304
  // The coordinator hashed the session token; only the hash arrives. Each verb resolves it
2268
2305
  // INSIDE the queue, as `invoke` does, so nothing can revoke between the resolution and
@@ -2332,6 +2369,11 @@ export function defineScopeDO(modules, bareOps) {
2332
2369
  return record;
2333
2370
  });
2334
2371
  }
2372
+ /** #1575: extracted-text search as the capability — its keys, its subtree, its minter now. */
2373
+ async capabilityAttachmentSearch(term, limit, sessionHash, tenantId, scopeId) {
2374
+ await this.ensureMigrations();
2375
+ return this.asCapability(sessionHash, tenantId, scopeId, 'attachments.search', (ctx) => this.searchAttachmentsAs(ctx, term, limit));
2376
+ }
2335
2377
  /**
2336
2378
  * Refuse an upload or a remove through a capability, and record it. Throws for an
2337
2379
  * unknown attachment id too — the same answer either way — but only a known target has
@@ -2822,8 +2864,8 @@ export function defineScopeDO(modules, bareOps) {
2822
2864
  this.ctx.storage.transactionSync(() => {
2823
2865
  this.sql.exec(`INSERT INTO _substrat_job_runs
2824
2866
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2825
- last_error, started_at, updated_at, next_attempt_at, ended_at)
2826
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at);
2867
+ last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
2868
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at, row.subject_id ?? null);
2827
2869
  });
2828
2870
  return row;
2829
2871
  }
@@ -2835,8 +2877,8 @@ export function defineScopeDO(modules, bareOps) {
2835
2877
  async jobRunInsert(row) {
2836
2878
  this.sql.exec(`INSERT INTO _substrat_job_runs
2837
2879
  (id, module_id, job, instance, payload, status, cursor, counters, attempts,
2838
- last_error, started_at, updated_at, next_attempt_at, ended_at)
2839
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at);
2880
+ last_error, started_at, updated_at, next_attempt_at, ended_at, subject_id)
2881
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, row.id, row.module_id, row.job, row.instance, row.payload, row.status, row.cursor, row.counters, row.attempts, row.last_error, row.started_at, row.updated_at, row.next_attempt_at, row.ended_at, row.subject_id ?? null);
2840
2882
  }
2841
2883
  /**
2842
2884
  * `running` runs whose backoff has elapsed, oldest first, after `afterId`.
@@ -3284,8 +3326,10 @@ export function defineScopeDO(modules, bareOps) {
3284
3326
  .toArray().map((r) => r.name));
3285
3327
  if (!known.has(table))
3286
3328
  throw new Error(`unknown table '${table}'`);
3287
- const l = Math.min(Math.max(1, limit), SCOPE_TABLE_PAGE_MAX);
3288
- const o = Math.max(0, offset);
3329
+ // The ceiling clamps; a bound SQLite would misread (NaN, non-finite, fractional,
3330
+ // negative) is refused instead of reaching LIMIT / OFFSET (#1632).
3331
+ const l = Math.min(assertRowLimit('limit', limit), SCOPE_TABLE_PAGE_MAX);
3332
+ const o = assertRowOffset('offset', offset);
3289
3333
  const cursor = this.sql.exec(`SELECT * FROM "${table}" LIMIT ? OFFSET ?`, l, o);
3290
3334
  const columns = cursor.columnNames;
3291
3335
  const rows = Array.from(cursor.raw(), (row) => row.map(cellToJson));
@@ -3416,6 +3460,8 @@ export function defineScopeDO(modules, bareOps) {
3416
3460
  'ALTER TABLE _substrat_migrations ADD COLUMN duration_ms INTEGER',
3417
3461
  'ALTER TABLE _substrat_migrations ADD COLUMN rows_changed INTEGER',
3418
3462
  'ALTER TABLE _substrat_tuples ADD COLUMN revoked_at TEXT',
3463
+ // #1632: legacy runs retain an unknown subject; no content-based backfill.
3464
+ 'ALTER TABLE _substrat_job_runs ADD COLUMN subject_id TEXT',
3419
3465
  // Executor retry state (#100). The defaults read as "terminal", which is
3420
3466
  // right for every row already there: each is a completed delivery or a
3421
3467
  // consumer dead-letter.
@@ -3599,6 +3645,10 @@ export function defineScopeDO(modules, bareOps) {
3599
3645
  // not this one. Dropped, so a restore never carries a receipt over; the repair
3600
3646
  // projection that follows writes this scope's own.
3601
3647
  this.sql.exec(`DELETE FROM _substrat_meta WHERE key = 'provisioned_for'`);
3648
+ // #1575: attachment text is not in a dump, so the load left it as it was. Drop the
3649
+ // text of attachments the dump did not bring back, and queue extraction for those
3650
+ // it brought back without text — the bytes decide what that run finds.
3651
+ reconcileAttachmentText(doSpineSql(this.sql), ulid, new Date().toISOString());
3602
3652
  // Re-point the restored grants at THIS scope (after the spine exists, so a dump
3603
3653
  // that carried no tuples table still finds one here).
3604
3654
  if (destScopeId) {
@@ -3745,12 +3795,16 @@ export function defineScopeDO(modules, bareOps) {
3745
3795
  // A failed delivery's error text about one of those events (#1632) — keyed by event
3746
3796
  // id, so the outbox predicate names it. Not a copy of the event; not counted.
3747
3797
  this.sql.exec(DELIVERY_ERROR_REDACTION_SQL, REDACTED_DELIVERY_NOTE, REDACTED_DELIVERY_NOTE, subjectId);
3798
+ const sql = doRedactionSql(this.sql);
3748
3799
  return {
3749
3800
  events: doomed.length,
3750
3801
  intents: this.redactSubjectIntents(subjectId, at),
3751
3802
  // The job-run tables (#1632) — the kernel's walk, so the pure host runs the
3752
3803
  // identical SQL.
3753
- jobRuns: redactSubjectJobRuns((sql, params) => this.sql.exec(sql, ...params).toArray(), subjectId, at),
3804
+ jobRuns: redactSubjectJobRuns(sql, subjectId, at),
3805
+ // The free-text copies (#1632), and the tombstoned intents the coordinator hands to
3806
+ // the directory half. Last, so those ids include every intent tombstoned above.
3807
+ ...redactSubjectScopeText(sql, subjectId, at),
3754
3808
  };
3755
3809
  }
3756
3810
  /**
@@ -4425,7 +4479,9 @@ export function defineScopeDO(modules, bareOps) {
4425
4479
  /** The directory's recorded-off modules (#1742), switched off in THIS unit right after the
4426
4480
  * seat, so no sweep can run the grants the seat just re-created. `scopeId` is the scope
4427
4481
  * this projection provisions (the object of `scopeTuples`), never another. */
4428
- switchOff) {
4482
+ switchOff,
4483
+ /** Declared service subjects excluded only from human lockout repair (#1896). */
4484
+ serviceSubjects) {
4429
4485
  if (await this.isReaped())
4430
4486
  return [];
4431
4487
  // A projection that arrives after this scope was reaped is dropped, not
@@ -4510,14 +4566,14 @@ export function defineScopeDO(modules, bareOps) {
4510
4566
  this.sql.exec(seat.sql, ...seat.params);
4511
4567
  }
4512
4568
  // #1659's one exception: the owner-of-record's seat comes back over a revoke when
4513
- // NOTHING else would let anyone act here — roles projected, no effective role grant.
4514
- // That is the #332 lockout this path exists to repair, and it is decided by the same
4515
- // predicate as the flip guard below, so "locked out" means one thing in this unit.
4569
+ // No HUMAN holder remains — declared service subjects cannot prevent lockout repair.
4570
+ // That is the #332 lockout this path exists to repair. The flip guard below still
4571
+ // counts service authority; only the repair excludes the declared service subjects.
4516
4572
  // With any other effective holder, the revoke stands: a hand-over that seats a
4517
4573
  // successor before unseating the owner is not undone by the next promote. A holder of
4518
4574
  // a role the vertical no longer defines is NOT one — it passes no check, so it must
4519
4575
  // not stand in for the holder this repair exists to restore.
4520
- if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId)) {
4576
+ if (roles.length > 0 && !this.hasEffectiveRoleGrant(tenantId, serviceSubjects)) {
4521
4577
  for (const st of scopeTuples ?? []) {
4522
4578
  if (!st.lockout_reseat)
4523
4579
  continue;
@@ -4566,8 +4622,8 @@ export function defineScopeDO(modules, bareOps) {
4566
4622
  * kernel's `effectiveRoleGrantQuery`, where it is tested against a real SQLite. A tuple
4567
4623
  * for a role the vertical no longer defines counts for nothing, exactly as in the local
4568
4624
  * checker, which expands a role only through its definition. */
4569
- hasEffectiveRoleGrant(tenantId) {
4570
- const q = effectiveRoleGrantQuery(tenantId, new Date().toISOString());
4625
+ hasEffectiveRoleGrant(tenantId, excludedSubjects) {
4626
+ const q = effectiveRoleGrantQuery(tenantId, new Date().toISOString(), undefined, excludedSubjects);
4571
4627
  const row = this.sql.exec(q.sql, ...q.params).toArray()[0];
4572
4628
  return row?.effective === 1;
4573
4629
  }