@substrat-run/adapter-sqlite 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import Database from 'better-sqlite3';
4
- import { adminLogEntry, createTenantInput, domainEvent, domainEventInput, eventId, identityLink, instant, moduleManifest, objectRef, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
4
+ import { accessLogEntry, adminLogEntry, createTenantInput, domainEvent, domainEventInput, eventId, identityLink, identityPool, instant, moduleManifest, createOrgInput, promotionAcknowledgement, bindHostnameInput, hostnameBinding, publishVersionInput, routeTarget, registerVerticalInput, vertical as verticalSchema, verticalChannel, verticalVersion, objectRef, org as orgSchema, orgMembership, principalId, resolvedIdentity, roleDefinition, scope as scopeSchema, tenant as tenantSchema, tenantRole, } from '@substrat-run/contracts';
5
5
  import { resolveScopeRecord, ulid, } from '@substrat-run/kernel';
6
6
  import { ScopeActor } from './actor.js';
7
7
  import { createTupleChecker } from './checker.js';
@@ -32,6 +32,10 @@ const KERNEL_DDL = `
32
32
  relation TEXT NOT NULL,
33
33
  object TEXT NOT NULL,
34
34
  expires_at TEXT,
35
+ -- K-21: revocation TOMBSTONES. The row stays and the walk skips it, because a
36
+ -- tuple that once granted access is evidence of why an access was allowed
37
+ -- (K-4) and D-32's compliance product has to produce that evidence.
38
+ revoked_at TEXT,
35
39
  PRIMARY KEY (subject, relation, object)
36
40
  );
37
41
  CREATE TABLE IF NOT EXISTS _substrat_deliveries (
@@ -42,6 +46,36 @@ const KERNEL_DDL = `
42
46
  PRIMARY KEY (event_id, consumer_module)
43
47
  );
