@substrat-run/kernel 0.142.0 → 0.144.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.
Files changed (169) hide show
  1. package/dist/attachment-extractor.d.ts +0 -9
  2. package/dist/attachment-extractor.d.ts.map +1 -1
  3. package/dist/attachment-extractor.js +2 -2
  4. package/dist/attachment-extractor.js.map +1 -1
  5. package/dist/attachment-text.d.ts +0 -5
  6. package/dist/attachment-text.d.ts.map +1 -1
  7. package/dist/attachment-text.js +0 -4
  8. package/dist/attachment-text.js.map +1 -1
  9. package/dist/attribution.d.ts +0 -7
  10. package/dist/attribution.d.ts.map +1 -1
  11. package/dist/attribution.js +0 -7
  12. package/dist/attribution.js.map +1 -1
  13. package/dist/audit-outcome.d.ts +60 -2
  14. package/dist/audit-outcome.d.ts.map +1 -1
  15. package/dist/audit-outcome.js +63 -3
  16. package/dist/audit-outcome.js.map +1 -1
  17. package/dist/capability.d.ts +20 -13
  18. package/dist/capability.d.ts.map +1 -1
  19. package/dist/capability.js +76 -16
  20. package/dist/capability.js.map +1 -1
  21. package/dist/connector-calls.d.ts +0 -5
  22. package/dist/connector-calls.d.ts.map +1 -1
  23. package/dist/connector-calls.js +1 -1
  24. package/dist/connector-calls.js.map +1 -1
  25. package/dist/denial-query.d.ts +0 -14
  26. package/dist/denial-query.d.ts.map +1 -1
  27. package/dist/denial-query.js +1 -1
  28. package/dist/denial-query.js.map +1 -1
  29. package/dist/derived-handlers.d.ts +17 -0
  30. package/dist/derived-handlers.d.ts.map +1 -0
  31. package/dist/derived-handlers.js +198 -0
  32. package/dist/derived-handlers.js.map +1 -0
  33. package/dist/entity-binding.d.ts +121 -0
  34. package/dist/entity-binding.d.ts.map +1 -0
  35. package/dist/entity-binding.js +235 -0
  36. package/dist/entity-binding.js.map +1 -0
  37. package/dist/entity-grant-shape.d.ts +127 -12
  38. package/dist/entity-grant-shape.d.ts.map +1 -1
  39. package/dist/entity-grant-shape.js +399 -137
  40. package/dist/entity-grant-shape.js.map +1 -1
  41. package/dist/entity-grant.d.ts +5 -7
  42. package/dist/entity-grant.d.ts.map +1 -1
  43. package/dist/entity-grant.js +6 -7
  44. package/dist/entity-grant.js.map +1 -1
  45. package/dist/entity-state.d.ts +0 -17
  46. package/dist/entity-state.d.ts.map +1 -1
  47. package/dist/entity-state.js +2 -2
  48. package/dist/entity-state.js.map +1 -1
  49. package/dist/entity-trash.d.ts +2 -2
  50. package/dist/entity-trash.d.ts.map +1 -1
  51. package/dist/entity-trash.js +6 -6
  52. package/dist/entity-trash.js.map +1 -1
  53. package/dist/grant-scoped-read.d.ts +0 -2
  54. package/dist/grant-scoped-read.d.ts.map +1 -1
  55. package/dist/grant-scoped-read.js +2 -2
  56. package/dist/grant-scoped-read.js.map +1 -1
  57. package/dist/index.d.ts +53 -55
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +51 -53
  60. package/dist/index.js.map +1 -1
  61. package/dist/job-run.d.ts +0 -39
  62. package/dist/job-run.d.ts.map +1 -1
  63. package/dist/job-run.js +6 -6
  64. package/dist/job-run.js.map +1 -1
  65. package/dist/list-index.d.ts +0 -45
  66. package/dist/list-index.d.ts.map +1 -1
  67. package/dist/list-index.js +2 -39
  68. package/dist/list-index.js.map +1 -1
  69. package/dist/membership-executor.d.ts +2 -1
  70. package/dist/membership-executor.d.ts.map +1 -1
  71. package/dist/membership-executor.js +1 -1
  72. package/dist/membership-executor.js.map +1 -1
  73. package/dist/migration-digest.d.ts +0 -12
  74. package/dist/migration-digest.d.ts.map +1 -1
  75. package/dist/migration-digest.js +1 -1
  76. package/dist/migration-digest.js.map +1 -1
  77. package/dist/module-erasure.d.ts +2 -1
  78. package/dist/module-erasure.d.ts.map +1 -1
  79. package/dist/module-erasure.js +12 -1
  80. package/dist/module-erasure.js.map +1 -1
  81. package/dist/module-operations.d.ts +146 -0
  82. package/dist/module-operations.d.ts.map +1 -0
  83. package/dist/module-operations.js +174 -0
  84. package/dist/module-operations.js.map +1 -0
  85. package/dist/peer.d.ts +0 -2
  86. package/dist/peer.d.ts.map +1 -1
  87. package/dist/peer.js +0 -4
  88. package/dist/peer.js.map +1 -1
  89. package/dist/platform-request-query.d.ts +6 -6
  90. package/dist/platform-request-query.d.ts.map +1 -1
  91. package/dist/platform-request-query.js +23 -7
  92. package/dist/platform-request-query.js.map +1 -1
  93. package/dist/platform-sweep.d.ts +26 -2
  94. package/dist/platform-sweep.d.ts.map +1 -1
  95. package/dist/platform-sweep.js +6 -2
  96. package/dist/platform-sweep.js.map +1 -1
  97. package/dist/refusal-query.d.ts +0 -11
  98. package/dist/refusal-query.d.ts.map +1 -1
  99. package/dist/refusal-query.js +1 -1
  100. package/dist/refusal-query.js.map +1 -1
  101. package/dist/refusals.d.ts +1 -3
  102. package/dist/refusals.d.ts.map +1 -1
  103. package/dist/refusals.js +0 -2
  104. package/dist/refusals.js.map +1 -1
  105. package/dist/row-decode.d.ts +16 -1
  106. package/dist/row-decode.d.ts.map +1 -1
  107. package/dist/row-decode.js +18 -6
  108. package/dist/row-decode.js.map +1 -1
  109. package/dist/scope-copy-ledger.d.ts +0 -2
  110. package/dist/scope-copy-ledger.d.ts.map +1 -1
  111. package/dist/scope-copy-ledger.js +1 -1
  112. package/dist/scope-copy-ledger.js.map +1 -1
  113. package/dist/scope-host.d.ts +47 -101
  114. package/dist/scope-host.d.ts.map +1 -1
  115. package/dist/scope-host.js +0 -17
  116. package/dist/scope-host.js.map +1 -1
  117. package/dist/scope-lifecycle.d.ts +0 -21
  118. package/dist/scope-lifecycle.d.ts.map +1 -1
  119. package/dist/scope-lifecycle.js +1 -1
  120. package/dist/scope-lifecycle.js.map +1 -1
  121. package/dist/scope-role-admin.d.ts +0 -6
  122. package/dist/scope-role-admin.d.ts.map +1 -1
  123. package/dist/scope-role-admin.js +1 -1
  124. package/dist/scope-role-admin.js.map +1 -1
  125. package/dist/search-index.d.ts +0 -23
  126. package/dist/search-index.d.ts.map +1 -1
  127. package/dist/search-index.js +2 -24
  128. package/dist/search-index.js.map +1 -1
  129. package/dist/spine-restore.d.ts +0 -22
  130. package/dist/spine-restore.d.ts.map +1 -1
  131. package/dist/spine-restore.js +2 -2
  132. package/dist/spine-restore.js.map +1 -1
  133. package/dist/sql-identifier.d.ts +3 -0
  134. package/dist/sql-identifier.d.ts.map +1 -1
  135. package/dist/sql-identifier.js +2 -0
  136. package/dist/sql-identifier.js.map +1 -1
  137. package/dist/sql-limits.d.ts +4 -0
  138. package/dist/sql-limits.d.ts.map +1 -1
  139. package/dist/sql-limits.js +4 -0
  140. package/dist/sql-limits.js.map +1 -1
  141. package/dist/subject-redaction.d.ts +0 -38
  142. package/dist/subject-redaction.d.ts.map +1 -1
  143. package/dist/subject-redaction.js +4 -4
  144. package/dist/subject-redaction.js.map +1 -1
  145. package/dist/system-switch-record.d.ts +0 -4
  146. package/dist/system-switch-record.d.ts.map +1 -1
  147. package/dist/system-switch-record.js +2 -2
  148. package/dist/system-switch-record.js.map +1 -1
  149. package/dist/system-switch.d.ts +5 -23
  150. package/dist/system-switch.d.ts.map +1 -1
  151. package/dist/system-switch.js +8 -1
  152. package/dist/system-switch.js.map +1 -1
  153. package/dist/table-ownership.d.ts +0 -32
  154. package/dist/table-ownership.d.ts.map +1 -1
  155. package/dist/table-ownership.js +5 -5
  156. package/dist/table-ownership.js.map +1 -1
  157. package/dist/testing.d.ts +17 -0
  158. package/dist/testing.d.ts.map +1 -0
  159. package/dist/testing.js +15 -0
  160. package/dist/testing.js.map +1 -0
  161. package/dist/vertical-events.d.ts +1 -7
  162. package/dist/vertical-events.d.ts.map +1 -1
  163. package/dist/vertical-events.js +2 -10
  164. package/dist/vertical-events.js.map +1 -1
  165. package/package.json +6 -2
  166. package/dist/connect-state.d.ts +0 -103
  167. package/dist/connect-state.d.ts.map +0 -1
  168. package/dist/connect-state.js +0 -128
  169. package/dist/connect-state.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { ENTITY_GRANTS_RETIRED, ENTITY_GRANTS_TOPPED_UP, ENTITY_SHAPE_MARKER_RELATION, domainEvent, entityGrantsRetiredPayload, entityGrantsToppedUpPayload, entityObjectRef, eventId, } from '@substrat-run/contracts';