44
48
  `;
49
+ /**
50
+ * The four migration-health columns → the contract's nullable `migrationFailure`.
51
+ * All-null is the healthy case (never attempted, or the last attempt succeeded and
52
+ * cleared them); a version present means the scope failed closed and did not serve.
53
+ *
54
+ * Returns the *unparsed* shape — `scopeSchema.parse` brands `lastAttemptAt` into an
55
+ * `Instant`, the same way every other row value in `mapScope` is branded on read.
56
+ */
57
+ function mapMigrationFailure(r) {
58
+ if (!r.migration_failed_version || !r.migration_last_attempt_at)
59
+ return null;
60
+ return {
61
+ version: r.migration_failed_version,
62
+ error: r.migration_error ?? '',
63
+ attempts: r.migration_attempts,
64
+ lastAttemptAt: r.migration_last_attempt_at,
65
+ };
66
+ }
67
+ const mapHostname = (r) => hostnameBinding.parse({
68
+ hostname: r.hostname,
69
+ tenantId: r.tenant_id,
70
+ scopeId: r.scope_id,
71
+ verticalSlug: r.vertical_slug,
72
+ surface: r.surface,
73
+ region: r.region,
74
+ status: r.status,
75
+ statusNote: r.status_note,
76
+ canonical: r.canonical === 1,
77
+ createdAt: r.created_at,
78
+ });
45
79
  export class SqliteScopeHost {
46
80
  admin;
47
81
  dir;
@@ -61,6 +95,15 @@ export class SqliteScopeHost {
61
95
  /** operation name → its owning module's entitlementKey (§4.3 gate). */
62
96
  operationEntitlement = new Map();
63
97
  roles = new Map(); // 'tenantId/roleKey'
98
+ /** Executor id → {eventType, handler} (K-22 §4.2). Host code, not module code. */
99
+ executors = new Map();
100
+ /**
101
+ * The event currently being effected by an executor, stamped onto any admin rows
102
+ * it writes. Ambient rather than threaded through every HostAdmin signature: it is
103
+ * set and cleared immediately around one `await`, and executors run sequentially,
104
+ * so there is no window where it belongs to a different event.
105
+ */
106
+ causedBy = null;
64
107
  systemPrincipal = principalId.parse(ulid());
65
108
  constructor(options) {
66
109
  this.dir = options.dir;
@@ -96,6 +139,80 @@ export class SqliteScopeHost {
96
139
  jurisdiction TEXT,
97
140
  status TEXT NOT NULL DEFAULT 'active',
98
141
  schema_version TEXT NOT NULL DEFAULT '0',
142
+ vertical_version_id TEXT,
143
+ -- Last FAILED migration attempt (§5.3). All null / 0 = healthy. Written on
144
+ -- the failure path so a scope that fails closed stops rendering as active;
145
+ -- cleared on the next success. See applyPendingMigrations.
146
+ migration_failed_version TEXT,
147
+ migration_error TEXT,
148
+ migration_attempts INTEGER NOT NULL DEFAULT 0,
149
+ migration_last_attempt_at TEXT,
150
+ created_at TEXT NOT NULL
151
+ );
152
+ -- The hostname map (K-26). A single environment-wide router resolves against
153
+ -- this before dispatching to the vertical's worker.
154
+ --
155
+ -- The surface column is why one hostname per scope was not enough: the shop
156
+ -- fronts a storefront AND a back office from one scope. The region column is
157
+ -- Regional Services, which Cloudflare configures per hostname — the reason
158
+ -- residency lives here rather than in a router deployed per jurisdiction.
159
+ CREATE TABLE IF NOT EXISTS hostnames (
160
+ hostname TEXT PRIMARY KEY,
161
+ tenant_id TEXT NOT NULL,
162
+ scope_id TEXT NOT NULL,
163
+ vertical_slug TEXT,
164
+ surface TEXT NOT NULL,
165
+ region TEXT,
166
+ status TEXT NOT NULL,
167
+ status_note TEXT,
168
+ canonical INTEGER NOT NULL DEFAULT 0,
169
+ created_at TEXT NOT NULL
170
+ );
171
+ CREATE INDEX IF NOT EXISTS hostnames_scope ON hostnames (scope_id, surface);
172
+ -- The vertical + version registry (#31). A scope binds to a VERSION, so
173
+ -- dev/staging/prod are the same vertical pinned differently, and a preview
174
+ -- deployment is a version nothing has been promoted to yet.
175
+ CREATE TABLE IF NOT EXISTS verticals (
176
+ slug TEXT PRIMARY KEY,
177
+ name TEXT NOT NULL,
178
+ source TEXT NOT NULL,
179
+ created_at TEXT NOT NULL
180
+ );
181
+ -- admission: 'pending' until the gates pass. A push is not a deploy, and
182
+ -- bind_scope_version refuses anything not admitted -- which is what makes
183
+ -- that sentence structural rather than a convention.
184
+ CREATE TABLE IF NOT EXISTS vertical_versions (
185
+ id TEXT PRIMARY KEY,
186
+ vertical_slug TEXT NOT NULL,
187
+ version TEXT NOT NULL,
188
+ manifest_digest TEXT NOT NULL,
189
+ permission_digest TEXT NOT NULL,
190
+ migration_digest TEXT NOT NULL,
191
+ deployment_ref TEXT,
192
+ admission TEXT NOT NULL,
193
+ admission_note TEXT,
194
+ created_at TEXT NOT NULL,
195
+ UNIQUE (vertical_slug, version)
196
+ );
197
+ -- Channels (#31 step 2): a named pointer per vertical. Promotion moves it,
198
+ -- and promotion is where the migration and permission diffs fire — the
199
+ -- moment a change reaches anyone, rather than the moment it was typed.
200
+ CREATE TABLE IF NOT EXISTS vertical_channels (
201
+ vertical_slug TEXT NOT NULL,
202
+ channel TEXT NOT NULL,
203
+ version_id TEXT NOT NULL,
204
+ updated_at TEXT NOT NULL,
205
+ PRIMARY KEY (vertical_slug, channel)
206
+ );
207
+ -- Organizations inside a tenant (K-22). Membership tuples point at these and
208
+ -- grantToOrg targets them. Before this the id was a free-form string with no
209
+ -- record, so a typo addressed a phantom org. The tenant_id column is also
210
+ -- kernel-design §4.3's required orgId <-> tenantId join.
211
+ CREATE TABLE IF NOT EXISTS orgs (
212
+ org_id TEXT PRIMARY KEY,
213
+ tenant_id TEXT NOT NULL,
214
+ slug TEXT NOT NULL,
215
+ name TEXT NOT NULL,
99
216
  created_at TEXT NOT NULL
100
217
  );
101
218
  CREATE TABLE IF NOT EXISTS _substrat_tenant_tuples (
@@ -104,6 +221,9 @@ export class SqliteScopeHost {
104
221
  relation TEXT NOT NULL,
105
222
  object TEXT NOT NULL,
106
223
  expires_at TEXT,
224
+ -- K-21: see the note on _substrat_tuples. Membership lives here, so this
225
+ -- is the column removeMember writes.
226
+ revoked_at TEXT,
107
227
  PRIMARY KEY (tenant_id, subject, relation, object)
108
228
  );
109
229
  CREATE TABLE IF NOT EXISTS _substrat_roles (
@@ -125,6 +245,16 @@ export class SqliteScopeHost {
125
245
  -- principal + home node. Provider-keyed so Better Auth, an OIDC issuer, or
126
246
  -- several at once coexist. Authentication input only — authorization stays
127
247
  -- in the tuples above.
248
+ -- Registered identity pools (K-23). A provider declares its topology before it
249
+ -- may link, so the directory knows whether the same externalId in two tenants is
250
+ -- one human (central) or two (tenant-bound). tenant_id is non-null exactly when
251
+ -- tenant-bound.
252
+ CREATE TABLE IF NOT EXISTS _substrat_identity_pools (
253
+ provider TEXT PRIMARY KEY,
254
+ topology TEXT NOT NULL,
255
+ tenant_id TEXT,
256
+ created_at TEXT NOT NULL
257
+ );
128
258
  CREATE TABLE IF NOT EXISTS _substrat_identities (
129
259
  provider TEXT NOT NULL,
130
260
  external_id TEXT NOT NULL,
@@ -132,20 +262,50 @@ export class SqliteScopeHost {
132
262
  tenant_id TEXT NOT NULL,
133
263
  scope_id TEXT,
134
264
  created_at TEXT NOT NULL,
135
- PRIMARY KEY (provider, external_id)
265
+ PRIMARY KEY (tenant_id, provider, external_id)
136
266
  );
137
267
  -- Append-only control-plane audit trail (control-plane.md §4.4). Lives in
138
268
  -- the directory, not a scope DB: it records cross-tenant staff actions and
139
269
  -- is stamped host-side. Never UPDATEd, never DELETEd.
270
+ -- Staff READS (K-24). Separate from the admin log because they are two
271
+ -- things: a mutation is permanent evidence, a read is operational history.
272
+ -- One table would force one retention policy on both, the stricter would
273
+ -- win, and read noise would bury the mutation rows an auditor came for.
274
+ --
275
+ -- drained_at marks a row shipped to Tier 2. ONLY drained rows may be
276
+ -- pruned: expiring on age alone would destroy evidence while calling
277
+ -- itself retention. Until that sink exists nothing drains, so nothing
278
+ -- prunes and the window is unbounded — a stated limitation, not a policy.
279
+ CREATE TABLE IF NOT EXISTS _substrat_access_log (
280
+ id TEXT PRIMARY KEY,
281
+ actor TEXT NOT NULL,
282
+ method TEXT NOT NULL,
283
+ tenant_id TEXT,
284
+ scope_id TEXT,
285
+ params TEXT,
286
+ -- What separates navigation from an incident: "called listScopes" against
287
+ -- "enumerated 4,000 tenants". A log that cannot tell them apart is a log
288
+ -- nobody reads.
289
+ result_count INTEGER NOT NULL,
290
+ drained_at TEXT,
291
+ at TEXT NOT NULL
292
+ );
293
+ CREATE INDEX IF NOT EXISTS _substrat_access_log_actor ON _substrat_access_log (actor, id);
294
+ CREATE INDEX IF NOT EXISTS _substrat_access_log_tenant ON _substrat_access_log (tenant_id, id);
140
295
  CREATE TABLE IF NOT EXISTS _substrat_admin_log (
141
296
  id TEXT PRIMARY KEY,
142
297
  actor TEXT NOT NULL,
143
298
  action TEXT NOT NULL,
144
- tenant_id TEXT NOT NULL,
299
+ -- Nullable: a platform-level action targets no tenant (K-23).
300
+ tenant_id TEXT,
145
301
  scope_id TEXT,
146
302
  vertical TEXT,
147
303
  before TEXT,
148
304
  after TEXT,
305
+ -- The event that caused this action, when one did (K-22 §4.2). This is
306
+ -- what joins the connector seam's two halves: the module's emit and the
307
+ -- executor's effect. Null for a staff member acting directly.
308
+ caused_by TEXT,
149
309
  at TEXT NOT NULL
150
310
  );
151
311
  -- Read-path indexes for the console (control-plane.md §4.5). The admin log
@@ -169,6 +329,11 @@ export class SqliteScopeHost {
169
329
  });
170
330
  this.admin = this.buildAdmin();
171
331
  }
332
+ registerExecutor(id, eventType, handler) {
333
+ if (this.executors.has(id))
334
+ throw new Error(`executor '${id}' is already registered`);
335
+ this.executors.set(id, { eventType, handler });
336
+ }
172
337
  registerModule(registration) {
173
338
  const manifest = moduleManifest.parse(registration.manifest);
174
339
  if (this.modules.has(manifest.id)) {
@@ -353,8 +518,12 @@ export class SqliteScopeHost {
353
518
  rt.db.exec('ROLLBACK');
354
519
  throw err;
355
520
  }
356
- // Post-commit, still inside the actor task: drain outbox → consumers.
521
+ // Post-commit, still inside the actor task: drain outbox → consumers,
522
+ // then → executors. Prompt dispatch (K-22 §4.2): the common case
523
+ // completes inside this request, with the outbox as the retry backstop
524
+ // if it does not.
357
525
  await this.dispatch(rt);
526
+ await this.dispatchExecutors(rt);
358
527
  return structuredClone(result);
359
528
  });
360
529
  },
@@ -393,6 +562,47 @@ export class SqliteScopeHost {
393
562
  // Event dispatch (testrun spec §9.2.3): at-least-once, kernel-journaled,
394
563
  // consumers run as system-actor operations in their own transactions.
395
564
  // -------------------------------------------------------------------------
565
+ /**
566
+ * Run executors over this scope's outbox (K-22 §4.2) — the connector half of the
567
+ * seam. Same at-least-once journal as consumers, keyed on a distinct delivery id
568
+ * so an executor and a module consumer on the same event do not shadow each other.
569
+ *
570
+ * Runs OUTSIDE the scope's transaction, and deliberately so: the executor acts on
571
+ * the directory, which is not part of the scope's serialization domain. The
572
+ * atomicity that matters already happened — the event only exists because the
573
+ * emitting transaction committed.
574
+ *
575
+ * A throwing executor leaves the delivery unjournaled, so it is retried on the
576
+ * next dispatch. That is the backstop; it is not a substitute for idempotent
577
+ * handlers, which at-least-once still requires.
578
+ */
579
+ async dispatchExecutors(rt) {
580
+ for (const [id, executor] of this.executors) {
581
+ const deliveryId = `executor:${id}`;
582
+ const rows = rt.db
583
+ .prepare(`SELECT * FROM _substrat_outbox o
584
+ WHERE o.type = ?
585
+ AND NOT EXISTS (
586
+ SELECT 1 FROM _substrat_deliveries d
587
+ WHERE d.event_id = o.id AND d.consumer_module = ?
588
+ )
589
+ ORDER BY o.id`)
590
+ .all(executor.eventType, deliveryId);
591
+ for (const row of rows) {
592
+ const event = this.parseOutboxRow(row);
593
+ this.causedBy = event.id;
594
+ try {
595
+ await executor.handler(this.admin, event);
596
+ }
597
+ finally {
598
+ this.causedBy = null;
599
+ }
600
+ rt.db
601
+ .prepare('INSERT OR IGNORE INTO _substrat_deliveries (event_id, consumer_module, delivered_at) VALUES (?, ?, ?)')
602
+ .run(row.id, deliveryId, new Date().toISOString());
603
+ }
604
+ }
605
+ }
396
606
  async dispatch(rt) {
397
607
  for (let round = 0; round < 50; round++) {
398
608
  let deliveredAny = false;
@@ -463,12 +673,27 @@ export class SqliteScopeHost {
463
673
  * remembering a call per method. `before` is captured only where cheaply
464
674
  * readable; idempotent upserts with no cheap prior state pass `before: null`.
465
675
  */
676
+ /**
677
+ * Record a staff read (K-24). Called by every read on `HostAdmin`, which is why
678
+ * they all take an actor: a read the log cannot attribute is unrepresentable.
679
+ *
680
+ * `params` is a bounded summary, not the raw filter — enough to know what was
681
+ * asked, capped so one query cannot write an unbounded row.
682
+ */
683
+ recordAccess(actor, method, target, params, resultCount) {
684
+ const summary = params == null ? null : JSON.stringify(params).slice(0, 500);
685
+ this.directory
686
+ .prepare(`INSERT INTO _substrat_access_log
687
+ (id, actor, method, tenant_id, scope_id, params, result_count, drained_at, at)
688
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
689
+ .run(ulid(), actor, method, target.tenantId ?? null, target.scopeId ?? null, summary, resultCount, new Date().toISOString());
690
+ }
466
691
  recordAdmin(actor, action, target, before, after) {
467
692
  this.directory
468
693
  .prepare(`INSERT INTO _substrat_admin_log
469
- (id, actor, action, tenant_id, scope_id, vertical, before, after, at)
470
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
471
- .run(ulid(), actor, action, target.tenantId, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after), new Date().toISOString());
694
+ (id, actor, action, tenant_id, scope_id, vertical, before, after, caused_by, at)
695
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
696
+ .run(ulid(), actor, action, target.tenantId ?? null, target.scopeId ?? null, target.vertical ?? null, before == null ? null : JSON.stringify(before), after == null ? null : JSON.stringify(after), this.causedBy, new Date().toISOString());
472
697
  }
473
698
  tenantHoldsEntitlement(tenantId, key) {
474
699
  return (this.directory
@@ -487,6 +712,88 @@ export class SqliteScopeHost {
487
712
  const r = this.directory.prepare('SELECT * FROM tenants WHERE tenant_id = ?').get(id);
488
713
  return r ? mapTenant(r) : undefined;
489
714
  };
715
+ const readPool = (provider) => {
716
+ const r = this.directory
717
+ .prepare('SELECT provider, topology, tenant_id FROM _substrat_identity_pools WHERE provider = ?')
718
+ .get(provider);
719
+ return r
720
+ ? identityPool.parse({ provider: r.provider, topology: r.topology, tenantId: r.tenant_id })
721
+ : undefined;
722
+ };
723
+ /**
724
+ * A pool must be registered before it may link, and a tenant-bound pool may only
725
+ * link into its own tenant. Resolution needs no equivalent check — K-22's
726
+ * (tenantId, provider, externalId) key already scopes reads — so this is the one
727
+ * place the topology is established rather than merely assumed.
728
+ */
729
+ const requirePoolServes = (provider, tenant) => {
730
+ const pool = readPool(provider);
731
+ if (!pool) {
732
+ throw new Error(`identity pool '${provider}' is not registered — a pool must declare its ` +
733
+ `topology before it may link (central vs tenant-bound decides whether the ` +
734
+ `same externalId in two tenants is one person or two)`);
735
+ }
736
+ if (pool.topology === 'tenant-bound' && pool.tenantId !== tenant) {
737
+ throw new Error(`identity pool '${provider}' is bound to tenant ${pool.tenantId} and cannot link into ${tenant}`);
738
+ }
739
+ };
740
+ const mapVertical = (r) => verticalSchema.parse({
741
+ slug: r.slug,
742
+ name: r.name,
743
+ source: r.source,
744
+ createdAt: r.created_at,
745
+ });
746
+ const readVertical = (slugValue) => {
747
+ const r = this.directory
748
+ .prepare('SELECT * FROM verticals WHERE slug = ?')
749
+ .get(slugValue);
750
+ return r ? mapVertical(r) : undefined;
751
+ };
752
+ const mapVersion = (r) => verticalVersion.parse({
753
+ id: r.id,
754
+ verticalSlug: r.vertical_slug,
755
+ version: r.version,
756
+ manifestDigest: r.manifest_digest,
757
+ permissionDigest: r.permission_digest,
758
+ migrationDigest: r.migration_digest,
759
+ deploymentRef: r.deployment_ref,
760
+ admission: r.admission,
761
+ admissionNote: r.admission_note,
762
+ createdAt: r.created_at,
763
+ });
764
+ const readVersion = (id) => {
765
+ const r = this.directory
766
+ .prepare('SELECT * FROM vertical_versions WHERE id = ?')
767
+ .get(id);
768
+ return r ? mapVersion(r) : undefined;
769
+ };
770
+ const mapOrg = (r) => orgSchema.parse({
771
+ id: r.org_id,
772
+ tenantId: r.tenant_id,
773
+ slug: r.slug,
774
+ name: r.name,
775
+ createdAt: r.created_at,
776
+ });
777
+ // Scoped by tenant, not just by id: an org id from another tenant must read as
778
+ // absent here, or `grantToOrg` would reach across the boundary the record exists
779
+ // to make explicit.
780
+ const readOrg = (tenant, id) => {
781
+ const r = this.directory
782
+ .prepare('SELECT * FROM orgs WHERE tenant_id = ? AND org_id = ?')
783
+ .get(tenant, id);
784
+ return r ? mapOrg(r) : undefined;
785
+ };
786
+ /**
787
+ * Fail closed on an org that does not exist in this tenant. This is what the
788
+ * record buys: before it, membership and grants accepted any string, so a typo
789
+ * produced a tuple pointing at a phantom that silently granted nothing and
790
+ * appeared in no listing.
791
+ */
792
+ const requireOrg = (tenant, id) => {
793
+ if (!readOrg(tenant, id)) {
794
+ throw new Error(`unknown org ${id} in tenant ${tenant}`);
795
+ }
796
+ };
490
797
  // The directory row → the `scope` contract. Parsed, not cast: the columns are
491
798
  // nullable in SQLite (ALTER TABLE cannot add NOT NULL to a populated table)
492
799
  // while the contract requires them, so this parse is where that gap is held
@@ -504,6 +811,8 @@ export class SqliteScopeHost {
504
811
  jurisdiction: r.jurisdiction,
505
812
  vertical: r.vertical,
506
813
  schemaVersion: r.schema_version,
814
+ verticalVersionId: r.vertical_version_id,
815
+ migrationFailure: mapMigrationFailure(r),
507
816
  createdAt: r.created_at,
508
817
  });
509
818
  // Scope lifecycle transition (control-plane.md §4.2): validate ownership,
@@ -564,7 +873,7 @@ export class SqliteScopeHost {
564
873
  this.roles.set(`${tenantId}/${parsed.key}`, parsed);
565
874
  this.recordAdmin(actor, 'defineRole', { tenantId }, before, parsed);
566
875
  },
567
- listRoles: async (filter) => {
876
+ listRoles: async (actor, filter) => {
568
877
  const where = [];
569
878
  const params = [];
570
879
  if (filter?.tenantId) {
@@ -582,6 +891,7 @@ export class SqliteScopeHost {
582
891
  // Parsed, not cast: `permissions` is a JSON blob in a TEXT column, so the
583
892
  // contract is the only thing standing between a corrupted row and the
584
893
  // console rendering a role with permissions nobody declared.
894
+ this.recordAccess(actor, 'listRoles', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
585
895
  return rows.map((r) => tenantRole.parse({
586
896
  tenantId: r.tenant_id,
587
897
  key: r.role_key,
@@ -604,13 +914,316 @@ export class SqliteScopeHost {
604
914
  this.recordAdmin(actor, 'grant', { tenantId: grant.node.tenantId, scopeId: grant.node.scopeId }, null, grant);
605
915
  },
606
916
  grantToOrg: async (actor, orgId, permission, node, entity) => {
917
+ // The org must exist in the node's tenant. A grant to a phantom org is
918
+ // worse than an error: it looks applied, resolves for nobody, and shows up
919
+ // in the permission diff as though access were conferred.
920
+ requireOrg(node.tenantId, orgId);
607
921
  writeGrant(`org:${orgId}`, permission, node, entity);
608
922
  this.recordAdmin(actor, 'grantToOrg', { tenantId: node.tenantId, scopeId: node.scopeId }, null, { orgId, permission, node, entity });
609
923
  },
924
+ // -- vertical + version registry (#31) ---------------------------------
925
+ // -- the hostname map (K-26) -------------------------------------------
926
+ bindHostname: async (actor, input) => {
927
+ const parsed = bindHostnameInput.parse(input);
928
+ const scope = this.directory
929
+ .prepare('SELECT tenant_id, vertical FROM scopes WHERE scope_id = ?')
930
+ .get(parsed.scopeId);
931
+ if (!scope || scope.tenant_id !== parsed.tenantId) {
932
+ throw new Error(`unknown scope ${parsed.scopeId} in tenant ${parsed.tenantId}`);
933
+ }
934
+ const existing = this.directory
935
+ .prepare('SELECT scope_id FROM hostnames WHERE hostname = ?')
936
+ .get(parsed.hostname);
937
+ if (existing && existing.scope_id !== parsed.scopeId) {
938
+ // A hostname is globally unique and routes to exactly one place. Silently
939
+ // rebinding it would move another tenant's traffic.
940
+ throw new Error(`hostname '${parsed.hostname}' is already bound to another scope`);
941
+ }
942
+ // Exactly one canonical per (scope, surface): "which one do certs and
943
+ // redirects use" has to have one answer, so a new canonical demotes the old.
944
+ if (parsed.canonical) {
945
+ this.directory
946
+ .prepare('UPDATE hostnames SET canonical = 0 WHERE scope_id = ? AND surface = ?')
947
+ .run(parsed.scopeId, parsed.surface);
948
+ }
949
+ this.directory
950
+ .prepare(`INSERT OR REPLACE INTO hostnames
951
+ (hostname, tenant_id, scope_id, vertical_slug, surface, region,
952
+ status, status_note, canonical, created_at)
953
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', NULL, ?, ?)`)
954
+ .run(parsed.hostname, parsed.tenantId, parsed.scopeId, scope.vertical, parsed.surface, parsed.region, parsed.canonical ? 1 : 0, new Date().toISOString());
955
+ this.recordAdmin(actor, 'bindHostname', { tenantId: parsed.tenantId, scopeId: parsed.scopeId, vertical: scope.vertical }, null, parsed);
956
+ },
957
+ setHostnameStatus: async (actor, raw, status, note) => {
958
+ const hostname = raw.toLowerCase(); // DNS is case-insensitive; the map is normalized
959
+ const row = this.directory
960
+ .prepare('SELECT tenant_id, scope_id, status FROM hostnames WHERE hostname = ?')
961
+ .get(hostname);
962
+ if (!row)
963
+ throw new Error(`unknown hostname '${hostname}'`);
964
+ if (row.status === status)
965
+ return; // idempotent, and a no-op is not audited
966
+ this.directory
967
+ .prepare('UPDATE hostnames SET status = ?, status_note = ? WHERE hostname = ?')
968
+ .run(status, note ?? null, hostname);
969
+ this.recordAdmin(actor, 'setHostnameStatus', { tenantId: row.tenant_id, scopeId: row.scope_id }, { status: row.status }, { status, note: note ?? null });
970
+ },
971
+ listHostnames: async (actor, filter) => {
972
+ const where = [];
973
+ const params = [];
974
+ if (filter?.tenantId) {
975
+ where.push('tenant_id = ?');
976
+ params.push(filter.tenantId);
977
+ }
978
+ if (filter?.scopeId) {
979
+ where.push('scope_id = ?');
980
+ params.push(filter.scopeId);
981
+ }
982
+ let sql = 'SELECT * FROM hostnames';
983
+ if (where.length)
984
+ sql += ` WHERE ${where.join(' AND ')}`;
985
+ sql += ' ORDER BY hostname';
986
+ const rows = this.directory.prepare(sql).all(...params);
987
+ this.recordAccess(actor, 'listHostnames', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
988
+ return rows.map(mapHostname);
989
+ },
990
+ resolveHostname: async (raw) => {
991
+ // The router's per-request read. No actor, not logged — same carve-out as
992
+ // resolveIdentity (K-24): this is a machine path, not a staff read.
993
+ const hostname = raw.toLowerCase();
994
+ const r = this.directory
995
+ .prepare(`SELECT tenant_id, scope_id, vertical_slug, surface, region
996
+ FROM hostnames WHERE hostname = ? AND status = 'active'`)
997
+ .get(hostname);
998
+ if (!r)
999
+ return undefined;
1000
+ return routeTarget.parse({
1001
+ tenantId: r.tenant_id,
1002
+ scopeId: r.scope_id,
1003
+ verticalSlug: r.vertical_slug,
1004
+ surface: r.surface,
1005
+ region: r.region,
1006
+ });
1007
+ },
1008
+ registerVertical: async (actor, input) => {
1009
+ const parsed = registerVerticalInput.parse(input);
1010
+ const existing = readVertical(parsed.slug);
1011
+ if (existing) {
1012
+ // Idempotent on an identical registration. A conflicting one throws:
1013
+ // changing a vertical's source silently rebinds what every scope on it
1014
+ // is understood to be running.
1015
+ if (existing.source === parsed.source && existing.name === parsed.name)
1016
+ return;
1017
+ throw new Error(`vertical '${parsed.slug}' is already registered as ${existing.source}`);
1018
+ }
1019
+ this.directory
1020
+ .prepare('INSERT INTO verticals (slug, name, source, created_at) VALUES (?, ?, ?, ?)')
1021
+ .run(parsed.slug, parsed.name, parsed.source, new Date().toISOString());
1022
+ this.recordAdmin(actor, 'registerVertical', { tenantId: null }, null, parsed);
1023
+ },
1024
+ listVerticals: async (actor) => {
1025
+ const rows = this.directory
1026
+ .prepare('SELECT * FROM verticals ORDER BY slug')
1027
+ .all();
1028
+ this.recordAccess(actor, 'listVerticals', {}, null, rows.length);
1029
+ return rows.map(mapVertical);
1030
+ },
1031
+ publishVersion: async (actor, input) => {
1032
+ const parsed = publishVersionInput.parse(input);
1033
+ if (!readVertical(parsed.verticalSlug)) {
1034
+ throw new Error(`unknown vertical '${parsed.verticalSlug}'`);
1035
+ }
1036
+ // Lands PENDING. A push is not a deploy — the gates decide, and binding a
1037
+ // scope is a separate, reviewable step.
1038
+ this.directory
1039
+ .prepare(`INSERT INTO vertical_versions
1040
+ (id, vertical_slug, version, manifest_digest, permission_digest,
1041
+ migration_digest, deployment_ref, admission, admission_note, created_at)
1042
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', NULL, ?)`)
1043
+ .run(parsed.id, parsed.verticalSlug, parsed.version, parsed.manifestDigest, parsed.permissionDigest, parsed.migrationDigest, parsed.deploymentRef, new Date().toISOString());
1044
+ this.recordAdmin(actor, 'publishVersion', { tenantId: null }, null, parsed);
1045
+ },
1046
+ listVersions: async (actor, verticalSlug) => {
1047
+ const rows = this.directory
1048
+ .prepare('SELECT * FROM vertical_versions WHERE vertical_slug = ? ORDER BY id')
1049
+ .all(verticalSlug);
1050
+ this.recordAccess(actor, 'listVersions', {}, { verticalSlug }, rows.length);
1051
+ return rows.map(mapVersion);
1052
+ },
1053
+ admitVersion: async (actor, versionId) => {
1054
+ const v = readVersion(versionId);
1055
+ if (!v)
1056
+ throw new Error(`unknown version ${versionId}`);
1057
+ if (v.admission === 'admitted')
1058
+ return; // idempotent
1059
+ if (v.admission === 'rejected') {
1060
+ throw new Error(`version ${versionId} was rejected — publish a new one`);
1061
+ }
1062
+ this.directory
1063
+ .prepare("UPDATE vertical_versions SET admission = 'admitted' WHERE id = ?")
1064
+ .run(versionId);
1065
+ this.recordAdmin(actor, 'admitVersion', { tenantId: null }, { admission: v.admission }, {
1066
+ admission: 'admitted',
1067
+ });
1068
+ },
1069
+ rejectVersion: async (actor, versionId, note) => {
1070
+ const v = readVersion(versionId);
1071
+ if (!v)
1072
+ throw new Error(`unknown version ${versionId}`);
1073
+ if (v.admission === 'admitted') {
1074
+ throw new Error(`version ${versionId} is already admitted — it may be bound`);
1075
+ }
1076
+ if (v.admission === 'rejected')
1077
+ return; // idempotent
1078
+ this.directory
1079
+ .prepare("UPDATE vertical_versions SET admission = 'rejected', admission_note = ? WHERE id = ?")
1080
+ .run(note, versionId);
1081
+ this.recordAdmin(actor, 'rejectVersion', { tenantId: null }, { admission: v.admission }, {
1082
+ admission: 'rejected',
1083
+ note,
1084
+ });
1085
+ },
1086
+ promoteVersion: async (actor, verticalSlug, channel, versionId, acknowledge) => {
1087
+ const incoming = readVersion(versionId);
1088
+ if (!incoming)
1089
+ throw new Error(`unknown version ${versionId}`);
1090
+ if (incoming.verticalSlug !== verticalSlug) {
1091
+ throw new Error(`version ${versionId} belongs to '${incoming.verticalSlug}'`);
1092
+ }
1093
+ if (incoming.admission !== 'admitted') {
1094
+ throw new Error(`version ${versionId} is ${incoming.admission}, not admitted — it cannot be promoted`);
1095
+ }
1096
+ const current = this.directory
1097
+ .prepare('SELECT version_id FROM vertical_channels WHERE vertical_slug = ? AND channel = ?')
1098
+ .get(verticalSlug, channel);
1099
+ const outgoing = current ? readVersion(current.version_id) : undefined;
1100
+ const ack = promotionAcknowledgement.parse(acknowledge ?? {});
1101
+ // §4's two checkpoints, fired where the blast radius is. A FIRST promotion
1102
+ // has nothing to diff against, so there is nothing to acknowledge — the
1103
+ // gate is about change, not about existence.
1104
+ if (outgoing) {
1105
+ if (outgoing.permissionDigest !== incoming.permissionDigest && !ack.permissionChange) {
1106
+ throw new Error(`promotion changes the permission surface (${outgoing.permissionDigest} → ` +
1107
+ `${incoming.permissionDigest}) — acknowledge it explicitly to promote`);
1108
+ }
1109
+ if (outgoing.migrationDigest !== incoming.migrationDigest && !ack.migrationChange) {
1110
+ throw new Error(`promotion changes migrations (${outgoing.migrationDigest} → ` +
1111
+ `${incoming.migrationDigest}) — acknowledge it explicitly to promote`);
1112
+ }
1113
+ }
1114
+ this.directory
1115
+ .prepare(`INSERT INTO vertical_channels (vertical_slug, channel, version_id, updated_at)
1116
+ VALUES (?, ?, ?, ?)
1117
+ ON CONFLICT (vertical_slug, channel) DO UPDATE SET version_id = ?, updated_at = ?`)
1118
+ .run(verticalSlug, channel, versionId, new Date().toISOString(), versionId, new Date().toISOString());
1119
+ // The acknowledgement is recorded, not just enforced: that is what turns
1120
+ // "someone reviewed the permission change" into evidence.
1121
+ this.recordAdmin(actor, 'promoteVersion', { tenantId: null, vertical: verticalSlug }, outgoing ? { versionId: outgoing.id, version: outgoing.version } : null, { channel, versionId, version: incoming.version, acknowledged: ack });
1122
+ },
1123
+ listChannels: async (actor, verticalSlug) => {
1124
+ const rows = this.directory
1125
+ .prepare('SELECT * FROM vertical_channels WHERE vertical_slug = ? ORDER BY channel')
1126
+ .all(verticalSlug);
1127
+ this.recordAccess(actor, 'listChannels', {}, { verticalSlug }, rows.length);
1128
+ return rows.map((r) => verticalChannel.parse({
1129
+ verticalSlug: r.vertical_slug,
1130
+ channel: r.channel,
1131
+ versionId: r.version_id,
1132
+ updatedAt: r.updated_at,
1133
+ }));
1134
+ },
1135
+ bindScopeVersion: async (actor, tenantId, scopeId, versionId) => {
1136
+ const v = readVersion(versionId);
1137
+ if (!v)
1138
+ throw new Error(`unknown version ${versionId}`);
1139
+ // The refusal this registry exists for. Without it, "a push lands pending"
1140
+ // is a convention, and D-30's argument is that we cannot afford conventions
1141
+ // where lockstep upgrades are the failure mode.
1142
+ if (v.admission !== 'admitted') {
1143
+ throw new Error(`version ${versionId} is ${v.admission}, not admitted — it cannot be bound to a scope`);
1144
+ }
1145
+ const scope = this.directory
1146
+ .prepare('SELECT tenant_id FROM scopes WHERE scope_id = ?')
1147
+ .get(scopeId);
1148
+ if (!scope || scope.tenant_id !== tenantId) {
1149
+ throw new Error(`unknown scope ${scopeId} in tenant ${tenantId}`);
1150
+ }
1151
+ this.directory
1152
+ .prepare('UPDATE scopes SET vertical_version_id = ?, vertical = ? WHERE scope_id = ?')
1153
+ .run(versionId, v.verticalSlug, scopeId);
1154
+ this.recordAdmin(actor, 'bindScopeVersion', { tenantId, scopeId }, null, {
1155
+ versionId,
1156
+ vertical: v.verticalSlug,
1157
+ version: v.version,
1158
+ });
1159
+ },
1160
+ createOrg: async (actor, input) => {
1161
+ const parsed = createOrgInput.parse(input);
1162
+ if (readOrg(parsed.tenantId, parsed.id))
1163
+ return; // idempotent, unaudited
1164
+ // Checked explicitly rather than left to the UNIQUE index: OR IGNORE would
1165
+ // swallow a collision from a DIFFERENT id and report success, silently not
1166
+ // creating the org the caller asked for. Fail closed instead (as createTenant).
1167
+ const slugOwner = this.directory
1168
+ .prepare('SELECT org_id FROM orgs WHERE tenant_id = ? AND slug = ?')
1169
+ .get(parsed.tenantId, parsed.slug);
1170
+ if (slugOwner) {
1171
+ throw new Error(`org slug '${parsed.slug}' already taken by ${slugOwner.org_id} (slugs are unique per tenant)`);
1172
+ }
1173
+ this.directory
1174
+ .prepare('INSERT INTO orgs (org_id, tenant_id, slug, name, created_at) VALUES (?, ?, ?, ?, ?)')
1175
+ .run(parsed.id, parsed.tenantId, parsed.slug, parsed.name, new Date().toISOString());
1176
+ this.recordAdmin(actor, 'createOrg', { tenantId: parsed.tenantId }, null, parsed);
1177
+ },
1178
+ listOrgs: async (actor, tenantId) => {
1179
+ const rows = this.directory
1180
+ .prepare('SELECT * FROM orgs WHERE tenant_id = ? ORDER BY slug')
1181
+ .all(tenantId).map(mapOrg);
1182
+ this.recordAccess(actor, 'listOrgs', { tenantId }, null, rows.length);
1183
+ return rows;
1184
+ },
1185
+ getOrg: async (actor, tenantId, orgId) => {
1186
+ const o = readOrg(tenantId, orgId);
1187
+ this.recordAccess(actor, 'getOrg', { tenantId }, { orgId }, o ? 1 : 0);
1188
+ return o;
1189
+ },
610
1190
  addMember: async (actor, tenantId, principal, orgId) => {
1191
+ requireOrg(tenantId, orgId);
1192
+ // INSERT OR REPLACE, so re-adding a revoked member clears the tombstone —
1193
+ // they are a member again. The add/revoke history is not lost: it lives in
1194
+ // the append-only admin log, which is where "what happened" belongs. The
1195
+ // tuple carries "what is true now" plus enough to explain a live proof.
611
1196
  writeTenantTuple(tenantId, `principal:${principal}`, 'member', `org:${orgId}`);
612
1197
  this.recordAdmin(actor, 'addMember', { tenantId }, null, { principal, orgId });
613
1198
  },
1199
+ removeMember: async (actor, tenantId, principal, orgId) => {
1200
+ requireOrg(tenantId, orgId);
1201
+ // Tombstone (K-21), never DELETE. Guarded on `revoked_at IS NULL` so a
1202
+ // repeat revoke neither moves the timestamp nor writes a second audit row.
1203
+ const info = this.directory
1204
+ .prepare(`UPDATE _substrat_tenant_tuples SET revoked_at = ?
1205
+ WHERE tenant_id = ? AND subject = ? AND relation = 'member' AND object = ?
1206
+ AND revoked_at IS NULL`)
1207
+ .run(new Date().toISOString(), tenantId, `principal:${principal}`, `org:${orgId}`);
1208
+ if (info.changes === 0)
1209
+ return; // never a member, or already revoked
1210
+ this.recordAdmin(actor, 'removeMember', { tenantId }, { principal, orgId }, null);
1211
+ },
1212
+ listMembers: async (actor, tenantId, orgId, options) => {
1213
+ requireOrg(tenantId, orgId);
1214
+ const rows = this.directory
1215
+ .prepare(`SELECT subject, revoked_at FROM _substrat_tenant_tuples
1216
+ WHERE tenant_id = ? AND relation = 'member' AND object = ?
1217
+ ${options?.includeRevoked ? '' : 'AND revoked_at IS NULL'}
1218
+ ORDER BY subject`)
1219
+ .all(tenantId, `org:${orgId}`);
1220
+ this.recordAccess(actor, 'listMembers', { tenantId }, { orgId, ...options }, rows.length);
1221
+ return rows.map((r) => orgMembership.parse({
1222
+ principal: r.subject.slice('principal:'.length),
1223
+ orgId,
1224
+ revokedAt: r.revoked_at,
1225
+ }));
1226
+ },
614
1227
  createTenant: async (actor, input) => {
615
1228
  const parsed = createTenantInput.parse(input);
616
1229
  // Idempotent: re-creating an existing tenant is a no-op, and a no-op is
@@ -642,9 +1255,18 @@ export class SqliteScopeHost {
642
1255
  .run(status, tenantId);
643
1256
  this.recordAdmin(actor, 'setTenantStatus', { tenantId }, { status: before.status }, { status });
644
1257
  },
645
- listTenants: async () => this.directory.prepare('SELECT * FROM tenants ORDER BY tenant_id').all().map(mapTenant),
646
- getTenant: async (tenantId) => readTenant(tenantId),
647
- listScopes: async (filter) => {
1258
+ listTenants: async (actor) => {
1259
+ const rows = this.directory.prepare('SELECT * FROM tenants ORDER BY tenant_id').all().map(mapTenant);
1260
+ // Enumerating every tenant on the platform is the read this log exists for.
1261
+ this.recordAccess(actor, 'listTenants', {}, null, rows.length);
1262
+ return rows;
1263
+ },
1264
+ getTenant: async (actor, tenantId) => {
1265
+ const t = readTenant(tenantId);
1266
+ this.recordAccess(actor, 'getTenant', { tenantId }, null, t ? 1 : 0);
1267
+ return t;
1268
+ },
1269
+ listScopes: async (actor, filter) => {
648
1270
  const where = [];
649
1271
  const params = [];
650
1272
  if (filter?.tenantId) {
@@ -667,13 +1289,17 @@ export class SqliteScopeHost {
667
1289
  const sql = 'SELECT * FROM scopes' +
668
1290
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
669
1291
  ' ORDER BY scope_id';
670
- return this.directory.prepare(sql).all(...params).map(mapScope);
1292
+ const scopes = this.directory.prepare(sql).all(...params).map(mapScope);
1293
+ this.recordAccess(actor, 'listScopes', { tenantId: filter?.tenantId ?? null }, filter, scopes.length);
1294
+ return scopes;
671
1295
  },
672
- getScopeRecord: async (tenantId, scopeId) => {
1296
+ getScopeRecord: async (actor, tenantId, scopeId) => {
673
1297
  const r = this.directory.prepare('SELECT * FROM scopes WHERE scope_id = ?').get(scopeId);
674
1298
  // Cross-check the pair (K-3): a scope that exists under a DIFFERENT tenant
675
1299
  // reads as absent, never as itself. Same rule as getScope's stub mint.
676
- if (!r || r.tenant_id !== tenantId)
1300
+ const found = r && r.tenant_id === tenantId;
1301
+ this.recordAccess(actor, 'getScopeRecord', { tenantId, scopeId }, null, found ? 1 : 0);
1302
+ if (!found)
677
1303
  return undefined;
678
1304
  return mapScope(r);
679
1305
  },
@@ -699,35 +1325,139 @@ export class SqliteScopeHost {
699
1325
  return; // nothing held, nothing changed
700
1326
  this.recordAdmin(actor, 'revokeEntitlement', { tenantId }, { entitlementKey }, null);
701
1327
  },
702
- listEntitlements: async (tenantId) => this.directory
703
- .prepare('SELECT entitlement_key FROM _substrat_entitlements WHERE tenant_id = ? ORDER BY entitlement_key')
704
- .all(tenantId).map((r) => r.entitlement_key),
1328
+ listEntitlements: async (actor, tenantId) => {
1329
+ const keys = this.directory
1330
+ .prepare('SELECT entitlement_key FROM _substrat_entitlements WHERE tenant_id = ? ORDER BY entitlement_key')
1331
+ .all(tenantId).map((r) => r.entitlement_key);
1332
+ this.recordAccess(actor, 'listEntitlements', { tenantId }, null, keys.length);
1333
+ return keys;
1334
+ },
1335
+ registerIdentityPool: async (actor, input) => {
1336
+ const parsed = identityPool.parse(input);
1337
+ const existing = readPool(parsed.provider);
1338
+ if (existing) {
1339
+ // Idempotent on an identical registration. A CONFLICTING one throws:
1340
+ // flipping a live pool's topology silently reinterprets every row it owns
1341
+ // — the same externalId across tenants would change from one human to two.
1342
+ if (existing.topology === parsed.topology && existing.tenantId === parsed.tenantId) {
1343
+ return;
1344
+ }
1345
+ throw new Error(`identity pool '${parsed.provider}' is already registered as ${existing.topology}` +
1346
+ `${existing.tenantId ? ` for tenant ${existing.tenantId}` : ''}`);
1347
+ }
1348
+ this.directory
1349
+ .prepare('INSERT INTO _substrat_identity_pools (provider, topology, tenant_id, created_at) VALUES (?, ?, ?, ?)')
1350
+ .run(parsed.provider, parsed.topology, parsed.tenantId, new Date().toISOString());
1351
+ this.recordAdmin(actor, 'registerIdentityPool',
1352
+ // Null for a central pool: it belongs to no single tenant, which is what
1353
+ // made the admin log's tenantId nullable.
1354
+ { tenantId: parsed.tenantId }, null, parsed);
1355
+ },
1356
+ getIdentityPool: async (actor, provider) => {
1357
+ const pool = readPool(provider);
1358
+ this.recordAccess(actor, 'getIdentityPool', {}, { provider }, pool ? 1 : 0);
1359
+ return pool;
1360
+ },
1361
+ listIdentityTenants: async (actor, provider, externalId) => {
1362
+ const pool = readPool(provider);
1363
+ if (!pool)
1364
+ throw new Error(`identity pool '${provider}' is not registered`);
1365
+ if (pool.topology !== 'central') {
1366
+ throw new Error(`identity pool '${provider}' is tenant-bound — enumerating tenants is only ` +
1367
+ `meaningful on a central pool, where the same externalId is the same person`);
1368
+ }
1369
+ const tenants = this.directory
1370
+ .prepare('SELECT tenant_id FROM _substrat_identities WHERE provider = ? AND external_id = ? ORDER BY tenant_id')
1371
+ .all(provider, externalId).map((r) => r.tenant_id);
1372
+ // Which tenants a given login touches — a cross-tenant question, and one
1373
+ // worth being able to ask who asked.
1374
+ this.recordAccess(actor, 'listIdentityTenants', {}, { provider }, tenants.length);
1375
+ return tenants;
1376
+ },
705
1377
  linkIdentity: async (actor, input) => {
706
1378
  const parsed = identityLink.parse(input);
707
- const info = this.directory
708
- .prepare(`INSERT OR IGNORE INTO _substrat_identities
1379
+ requirePoolServes(parsed.provider, parsed.tenantId);
1380
+ // Read before write. `INSERT OR IGNORE` alone cannot tell "already bound to the
1381
+ // same principal" (idempotent) from "already bound to someone else" (a
1382
+ // collision), and silently ignoring the second resolves one person as another.
1383
+ const existing = this.directory
1384
+ .prepare(`SELECT principal_id FROM _substrat_identities
1385
+ WHERE tenant_id = ? AND provider = ? AND external_id = ?`)
1386
+ .get(parsed.tenantId, parsed.provider, parsed.externalId);
1387
+ if (existing) {
1388
+ if (existing.principal_id === parsed.principal)
1389
+ return; // idempotent, unaudited
1390
+ throw new Error(`identity ${parsed.provider}:${parsed.externalId} in tenant ${parsed.tenantId} ` +
1391
+ `is already bound to ${existing.principal_id}`);
1392
+ }
1393
+ this.directory
1394
+ .prepare(`INSERT INTO _substrat_identities
709
1395
  (provider, external_id, principal_id, tenant_id, scope_id, created_at)
710
1396
  VALUES (?, ?, ?, ?, ?, ?)`)
711
1397
  .run(parsed.provider, parsed.externalId, parsed.principal, parsed.tenantId, parsed.scopeId ?? null, new Date().toISOString());
712
- // Idempotent: an identity already bound is a no-op, and a no-op is not audited.
713
- if (info.changes === 0)
714
- return;
715
1398
  this.recordAdmin(actor, 'linkIdentity', { tenantId: parsed.tenantId, scopeId: parsed.scopeId }, null, { provider: parsed.provider, externalId: parsed.externalId, principal: parsed.principal });
716
1399
  },
717
- resolveIdentity: async (provider, externalId) => {
1400
+ resolveIdentity: async (tenantId, provider, externalId) => {
718
1401
  const row = this.directory
719
- .prepare(`SELECT principal_id, tenant_id, scope_id FROM _substrat_identities
720
- WHERE provider = ? AND external_id = ?`)
721
- .get(provider, externalId);
1402
+ .prepare(`SELECT principal_id, scope_id FROM _substrat_identities
1403
+ WHERE tenant_id = ? AND provider = ? AND external_id = ?`)
1404
+ .get(tenantId, provider, externalId);
722
1405
  if (!row)
723
1406
  return undefined;
724
- return resolvedIdentity.parse({
725
- principal: row.principal_id,
726
- tenantId: row.tenant_id,
727
- scopeId: row.scope_id,
728
- });
1407
+ return resolvedIdentity.parse({ principal: row.principal_id, scopeId: row.scope_id });
1408
+ },
1409
+ accessLog: async (actor, filter) => {
1410
+ const where = [];
1411
+ const params = [];
1412
+ if (filter?.actor) {
1413
+ where.push('actor = ?');
1414
+ params.push(filter.actor);
1415
+ }
1416
+ if (filter?.tenantId) {
1417
+ where.push('tenant_id = ?');
1418
+ params.push(filter.tenantId);
1419
+ }
1420
+ if (filter?.method) {
1421
+ where.push('method = ?');
1422
+ params.push(filter.method);
1423
+ }
1424
+ let sql = 'SELECT * FROM _substrat_access_log';
1425
+ if (where.length)
1426
+ sql += ` WHERE ${where.join(' AND ')}`;
1427
+ sql += ' ORDER BY id';
1428
+ if (filter?.limit !== undefined) {
1429
+ sql += ' LIMIT ?';
1430
+ params.push(filter.limit);
1431
+ }
1432
+ const rows = this.directory.prepare(sql).all(...params);
1433
+ // Reading the access log is itself a read. Recorded BEFORE the rows are
1434
+ // returned, so the row describing this call is not in its own result.
1435
+ this.recordAccess(actor, 'accessLog', { tenantId: filter?.tenantId ?? null }, filter, rows.length);
1436
+ return rows.map((r) => accessLogEntry.parse({
1437
+ id: r.id,
1438
+ actor: r.actor,
1439
+ method: r.method,
1440
+ tenantId: r.tenant_id,
1441
+ scopeId: r.scope_id,
1442
+ params: r.params,
1443
+ resultCount: r.result_count,
1444
+ drainedAt: r.drained_at,
1445
+ at: r.at,
1446
+ }));
1447
+ },
1448
+ pruneAccessLog: async (actor, limit) => {
1449
+ // ONLY drained rows. Age alone is not a licence to delete evidence.
1450
+ const info = this.directory
1451
+ .prepare(`DELETE FROM _substrat_access_log WHERE id IN (
1452
+ SELECT id FROM _substrat_access_log WHERE drained_at IS NOT NULL ORDER BY id LIMIT ?
1453
+ )`)
1454
+ .run(limit);
1455
+ if (info.changes > 0) {
1456
+ this.recordAdmin(actor, 'pruneAccessLog', { tenantId: null }, { pruned: info.changes }, null);
1457
+ }
1458
+ return info.changes;
729
1459
  },
730
- auditLog: async (filter) => {
1460
+ auditLog: async (actor, filter) => {
731
1461
  const where = [];
732
1462
  const params = [];
733
1463
  if (filter?.tenantId) {
@@ -772,6 +1502,9 @@ export class SqliteScopeHost {
772
1502
  params.push(filter.limit);
773
1503
  }
774
1504
  const rows = this.directory.prepare(sql).all(...params);
1505
+ // Reading the audit trail is itself audited. Who examined the record of
1506
+ // who did what is exactly the question an incident asks second.
1507
+ this.recordAccess(actor, 'auditLog', { tenantId: filter?.tenantId ?? null, scopeId: filter?.scopeId ?? null }, filter, rows.length);
775
1508
  return rows.map((r) => adminLogEntry.parse({
776
1509
  id: r.id,
777
1510
  actor: r.actor,
@@ -781,6 +1514,7 @@ export class SqliteScopeHost {
781
1514
  vertical: r.vertical,
782
1515
  before: r.before === null ? null : JSON.parse(r.before),
783
1516
  after: r.after === null ? null : JSON.parse(r.after),
1517
+ causedBy: r.caused_by,
784
1518
  at: r.at,
785
1519
  }));
786
1520
  },
@@ -796,7 +1530,92 @@ export class SqliteScopeHost {
796
1530
  * within tenant"). Idempotent: on a fresh directory every column already
797
1531
  * exists and every UPDATE matches nothing.
798
1532
  */
1533
+ /**
1534
+ * Additive column migration for a table that already exists in someone's data
1535
+ * directory. `PRAGMA table_info` is available in the pure adapter (the DO adapter
1536
+ * has to attempt-and-tolerate instead — see its `ensureDirectoryColumns`).
1537
+ */
1538
+ ensureColumn(db, table, column, ddl) {
1539
+ const existing = db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === column);
1540
+ if (!existing)
1541
+ db.exec(`ALTER TABLE ${table} ADD COLUMN ${ddl}`);
1542
+ }
1543
+ /**
1544
+ * Rebuild `_substrat_identities` when it still carries the pre-K-22 global key.
1545
+ * A PRIMARY KEY cannot be ALTERed, so this is create-copy-drop-rename.
1546
+ *
1547
+ * The old shape is detected from `sqlite_master.sql` rather than `PRAGMA table_info`
1548
+ * (which reports PK membership but not composition readably), and because the same
1549
+ * check works on DO SQLite, where PRAGMA is restricted — so both adapters can use
1550
+ * one detection strategy.
1551
+ *
1552
+ * Rows carry `tenant_id` already, so the copy is lossless: what changes is which
1553
+ * columns are unique, not what is stored. Two pools that both issued `123` were
1554
+ * previously ONE row (the second link silently ignored); after the rebuild the
1555
+ * surviving row keeps its tenant and the other tenant's link can be made again.
1556
+ */
1557
+ ensureIdentityKey() {
1558
+ const row = this.directory
1559
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
1560
+ .get('_substrat_identities');
1561
+ if (!row || row.sql.includes('PRIMARY KEY (tenant_id, provider, external_id)'))
1562
+ return;
1563
+ this.directory.exec(`
1564
+ CREATE TABLE _substrat_identities_new (
1565
+ provider TEXT NOT NULL,
1566
+ external_id TEXT NOT NULL,
1567
+ principal_id TEXT NOT NULL,
1568
+ tenant_id TEXT NOT NULL,
1569
+ scope_id TEXT,
1570
+ created_at TEXT NOT NULL,
1571
+ PRIMARY KEY (tenant_id, provider, external_id)
1572
+ );
1573
+ INSERT OR IGNORE INTO _substrat_identities_new
1574
+ (provider, external_id, principal_id, tenant_id, scope_id, created_at)
1575
+ SELECT provider, external_id, principal_id, tenant_id, scope_id, created_at
1576
+ FROM _substrat_identities;
1577
+ DROP TABLE _substrat_identities;
1578
+ ALTER TABLE _substrat_identities_new RENAME TO _substrat_identities;
1579
+ `);
1580
+ }
1581
+ /**
1582
+ * Drop the admin log's `tenant_id NOT NULL` (K-23). SQLite cannot relax a column
1583
+ * constraint in place, so this is the same create-copy-drop-rename the identity key
1584
+ * uses, detected the same way — from `sqlite_master.sql`, which works on DO SQLite
1585
+ * too. Rows are copied verbatim: the log stays append-only in content, this only
1586
+ * widens what a future row may say.
1587
+ */
1588
+ ensureAdminLogTenantNullable() {
1589
+ const row = this.directory
1590
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
1591
+ .get('_substrat_admin_log');
1592
+ if (!row || !/tenant_id TEXT NOT NULL/.test(row.sql))
1593
+ return;
1594
+ this.directory.exec(`
1595
+ CREATE TABLE _substrat_admin_log_new (
1596
+ id TEXT PRIMARY KEY,
1597
+ actor TEXT NOT NULL,
1598
+ action TEXT NOT NULL,
1599
+ tenant_id TEXT,
1600
+ scope_id TEXT,
1601
+ vertical TEXT,
1602
+ before TEXT,
1603
+ after TEXT,
1604
+ at TEXT NOT NULL
1605
+ );
1606
+ INSERT INTO _substrat_admin_log_new
1607
+ SELECT id, actor, action, tenant_id, scope_id, vertical, before, after, at
1608
+ FROM _substrat_admin_log;
1609
+ DROP TABLE _substrat_admin_log;
1610
+ ALTER TABLE _substrat_admin_log_new RENAME TO _substrat_admin_log;
1611
+ `);
1612
+ }
799
1613
  ensureDirectoryColumns() {
1614
+ this.ensureIdentityKey();
1615
+ this.ensureAdminLogTenantNullable();
1616
+ this.ensureColumn(this.directory, '_substrat_admin_log', 'caused_by', 'caused_by TEXT');
1617
+ // K-21's tombstone on tenant-level tuples (membership lives here).
1618
+ this.ensureColumn(this.directory, '_substrat_tenant_tuples', 'revoked_at', 'revoked_at TEXT');
800
1619
  const existing = new Set(this.directory.prepare('PRAGMA table_info(scopes)').all().map((c) => c.name));
801
1620
  for (const [column, ddl] of [
802
1621
  ['parent_scope_id', 'parent_scope_id TEXT'],
@@ -804,6 +1623,11 @@ export class SqliteScopeHost {
804
1623
  ['kind', 'kind TEXT'],
805
1624
  ['name', 'name TEXT'],
806
1625
  ['vertical', 'vertical TEXT'],
1626
+ ['vertical_version_id', 'vertical_version_id TEXT'],
1627
+ ['migration_failed_version', 'migration_failed_version TEXT'],
1628
+ ['migration_error', 'migration_error TEXT'],
1629
+ ['migration_attempts', 'migration_attempts INTEGER NOT NULL DEFAULT 0'],
1630
+ ['migration_last_attempt_at', 'migration_last_attempt_at TEXT'],
807
1631
  ]) {
808
1632
  if (!existing.has(column))
809
1633
  this.directory.exec(`ALTER TABLE scopes ADD COLUMN ${ddl}`);
@@ -819,6 +1643,7 @@ export class SqliteScopeHost {
819
1643
  // duplicates it exists to forbid (SQLite treats NULLs as distinct).
820
1644
  this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS scopes_tenant_slug ON scopes (tenant_id, slug)');
821
1645
  this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS tenants_slug ON tenants (slug)');
1646
+ this.directory.exec('CREATE UNIQUE INDEX IF NOT EXISTS orgs_tenant_slug ON orgs (tenant_id, slug)');
822
1647
  }
823
1648
  loadRoles() {
824
1649
  const rows = this.directory
@@ -896,38 +1721,71 @@ export class SqliteScopeHost {
896
1721
  // registers legitimately sits at schema_version '0'.
897
1722
  if (pending.length === 0)
898
1723
  return;
899
- await rt.actor.enqueue(() => {
900
- for (const { moduleId, migration } of pending) {
901
- const key = `${moduleId}@${migration.version}`;
902
- if (rt.appliedMigrations.has(key))
903
- continue;
904
- rt.db.exec('BEGIN IMMEDIATE');
905
- try {
906
- const already = rt.db
907
- .prepare('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?')
908
- .get(moduleId, migration.version);
909
- if (!already) {
910
- rt.db.exec(migration.sql);
911
- rt.db
912
- .prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at) VALUES (?, ?, ?)')
913
- .run(moduleId, migration.version, new Date().toISOString());
1724
+ // The failing `module@version` and its cause, captured structurally rather than
1725
+ // re-parsed out of the thrown message the directory record has to name both.
1726
+ let failure;
1727
+ try {
1728
+ await rt.actor.enqueue(() => {
1729
+ for (const { moduleId, migration } of pending) {
1730
+ const key = `${moduleId}@${migration.version}`;
1731
+ if (rt.appliedMigrations.has(key))
1732
+ continue;
1733
+ rt.db.exec('BEGIN IMMEDIATE');
1734
+ try {
1735
+ const already = rt.db
1736
+ .prepare('SELECT 1 FROM _substrat_migrations WHERE module_id = ? AND version = ?')
1737
+ .get(moduleId, migration.version);
1738
+ if (!already) {
1739
+ rt.db.exec(migration.sql);
1740
+ rt.db
1741
+ .prepare('INSERT INTO _substrat_migrations (module_id, version, applied_at) VALUES (?, ?, ?)')
1742
+ .run(moduleId, migration.version, new Date().toISOString());
1743
+ }
1744
+ rt.db.exec('COMMIT');
914
1745
  }
915
- rt.db.exec('COMMIT');
916
- }
917
- catch (err) {
918
- rt.db.exec('ROLLBACK');
919
- throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
1746
+ catch (err) {
1747
+ rt.db.exec('ROLLBACK');
1748
+ failure = { version: key, error: err.message };
1749
+ throw new Error(`migration failed for ${key} — scope fails closed: ${err.message}`);
1750
+ }
1751
+ rt.appliedMigrations.add(key);
920
1752
  }
921
- rt.appliedMigrations.add(key);
922
- }
923
- });
924
- // The scope's migration state, projected into the directory so the fleet
925
- // question ("which scopes are behind?") is answerable from the index alone
926
- // §5.4's "fleet questions never fan out". Written here, at the one place
927
- // migrations are applied, so it cannot drift from the journal it summarises.
1753
+ });
1754
+ }
1755
+ finally {
1756
+ // `finally`, not the success path: a scope that failed closed is exactly the
1757
+ // one the fleet needs to see, and projecting only on success is what let a
1758
+ // half-migrated scope keep a stale `schema_version` and render as healthy
1759
+ // (#32). The throw still propagates recording is not recovering.
1760
+ this.recordMigrationState(rt, failure);
1761
+ }
1762
+ }
1763
+ /**
1764
+ * Project a scope's migration state into the directory — §5.4's "fleet questions
1765
+ * never fan out", so the index answers "which scopes are behind" and "which
1766
+ * failed" without waking anything.
1767
+ *
1768
+ * `appliedMigrations.size` is written on both paths: after a partial failure it
1769
+ * is the count that actually landed, which is more truthful than the pre-attempt
1770
+ * value. On success the failure columns are cleared, so `attempts` counts
1771
+ * *consecutive* failures — what the sweep's backoff (#49) needs.
1772
+ */
1773
+ recordMigrationState(rt, failure) {
1774
+ const version = String(rt.appliedMigrations.size);
1775
+ if (!failure) {
1776
+ this.directory
1777
+ .prepare(`UPDATE scopes SET schema_version = ?, migration_failed_version = NULL,
1778
+ migration_error = NULL, migration_attempts = 0, migration_last_attempt_at = NULL
1779
+ WHERE scope_id = ?`)
1780
+ .run(version, rt.scopeId);
1781
+ return;
1782
+ }
928
1783
  this.directory
929
- .prepare('UPDATE scopes SET schema_version = ? WHERE scope_id = ?')
930
- .run(String(rt.appliedMigrations.size), rt.scopeId);
1784
+ .prepare(`UPDATE scopes SET schema_version = ?, migration_failed_version = ?,
1785
+ migration_error = ?, migration_attempts = migration_attempts + 1,
1786
+ migration_last_attempt_at = ?
1787
+ WHERE scope_id = ?`)
1788
+ .run(version, failure.version, failure.error, new Date().toISOString(), rt.scopeId);
931
1789
  }
932
1790
  runtime(tenantId, scopeId) {
933
1791
  const key = `${tenantId}/${scopeId}`;
@@ -937,6 +1795,9 @@ export class SqliteScopeHost {
937
1795
  const db = new Database(join(this.dir, `${tenantId}__${scopeId}.sqlite`));
938
1796
  db.pragma('journal_mode = WAL');
939
1797
  db.exec(KERNEL_DDL);
1798
+ // KERNEL_DDL is all IF NOT EXISTS, so a scope DB created before K-21 keeps the
1799
+ // old shape — ALTER the tombstone in.
1800
+ this.ensureColumn(db, '_substrat_tuples', 'revoked_at', 'revoked_at TEXT');
940
1801
  const appliedMigrations = new Set(db.prepare('SELECT module_id, version FROM _substrat_migrations').all().map((r) => `${r.module_id}@${r.version}`));
941
1802
  const created = { tenantId, scopeId, db, actor: new ScopeActor(), appliedMigrations };
942
1803
  this.scopes.set(key, created);