2
2
  import { assertKernelAuthoredType, substratError } from '@substrat-run/contracts';
3
3
  import { KERNEL_ACTOR, kernelOutboxInsertSql } from './kernel-outbox.js';
4
- import { GRANTEE_KEY_RELATION, explicitTupleSql } from './entity-grant.js';
4
+ import { SHAPE_PROTECTED_KEY_RELATION, explicitTupleSql } from './entity-grant.js';
5
5
  import { liveTupleSql } from './permission-eval.js';
6
6
  import { assertSqlIdentifier } from './sql-identifier.js';
7
7
  /**
@@ -40,13 +40,25 @@ import { assertSqlIdentifier } from './sql-identifier.js';
40
40
  * the principal; a table column naming the principal), and a person is marked only on their
41
41
  * own record, and only when they hold a row there (live or tombstoned) for some key of the
42
42
  * shape — evidence they were given it. Someone `ctx.grant`ed even the whole shape on another
43
- * person's record is never marked. A shape with no `holder` gets no backfill. Each batch marks
44
- * at most what the pass's budget allows; the markers are the progress, since a marked pair is
45
- * no longer a candidate, and a record tuple (`shape:<type>`, `shape-backfilled`, `scope:<id>`)
46
- * ends it once a batch comes back short.
43
+ * person's record is never marked. A shape with no `holder` gets no backfill. It is read in
44
+ * windows like the walks (#2154): each pass reads at most its read budget of rows, in an index
45
+ * order, marks at most its write budget, and hands back the last row it read as the cursor's
46
+ * `row`, so the next pass seeks past it rather than re-reading from the start. A record tuple
47
+ * (`shape:<type>`, `shape-backfilled`, `scope:<id>`) ends it once a read reaches the end of the
48
+ * range, and never before. A table holder's read is keyed on `idColumn` and then the row's own
49
+ * identity (its `rowid`, or a `WITHOUT ROWID` table's primary key), because nothing makes
50
+ * `idColumn` unique or the principal non-NULL, and a key that ties lets a window's edge skip a row.
51
+ *
52
+ * The cut-off is the cursor. Evidence that lands behind it while the backfill runs (a direct
53
+ * grant of a shape key to the person on their own record, for `'self'` or a table holder) is not
54
+ * marked, and once the record is written nothing is: the same accepted gap as a grant landing
55
+ * after the record. There is deliberately no confirming walk, as a retirement has: it would
56
+ * double every first backfill's reads to catch a grant that a shape grant should have made. For
57
+ * `'grantee'` nothing can land at all, since the first pass records the grantee keys and the
58
+ * tuple writer refuses them to every other grant from then on.
47
59
  * - `holder: 'grantee'` (#2083), for a record that names no principal (a portal customer, a
48
60
  * contact): whoever holds a live current or retired key of the shape on such an entity was given it.
49
- * Each pass records current and retired keys (`shape-grantee-key`), and the explicit tuple
61
+ * Each reconcile records current and retired keys (`shape-grantee-key`), and the explicit tuple
50
62
  * writer makes non-shape grants refuse them on that entity type. A tuple from before the
51
63
  * first such pass cannot be told apart. A marker already written by a shape grant persists
52
64
  * through retirement, so its holder receives the new keys even if their old key was K alone.
@@ -55,8 +67,36 @@ import { assertSqlIdentifier } from './sql-identifier.js';
55
67
  * record. A sharing shape, which people reach through `ctx.grant` (todo's `list`), is skipped
56
68
  * whole, backfill included, so a sharee is never marked a holder.
57
69
  */
70
+ /**
71
+ * The index every marker walk reads (#2083): markers only, in `(object, subject)` order, so one
72
+ * shape's markers are one range (`<type>:` up to `<type>;`) and a pass's cursor is a seek into
73
+ * it. Without it a pass scanned the whole `_substrat_tuples` table, even when it had nothing to
74
+ * do. Partial, so it costs a row per marker and nothing for any other tuple. SQLite uses a
75
+ * partial index only where it can prove the index's WHERE from the query's, so the walks write the
76
+ * relation as this same literal: proved from the text alone, with no dependence on SQLite
77
+ * re-planning once a parameter is bound. In KERNEL_DDL on both adapters, which every wake re-runs,
78
+ * so an existing scope builds it on its next wake.
79
+ */
80
+ export const SHAPE_MARKER_INDEX_DDL = `CREATE INDEX IF NOT EXISTS _substrat_tuples_shape_marker ON _substrat_tuples (object, subject) WHERE relation = '${ENTITY_SHAPE_MARKER_RELATION}'`;
58
81
  /** Holders topped up per pass — one transaction each, so a large scope never holds one long. */
59
82
  export const SHAPE_TOP_UP_BATCH = 500;
83
+ /**
84
+ * Rows a pass may READ, per row of work it may write: markers on a walk, and the tuples or table
85
+ * rows the backfill reads for candidates (#2154). The budget bounds writes, but finding
86
+ * the holders who need one means reading past the ones who do not, and on a scope where nobody
87
+ * does (the common reconcile: nothing changed) that is every marker. Ten per row keeps a pass at
88
+ * 5000 index rows by default, short on any scope, while a rollout still spends most of each pass
89
+ * writing. A pass that reads its window without filling its budget hands back a cursor, and the
90
+ * next pass reads on from there.
91
+ */
92
+ export const SHAPE_MARKER_READS_PER_ROW = 10;
93
+ /**
94
+ * Confirming walks that may take keys, per shape per reconcile, before its retirement is left open
95
+ * for the next reconcile. Each one that takes keys starts another, so a steady stream of grants of
96
+ * a retired key landing ahead of the walk would otherwise keep a single reconcile walking forever.
97
+ * Left open, the retirement keeps no record, so the next reconcile simply runs it again.
98
+ */
99
+ export const SHAPE_RETIRE_CONFIRMS_MAX = 3;
60
100
  /** The largest batch a caller may ask for: past this a pass is the long transaction it exists to avoid. */
61
101
  export const SHAPE_TOP_UP_BATCH_MAX = 5000;
62
102
  /** `batch`, or `validation_failed`: a pass size is a positive integer no larger than the max. */
@@ -75,6 +115,11 @@ const recordRefs = (entityType, scopeId) => [`shape:${entityType}`, `scope:${sco
75
115
  /** The writer of the top-up events: the kernel, as no module or person acted. */
76
116
  const PRINCIPAL = 'principal:';
77
117
  const keysOf = (permissions) => [...new Set(permissions)].sort();
118
+ /**
119
+ * Every object of one entity type, as an index range: `prefix` is `<type>:`, and `;` is the byte
120
+ * after `:`. A range, not `substr(object, …) = ?`, so an index leading with `object` can seek it.
121
+ */
122
+ const typeRange = (prefix) => [prefix, `${prefix.slice(0, -1)};`];
78
123
  /**
79
124
  * The shape's grant to one person on one entity: the marker and every key, each an explicit
80
125
  * write, so a re-grant brings back what a revoke tombstoned — as `ctx.grant` does.
@@ -92,21 +137,31 @@ export function grantEntityShapeIn(db, principal, entity, permissions) {
92
137
  }
93
138
  }
94
139
  /**
95
- * One pass of the reconcile over one scope, at most `limit` rows of work: backfill marks for any
96
- * shape whose backfill is not done here, then holders whose retired keys are taken back, each
97
- * with its `entity.grants-retired` event, then holders topped up, each with its
98
- * `entity.grants-topped-up` event. `done` is false when the budget ran out, and the caller runs
99
- * another pass. Run it inside ONE transaction, so the keys and their events commit together.
100
- * Re-running a finished scope writes nothing.
140
+ * One pass of the reconcile over one scope, at most `limit` rows of work and
141
+ * `limit × SHAPE_MARKER_READS_PER_ROW` rows read (markers, and the backfill's tuples or table
142
+ * rows): backfill marks for any shape whose backfill
143
+ * is not done here, then holders whose retired keys are taken back, each with its
144
+ * `entity.grants-retired` event, then holders topped up, each with its `entity.grants-topped-up`
145
+ * event. `next` is `null` once the scope is done; otherwise the caller runs another pass with it
146
+ * as `after`. `retirementsLeftOpen` counts the shapes whose retirement this pass gave up on at
147
+ * `SHAPE_RETIRE_CONFIRMS_MAX`, for the next reconcile to run again. Run it inside ONE
148
+ * transaction, so the keys and their events commit together. Re-running a finished scope writes
149
+ * nothing.
101
150
  */
102
151
  export function topUpEntityGrantShapes(db, pass) {
103
- // Here as well as at each entry point: a pass with no budget never reports done, so a caller
104
- // looping until it does would never stop.
105
- let budget = shapeTopUpBatch(pass.limit);
106
- recordGranteeKeys(db, pass.shapes);
152
+ // Here as well as at each entry point: a pass with no budget never hands back `next: null`, so
153
+ // a caller looping until it does would never stop.
154
+ const limit = shapeTopUpBatch(pass.limit);
155
+ const room = { writes: limit, reads: limit * SHAPE_MARKER_READS_PER_ROW };
156
+ // The registry is the same for every pass of one reconcile, so its facts are recorded by the first.
157
+ if (!pass.after)
158
+ recordShapeFacts(db, pass.shapes);
107
159
  let toppedUp = 0;
108
160
  let retired = 0;
109
- for (const shape of pass.shapes) {
161
+ let retirementsLeftOpen = 0;
162
+ const from = pass.after ?? null;
163
+ for (let i = from?.shape ?? 0; i < pass.shapes.length; i++) {
164
+ const shape = pass.shapes[i];
110
165
  // A sharing shape is never reconciled — not topped up, not backfilled, nothing retired.
111
166
  if (!shape.bootstrap)
112
167
  continue;
@@ -115,80 +170,155 @@ export function topUpEntityGrantShapes(db, pass) {
115
170
  const gone = keysOf(shape.retired ?? []).filter((k) => !keys.includes(k));
116
171
  if (keys.length === 0 && gone.length === 0)
117
172
  continue;
173
+ // Where this shape starts: the cursor's step if it points here, else the beginning.
174
+ const resume = from?.shape === i ? from : null;
175
+ const at = resume?.step ?? 'backfill';
176
+ const marker = (step) => (resume?.step === step ? resume.marker : null);
177
+ const stop = (step, last, more) => ({
178
+ toppedUp,
179
+ retired,
180
+ retirementsLeftOpen,
181
+ next: { shape: i, step, marker: last, ...more },
182
+ });
118
183
  const prefix = `${shape.entityType}:`;
119
- const json = JSON.stringify(keys);
120
- // A live retired key still identifies a legacy holder so this pass can retire it.
121
- // The grantee query excludes tombstoned tuples.
122
- budget -= backfill(db, pass, shape, prefix, JSON.stringify([...keys, ...gone]), budget);
123
- if (budget === 0)
124
- return { toppedUp, retired, done: false };
125
- reopenRetirements(db, pass, shape.entityType, keys);
126
- const took = retire(db, pass, shape.entityType, prefix, gone, budget);
127
- budget -= took;
128
- retired += took;
129
- if (budget === 0)
130
- return { toppedUp, retired, done: false };
184
+ if (at === 'backfill') {
185
+ // A live retired key still identifies a legacy holder so this pass can retire it.
186
+ // The grantee query excludes tombstoned tuples.
187
+ const read = backfill(db, pass, shape, prefix, JSON.stringify([...keys, ...gone]), room, resume?.row ?? null);
188
+ if (!read.done)
189
+ return stop('backfill', null, read.at ? { row: read.at } : undefined);
190
+ }
191
+ if (at !== 'topUp') {
192
+ reopenRetirements(db, pass, shape.entityType, keys);
193
+ let start = marker('retire');
194
+ // The first walk is never a confirming one; nor is a resumed walk that does not say it is.
195
+ let confirm = resume?.step === 'retire' ? resume.confirm : undefined;
196
+ for (;;) {
197
+ const took = retire(db, pass, shape.entityType, prefix, gone, start, room, confirm?.clean === true);
198
+ retired += took.found.length;
199
+ if (confirm)
200
+ confirm = { ...confirm, clean: confirm.clean && took.found.length === 0 };
201
+ if (!took.done)
202
+ return stop('retire', took.at, confirm ? { confirm } : undefined);
203
+ if (confirm?.clean)
204
+ break; // `retire` recorded the retirement finished
205
+ // A confirming walk from the first marker, for what landed behind this one; a walk that
206
+ // ended here having taken keys counts against the cap if it was itself confirming.
207
+ const count = confirm ? confirm.count + 1 : 0;
208
+ if (count >= SHAPE_RETIRE_CONFIRMS_MAX) {
209
+ retirementsLeftOpen++;
210
+ break;
211
+ }
212
+ [start, confirm] = [null, { clean: true, count }];
213
+ }
214
+ }
131
215
  if (keys.length === 0)
132
216
  continue;
133
- const holders = db.all(`SELECT m.subject, m.object FROM _substrat_tuples m
134
- WHERE m.relation = ? AND ${liveTupleSql('m')}
135
- AND substr(m.subject, 1, ${PRINCIPAL.length}) = '${PRINCIPAL}'
136
- AND substr(m.object, 1, ?) = ?
137
- AND EXISTS (SELECT 1 FROM json_each(?) k
138
- WHERE NOT EXISTS (SELECT 1 FROM _substrat_tuples t
139
- WHERE t.subject = m.subject AND t.object = m.object
140
- AND t.relation = 'granted:' || k.value))
141
- ORDER BY m.subject, m.object
142
- LIMIT ?`, ENTITY_SHAPE_MARKER_RELATION, pass.now, prefix.length, prefix, json, budget);
143
- for (const h of holders) {
144
- const added = db.all(`SELECT k.value AS key FROM json_each(?) k
145
- WHERE NOT EXISTS (SELECT 1 FROM _substrat_tuples t
146
- WHERE t.subject = ? AND t.object = ? AND t.relation = 'granted:' || k.value)
147
- ORDER BY k.value`, json, h.subject, h.object).map((m) => m.key);
148
- for (const key of added) {
149
- db.run(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, h.subject, `granted:${key}`, h.object);
150
- }
151
- const st = kernelOutboxInsertSql(shapeEvent(pass, ENTITY_GRANTS_TOPPED_UP, entityGrantsToppedUpPayload, {
152
- principal: h.subject.slice(PRINCIPAL.length),
153
- entity: { entityType: shape.entityType, entityId: h.object.slice(prefix.length) },
154
- added,
155
- }), pass.version);
156
- db.run(st.sql, ...st.params);
217
+ const gave = topUp(db, pass, shape.entityType, prefix, keys, marker('topUp'), room);
218
+ toppedUp += gave.found.length;
219
+ if (!gave.done)
220
+ return stop('topUp', gave.at);
221
+ }
222
+ return { toppedUp, retired, retirementsLeftOpen, next: null };
223
+ }
224
+ /** A step with nothing to walk: done, having read nothing. */
225
+ const NOTHING_TO_WALK = { found: [], done: true, at: null };
226
+ /**
227
+ * One window of a step's read, fetched in sort order with each row's `hit` computed in the SELECT
228
+ * rather than filtered in the WHERE, so `LIMIT window` bounds the rows read and not only the rows
229
+ * wanted. Takes the wanted pairs, each once, until the write budget is spent, and charges every
230
+ * row FETCHED to the read budget: the whole window, not only up to where the writes ran out,
231
+ * because SQLite evaluated `hit` on every row it returned. `done` once every row fetched was taken
232
+ * and the fetch came back short of the window; `last` is the last row taken, where the next
233
+ * window starts after.
234
+ */
235
+ function takeWindow(rows, window, room) {
236
+ const found = [];
237
+ // A pair can come back on several rows of one window (a tuple per key it holds); it is one write.
238
+ const seen = new Set();
239
+ let read = 0;
240
+ while (read < rows.length && found.length < room.writes) {
241
+ const r = rows[read++];
242
+ const pair = `${r.subject}\n${r.object}`;
243
+ if (!r.hit || seen.has(pair))
244
+ continue;
245
+ seen.add(pair);
246
+ found.push(r);
247
+ }
248
+ room.reads -= rows.length;
249
+ room.writes -= found.length;
250
+ return { found, done: read === rows.length && rows.length < window, last: rows[read - 1] };
251
+ }
252
+ /**
253
+ * The live markers of one shape after `after`, in index order, that are `wanted` (a SQL predicate
254
+ * on the marker `m`, binding one parameter: `json`), as many as `room` allows, read as one
255
+ * {@link takeWindow}.
256
+ */
257
+ function walkMarkers(db, now, prefix, after, room, wanted, json) {
258
+ if (room.writes === 0 || room.reads === 0)
259
+ return { found: [], done: false, at: after };
260
+ const [, end] = typeRange(prefix);
261
+ const from = after ?? { object: prefix, subject: '' };
262
+ const window = room.reads;
263
+ const rows = db.all(`SELECT m.object, m.subject,
264
+ (substr(m.subject, 1, ${PRINCIPAL.length}) = '${PRINCIPAL}' AND ${liveTupleSql('m')} AND ${wanted}) AS hit
265
+ FROM _substrat_tuples m
266
+ WHERE m.relation = '${ENTITY_SHAPE_MARKER_RELATION}' AND (m.object, m.subject) > (?, ?) AND m.object < ?
267
+ ORDER BY m.object, m.subject
268
+ LIMIT ?`, now, json,
269
+ // A row-value seek: a resumed walk starts after the cursor and reads none of what it passed.
270
+ from.object, from.subject, end, window);
271
+ const { found, done, last } = takeWindow(rows, window, room);
272
+ return { found, done, at: last ? { object: last.object, subject: last.subject } : after };
273
+ }
274
+ /**
275
+ * One bounded walk of a shape's top-up: each live marker whose entity lacks a key of the current
276
+ * shape (no row at all — a tombstone is a revoke, kept) gets that key, with one event.
277
+ */
278
+ function topUp(db, pass, entityType, prefix, keys, after, room) {
279
+ const json = JSON.stringify(keys);
280
+ const walk = walkMarkers(db, pass.now, prefix, after, room, `EXISTS (SELECT 1 FROM json_each(?) k
281
+ WHERE NOT EXISTS (SELECT 1 FROM _substrat_tuples t
282
+ WHERE t.subject = m.subject AND t.object = m.object
283
+ AND t.relation = 'granted:' || k.value))`, json);
284
+ for (const h of walk.found) {
285
+ const added = db.all(`SELECT k.value AS key FROM json_each(?) k
286
+ WHERE NOT EXISTS (SELECT 1 FROM _substrat_tuples t
287
+ WHERE t.subject = ? AND t.object = ? AND t.relation = 'granted:' || k.value)
288
+ ORDER BY k.value`, json, h.subject, h.object).map((m) => m.key);
289
+ for (const key of added) {
290
+ db.run(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)`, h.subject, `granted:${key}`, h.object);
157
291
  }
158
- budget -= holders.length;
159
- toppedUp += holders.length;
160
- if (budget === 0)
161
- return { toppedUp, retired, done: false };
292
+ const st = kernelOutboxInsertSql(shapeEvent(pass, ENTITY_GRANTS_TOPPED_UP, entityGrantsToppedUpPayload, {
293
+ principal: h.subject.slice(PRINCIPAL.length),
294
+ entity: { entityType, entityId: h.object.slice(prefix.length) },
295
+ added,
296
+ }), pass.version);
297
+ db.run(st.sql, ...st.params);
162
298
  }
163
- return { toppedUp, retired, done: true };
299
+ return walk;
164
300
  }
165
301
  /**
166
- * One bounded batch of a shape's retirement: at most `budget` (person, entity) pairs, each a
167
- * live marker still holding a retired key live there, whose retired keys are tombstoned with one
168
- * event. Keys whose retirement already finished on this scope are skipped, and a short batch
169
- * records the rest finished. Returns how many pairs it took keys from.
302
+ * One bounded walk of a shape's retirement: each live marker still holding a retired key live
303
+ * there has its retired keys tombstoned, with one event. Keys whose retirement already finished
304
+ * on this scope are skipped. The rest are recorded finished only by a `clean` (confirming) walk
305
+ * that reaches the shape's last marker having taken nothing.
170
306
  */
171
- function retire(db, pass, entityType, prefix, gone, budget) {
307
+ function retire(db, pass, entityType, prefix, gone, after, room, clean) {
172
308
  if (gone.length === 0)
173
- return 0;
309
+ return NOTHING_TO_WALK;
174
310
  const [shapeRef, scopeRef] = recordRefs(entityType, pass.scopeId);
175
311
  const finished = new Set(db.all(`SELECT substr(relation, ${RETIRED_RELATION.length + 1}) AS key FROM _substrat_tuples
176
312
  WHERE subject = ? AND object = ? AND revoked_at IS NULL
177
313
  AND relation IN (SELECT '${RETIRED_RELATION}' || value FROM json_each(?))`, shapeRef, scopeRef, JSON.stringify(gone)).map((r) => r.key));
178
314
  const open = gone.filter((k) => !finished.has(k));
179
315
  if (open.length === 0)
180
- return 0;
316
+ return NOTHING_TO_WALK;
181
317
  const json = JSON.stringify(open);
182
- const holders = db.all(`SELECT m.subject, m.object FROM _substrat_tuples m
183
- WHERE m.relation = ? AND ${liveTupleSql('m')}
184
- AND substr(m.subject, 1, ${PRINCIPAL.length}) = '${PRINCIPAL}'
185
- AND substr(m.object, 1, ?) = ?
186
- AND EXISTS (SELECT 1 FROM _substrat_tuples t
187
- WHERE t.subject = m.subject AND t.object = m.object AND t.revoked_at IS NULL
188
- AND t.relation IN (SELECT 'granted:' || value FROM json_each(?)))
189
- ORDER BY m.subject, m.object
190
- LIMIT ?`, ENTITY_SHAPE_MARKER_RELATION, pass.now, prefix.length, prefix, json, budget);
191
- for (const h of holders) {
318
+ const walk = walkMarkers(db, pass.now, prefix, after, room, `EXISTS (SELECT 1 FROM _substrat_tuples t
319
+ WHERE t.subject = m.subject AND t.object = m.object AND t.revoked_at IS NULL
320
+ AND t.relation IN (SELECT 'granted:' || value FROM json_each(?)))`, json);
321
+ for (const h of walk.found) {
192
322
  // Tombstoned and read back in one statement: the keys this holder lost are what it touched.
193
323
  const removed = keysOf(db.all(`UPDATE _substrat_tuples SET revoked_at = ?
194
324
  WHERE subject = ? AND object = ? AND revoked_at IS NULL
@@ -201,12 +331,12 @@ function retire(db, pass, entityType, prefix, gone, budget) {
201
331
  }), pass.version);
202
332
  db.run(st.sql, ...st.params);
203
333
  }
204
- if (holders.length < budget) {
334
+ if (walk.done && clean && walk.found.length === 0) {
205
335
  for (const k of open) {
206
336
  db.run('INSERT OR REPLACE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', shapeRef, `${RETIRED_RELATION}${k}`, scopeRef);
207
337
  }
208
338
  }
209
- return holders.length;
339
+ return walk;
210
340
  }
211
341
  /**
212
342
  * A key back in the shape ends its retirement's record (tombstoned, like any tuple), so a later
@@ -220,93 +350,225 @@ function reopenRetirements(db, pass, entityType, keys) {
220
350
  AND relation IN (SELECT '${RETIRED_RELATION}' || value FROM json_each(?))`, pass.now, ...recordRefs(entityType, pass.scopeId), JSON.stringify(keys));
221
351
  }
222
352
  /**
223
- * One bounded batch of the backfill for one shape — at most `budget` marks — and the run-once
224
- * record when the batch comes back short. Returns how many it marked. See the module comment:
225
- * provenance only, from the shape's declared `holder`.
353
+ * One window of the backfill for one shape after `after` (#2154), as one {@link takeWindow}: at
354
+ * most `room.reads` rows read, in an index order a cursor can seek, and at most `room.writes`
355
+ * marks; `done` once it read its whole range, else `at` is the last row it read. The run-once record is written only by the window that reads to the end of the range, so
356
+ * a backfill cut short by either budget hands back where it stopped and keeps no record. See the
357
+ * module comment: provenance only, from the shape's declared `holder`.
226
358
  */
227
- function backfill(db, pass, shape, prefix, json, budget) {
228
- if (!shape.holder || budget === 0)
229
- return 0;
359
+ function backfill(db, pass, shape, prefix, json, room, after) {
360
+ if (!shape.holder)
361
+ return { done: true, at: null };
230
362
  const [shapeRef, scopeRef] = recordRefs(shape.entityType, pass.scopeId);
231
363
  const record = [shapeRef, BACKFILLED_RELATION, scopeRef];
232
364
  if (db.all('SELECT 1 FROM _substrat_tuples WHERE subject = ? AND relation = ? AND object = ?', ...record).length > 0) {
233
- return 0;
365
+ return { done: true, at: null };
234
366
  }
367
+ if (room.writes === 0 || room.reads === 0)
368
+ return { done: false, at: after };
369
+ const window = room.reads;
235
370
  // Not yet a holder in any state: a tombstoned marker is a revoke of the holding, kept.
236
371
  const unmarked = (subject, object) => `NOT EXISTS (SELECT 1 FROM _substrat_tuples m WHERE m.subject = ${subject} AND m.relation = '${ENTITY_SHAPE_MARKER_RELATION}' AND m.object = ${object})`;
237
372
  const heldThere = (subject, object) => `EXISTS (SELECT 1 FROM _substrat_tuples g WHERE g.subject = ${subject} AND g.object = ${object}
238
373
  AND g.relation IN (SELECT 'granted:' || value FROM json_each(?)))`;
239
- let candidates;
240
- if (shape.holder === 'self') {
241
- // The entity id IS the principal: `owner:<p>` belongs to `principal:<p>` and nobody else.
242
- candidates = db.all(`SELECT DISTINCT t.subject, t.object FROM _substrat_tuples t
243
- WHERE substr(t.object, 1, ?) = ?
244
- AND t.subject = '${PRINCIPAL}' || substr(t.object, ?)
245
- AND t.relation IN (SELECT 'granted:' || value FROM json_each(?))
246
- AND ${unmarked('t.subject', 't.object')}
247
- ORDER BY t.subject, t.object
248
- LIMIT ?`, prefix.length, prefix, prefix.length + 1, json, budget);
249
- }
250
- else if (shape.holder === 'grantee') {
251
- // Whoever holds a LIVE key of the shape on an entity of this type. Live, unlike the other two
252
- // holders: with no record naming the person, a key taken back from them is no evidence they
253
- // still belong there, and marking them would hand a revoked portal the next key it gains.
254
- candidates = db.all(`SELECT DISTINCT t.subject, t.object FROM _substrat_tuples t
255
- WHERE substr(t.object, 1, ?) = ?
256
- AND substr(t.subject, 1, ${PRINCIPAL.length}) = '${PRINCIPAL}'
257
- AND t.relation IN (SELECT 'granted:' || value FROM json_each(?))
258
- AND ${liveTupleSql('t')}
259
- AND ${unmarked('t.subject', 't.object')}
260
- ORDER BY t.subject, t.object
261
- LIMIT ?`, prefix.length, prefix, json, pass.now, budget);
374
+ const shapeKey = `t.relation IN (SELECT 'granted:' || value FROM json_each(?))`;
375
+ // The rows of the window, and the sort key the cursor carries for a row.
376
+ let rows;
377
+ let keyOf;
378
+ if (shape.holder === 'self' || shape.holder === 'grantee') {
379
+ // The type's tuples on `_substrat_tuples_object_relation_subject`, in its order, so the cursor
380
+ // is a row-value seek into the range. Every tuple of the type is read, a key of another shape
381
+ // or a marker included; which of them make a candidate is `hit`.
382
+ const [, end] = typeRange(prefix);
383
+ const from = after ?? [prefix, '', ''];
384
+ const [holds, holdsParams] = shape.holder === 'self'
385
+ ? // The entity id IS the principal: `owner:<p>` belongs to `principal:<p>` and nobody else.
386
+ [`t.subject = '${PRINCIPAL}' || substr(t.object, ${prefix.length + 1})`, []]
387
+ : // Whoever holds a LIVE key of the shape on an entity of this type. Live, unlike the other
388
+ // two holders: with no record naming the person, a key taken back from them is no evidence
389
+ // they still belong there, and marking them would hand a revoked portal the next key it gains.
390
+ [`substr(t.subject, 1, ${PRINCIPAL.length}) = '${PRINCIPAL}' AND ${liveTupleSql('t')}`, [pass.now]];
391
+ rows = db.all(`SELECT t.object, t.relation, t.subject, (${shapeKey} AND ${holds} AND ${unmarked('t.subject', 't.object')}) AS hit
392
+ FROM _substrat_tuples t
393
+ WHERE (t.object, t.relation, t.subject) > (?, ?, ?) AND t.object < ?
394
+ ORDER BY t.object, t.relation, t.subject
395
+ LIMIT ?`, json, ...holdsParams, ...from, end, window);
396
+ keyOf = (r) => [r.object, r.relation, r.subject];
262
397
  }
263
398
  else {
264
- const { table, idColumn, principalColumn } = shape.holder;
265
- const where = `entityGrants holder of '${shape.entityType}'`;
266
- for (const [kind, name] of [['a table', table], ['a column', idColumn], ['a column', principalColumn]]) {
267
- assertSqlIdentifier('reconcileEntityGrantShapes', kind, name, where);
268
- }
399
+ const { table, idColumn, principalColumn } = holderNames(shape.holder, 'reconcileEntityGrantShapes', `entityGrants holder of '${shape.entityType}'`);
269
400
  // The vertical's table may not exist yet on this scope (a module not migrated): nothing to
270
401
  // mark, and the backfill stays open for the next reconcile rather than being recorded done.
271
- if (db.all(`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`, table).length === 0)
272
- return 0;
402
+ // Asked of SQLite, not read off the DDL's text, which keeps table options in any order and
403
+ // comments after them (#2159 review).
404
+ const listed = db.all(`SELECT wr FROM pragma_table_list WHERE schema = 'main' AND type = 'table' AND name = ?`, table)[0];
405
+ if (!listed)
406
+ return { done: true, at: null };
273
407
  const subject = `'${PRINCIPAL}' || e."${principalColumn}"`;
274
408
  const object = `? || e."${idColumn}"`;
275
- candidates = db.all(`SELECT ${subject} AS subject, ${object} AS object FROM "${table}" e
276
- WHERE e."${principalColumn}" IS NOT NULL
277
- AND ${heldThere(subject, object)}
278
- AND ${unmarked(subject, object)}
279
- ORDER BY 1, 2
280
- LIMIT ?`, prefix, prefix, json, prefix, budget);
409
+ // The table in `idColumn` order, the cursor a seek on it: the raw values, so they compare as the
410
+ // columns do. Nothing makes `idColumn` unique, and the principal may be NULL, so the key goes on
411
+ // to the row's own identity (#2159 review): rows sharing an id are never skipped at a window's
412
+ // edge. Bounded only where `idColumn` leads an index (an entity id is the primary key);
413
+ // otherwise every window sorts the whole table, which `warnUnboundedRead` reports.
414
+ const key = [`e."${idColumn}"`, ...rowIdentity(db, table, idColumn, Number(listed['wr']) === 1)];
415
+ const sql = `SELECT ${key.map((k, i) => `${k} AS k${i}`).join(', ')}, ${subject} AS subject, ${object} AS object,
416
+ (e."${principalColumn}" IS NOT NULL AND ${heldThere(subject, object)} AND ${unmarked(subject, object)}) AS hit
417
+ FROM "${table}" e
418
+ WHERE ${after ? `(${key.join(', ')}) > (${key.map(() => '?').join(', ')})` : `e."${idColumn}" IS NOT NULL`}
419
+ ORDER BY ${key.join(', ')}
420
+ LIMIT ?`;
421
+ const params = [prefix, prefix, json, prefix, ...(after ?? []), window];
422
+ if (!after)
423
+ warnUnboundedRead(db, pass, shape.entityType, { table, idColumn }, sql, params);
424
+ rows = db.all(sql, ...params);
425
+ keyOf = (r) => key.map((_, i) => r[`k${i}`]);
281
426
  }
282
- for (const c of candidates) {
427
+ const { found, done, last } = takeWindow(rows, window, room);
428
+ for (const c of found) {
283
429
  db.run('INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', c.subject, ENTITY_SHAPE_MARKER_RELATION, c.object);
284
430
  }
285
- if (candidates.length < budget) {
431
+ if (done)
286
432
  db.run('INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', ...record);
433
+ return { done, at: last ? keyOf(last) : after };
434
+ }
435
+ /**
436
+ * What tells apart two rows of a holder table that share an `idColumn`, after it in the read's key:
437
+ * the `rowid`, unique, never NULL, and already the last column of every index on a rowid table, so
438
+ * an index leading with `idColumn` serves the whole key with no sort. Named by the first of its three
439
+ * names that no column of the table has taken: a column called `rowid` is that column, not the row's
440
+ * identity. A table that takes all three is refused (`validation_failed`) rather than read with a key
441
+ * that can tie: a rowid table's primary key may hold NULLs, so it cannot stand in. A `WITHOUT ROWID` table has
442
+ * none; its primary key columns are NOT NULL, unique, and end each of its indexes the same way, so
443
+ * they stand in, read in key order (those already `idColumn`'s left out: a key of `idColumn`
444
+ * alone is unique, and needs nothing after it).
445
+ */
446
+ function rowIdentity(db, table, idColumn, withoutRowid) {
447
+ const columns = db.all('SELECT name, pk FROM pragma_table_info(?) ORDER BY pk', table).map((r) => ({ name: String(r['name']), pk: Number(r['pk']) }));
448
+ if (withoutRowid) {
449
+ return columns.filter((c) => c.pk > 0 && c.name !== idColumn).map((c) => `e."${c.name.replaceAll('"', '""')}"`);
450
+ }
451
+ const taken = new Set(columns.map((c) => c.name.toLowerCase()));
452
+ const alias = ROWID_NAMES.find((n) => !taken.has(n));
453
+ if (!alias) {
454
+ throw substratError('validation_failed', `reconcileEntityGrantShapes: holder table "${table}" has columns named rowid, _rowid_ and oid, so its rows cannot be told apart; rename one`, { errors: [{ path: 'holder.table', message: 'a table that leaves one of rowid, _rowid_ or oid to the row' }] });
287
455
  }
288
- return candidates.length;
456
+ return [`e.${alias}`];
289
457
  }
458
+ /** The names SQLite answers a rowid table's row identity to, unless a column has taken one. */
459
+ const ROWID_NAMES = ['rowid', '_rowid_', 'oid'];
290
460
  /**
291
- * The current and retired keys of every `holder: 'grantee'` bootstrap shape, recorded so the
292
- * explicit tuple writer can refuse them to every non-shape grant. A reconcile carries the
293
- * reached version's whole reviewed registry,
294
- * so the records are made to match it exactly: a declaration a later version drops stops
295
- * refusing. Bounded by the declared keys, so it runs in every pass without a budget.
461
+ * A table holder's backfill is bounded only where SQLite reads the table in `idColumn` order off
462
+ * an index; where it must sort instead (`idColumn` leads no index), every window reads the whole
463
+ * table. The schema is the vertical's, known only on the scope, so the backfill's first window
464
+ * asks SQLite for the plan of the read it is about to send and writes one structured warn line
465
+ * when the plan sorts the whole table (`USE TEMP B-TREE FOR ORDER BY`), its keys a contract as the
466
+ * log lines' are. The read still runs: the backfill is correct, only unbounded. A sort of only the
467
+ * key's LAST TERM is not reported: an index on `(idColumn, principalColumn)` serves `idColumn` but
468
+ * not the row identity after it, so SQLite sorts the rows of one id at a time, which is bounded by
469
+ * the rows sharing an id, not by the table.
296
470
  */
297
- function recordGranteeKeys(db, shapes) {
298
- const rows = shapes
299
- .filter((s) => s.bootstrap && s.holder === 'grantee')
300
- .flatMap((s) => keysOf([...s.permissions, ...(s.retired ?? [])]).map((k) => [`shape:${s.entityType}`, `granted:${k}`]));
471
+ function warnUnboundedRead(db, pass, entityType, holder, sql, params) {
472
+ const plan = db.all(`EXPLAIN QUERY PLAN ${sql}`, ...params).map((r) => String(r['detail']));
473
+ if (!plan.some((d) => d.startsWith('USE TEMP B-TREE FOR ORDER BY')))
474
+ return;
475
+ console.warn(JSON.stringify({
476
+ substrat: 'shape-backfill-unbounded',
477
+ level: 'warn',
478
+ tenantId: pass.tenantId,
479
+ scopeId: pass.scopeId,
480
+ entityType,
481
+ ...holder,
482
+ message: `the entity-grant backfill of '${entityType}' sorts all of "${holder.table}" on every pass: index "${holder.idColumn}" to bound it`,
483
+ }));
484
+ }
485
+ /**
486
+ * A BINDING shape's record (#1686), on the scope's tuple spine beside the grantee keys: which
487
+ * table column names the record's person (`<table>.<idColumn>.<principalColumn>`), the key that
488
+ * lets someone bind or unbind them, and the shape's current keys — what a binding invite's claim
489
+ * gives. The claim and an unbind read these, never the vertical's code: the shape is the reviewed
490
+ * registry the platform last reconciled this scope with. Each identifier is a SQL identifier, so
491
+ * `.` cannot occur inside one.
492
+ */
493
+ export const BIND_HOLDER_RELATION = 'shape-bind-holder';
494
+ export const BIND_PERMISSION_RELATION = 'shape-bind-permission';
495
+ export const BIND_KEY_RELATION = 'shape-bind-key';
496
+ /**
497
+ * `(shape:<type>, shape-bound, bound)`: this scope has declared a binding for `<type>` (#1686).
498
+ * Written by the first reconcile that records the binding (and by every claim, to the same effect)
499
+ * and STICKY — no reconcile removes it, whatever later
500
+ * versions declare — so an erasure keeps releasing that type's holders after a version drops the
501
+ * shape, drops its `bind` or moves its holder column. A spine tuple like the others here, so
502
+ * module code can never write one (`ctx.sql` refuses every `_substrat_*` write).
503
+ */
504
+ export const BOUND_EVER_RELATION = 'shape-bound';
505
+ /** A shape the reconcile records as binding: bootstrap, a table holder, and a `bind`. */
506
+ const binds = (s) => !!s.bootstrap && !!s.bind && typeof s.holder === 'object';
507
+ /** A table holder's three names, each checked as a SQL identifier before anything interpolates it. */
508
+ function holderNames(holder, area, where) {
509
+ for (const [kind, name] of [['a table', holder.table], ['a column', holder.idColumn], ['a column', holder.principalColumn]]) {
510
+ assertSqlIdentifier(area, kind, name, where);
511
+ }
512
+ return holder;
513
+ }
514
+ /**
515
+ * The registry facts a reconcile records for the explicit tuple writer and a binding claim, made to
516
+ * match the reached version's whole reviewed registry exactly, so a declaration a later version
517
+ * drops stops counting:
518
+ * - every current and retired key of each `holder: 'grantee'` or BOUND bootstrap shape
519
+ * (`shape-grantee-key`), which every non-shape grant then refuses on that entity type;
520
+ * - each bound shape's holder column and current keys, and — only while it declares `bind` — its
521
+ * bind key (#1686).
522
+ *
523
+ * A type that has ever bound (`shape-bound`) keeps its protected keys whatever later versions
524
+ * declare, so the keys stay refused to direct grants while its holders keep the shape; a key a
525
+ * later version gives the shape joins them.
526
+ *
527
+ * A shape is BOUND while it declares `bind`, and stays bound after a version drops `bind` for as
528
+ * long as it is still a bootstrap shape with the same table holder: records may already name their
529
+ * people in that column, holding the shape through it, and an erasure releases them by these
530
+ * facts. Without the bind key nobody can mint, claim or unbind any more; what was bound stays as a
531
+ * shape holder does when a key is dropped without being retired. A version that drops the shape,
532
+ * or changes its holder, drops the facts with it.
533
+ * Three statements, bounded by the declared shapes, so it runs without a budget.
534
+ */
535
+ function recordShapeFacts(db, shapes) {
536
+ const ref = (s) => `shape:${s.entityType}`;
537
+ const holderOf = (s) => {
538
+ const { table, idColumn, principalColumn } = holderNames(s.holder, 'reconcileEntityGrantShapes', `entityGrants bind of '${s.entityType}'`);
539
+ return `${table}.${idColumn}.${principalColumn}`;
540
+ };
541
+ const recorded = new Map(db.all(`SELECT subject, object FROM _substrat_tuples WHERE subject >= 'shape:' AND subject < 'shape;' AND relation = ?`, BIND_HOLDER_RELATION).map((r) => [r.subject, r.object]));
542
+ const tableHeld = (s) => !!s.bootstrap && typeof s.holder === 'object';
543
+ const bound = shapes.filter(tableHeld).filter((s) => binds(s) || recorded.get(ref(s)) === holderOf(s));
544
+ const boundTypes = new Set(bound.map((s) => s.entityType));
545
+ const facts = [
546
+ ...shapes
547
+ .filter((s) => (s.bootstrap && s.holder === 'grantee') || boundTypes.has(s.entityType))
548
+ .flatMap((s) => keysOf([...s.permissions, ...(s.retired ?? [])]).map((k) => [ref(s), SHAPE_PROTECTED_KEY_RELATION, `granted:${k}`])),
549
+ ...bound.map((s) => [ref(s), BIND_HOLDER_RELATION, holderOf(s)]),
550
+ ...shapes.filter(binds).map((s) => [ref(s), BIND_PERMISSION_RELATION, s.bind.permission]),
551
+ // A declared binding is sticky from the first reconcile that records it, not only from its first
552
+ // claim: its keys stay refused and its holders (backfilled ones included) stay reachable by an
553
+ // erasure, whatever later versions declare. Never deleted: the DELETE below names other relations.
554
+ ...shapes.filter(binds).map((s) => [ref(s), BOUND_EVER_RELATION, 'bound']),
555
+ ...bound.flatMap((s) => keysOf(s.permissions).map((k) => [ref(s), BIND_KEY_RELATION, `granted:${k}`])),
556
+ ];
557
+ const json = JSON.stringify(facts);
301
558
  db.run(
302
559
  // The subject range keeps it on the primary key: `shape:` rows only, never the whole table.
303
- `DELETE FROM _substrat_tuples WHERE subject >= 'shape:' AND subject < 'shape;' AND relation = ?
560
+ `DELETE FROM _substrat_tuples WHERE subject >= 'shape:' AND subject < 'shape;' AND relation IN (?, ?, ?, ?)
304
561
  AND NOT EXISTS (SELECT 1 FROM json_each(?) r
305
562
  WHERE json_extract(r.value, '$[0]') = _substrat_tuples.subject
306
- AND json_extract(r.value, '$[1]') = _substrat_tuples.object)`, GRANTEE_KEY_RELATION, JSON.stringify(rows));
307
- for (const [subject, object] of rows) {
308
- db.run('INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object) VALUES (?, ?, ?)', subject, GRANTEE_KEY_RELATION, object);
309
- }
563
+ AND json_extract(r.value, '$[1]') = _substrat_tuples.relation
564
+ AND json_extract(r.value, '$[2]') = _substrat_tuples.object)
565
+ -- A type that ever bound keeps its protected keys whatever this version declares (#1686):
566
+ -- its holders keep the shape until released, and a direct grant of a key of it made while
567
+ -- the shape is out of the registry would be a holder no marker, unbind or held check sees.
568
+ AND NOT (relation = ? AND EXISTS (SELECT 1 FROM _substrat_tuples b
569
+ WHERE b.subject = _substrat_tuples.subject AND b.relation = ?))`, SHAPE_PROTECTED_KEY_RELATION, BIND_HOLDER_RELATION, BIND_PERMISSION_RELATION, BIND_KEY_RELATION, json, SHAPE_PROTECTED_KEY_RELATION, BOUND_EVER_RELATION);
570
+ db.run(`INSERT OR IGNORE INTO _substrat_tuples (subject, relation, object)
571
+ SELECT json_extract(value, '$[0]'), json_extract(value, '$[1]'), json_extract(value, '$[2]') FROM json_each(?)`, json);
310
572
  }
311
573
  /**
312
574
  * The kernel's event for one (person, entity) a pass changed — a top-up or a retirement — the