@wtfalch/foundry 0.68.3 → 0.69.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.
@@ -28,6 +28,8 @@ export interface SealResult {
28
28
  treeSize?: number;
29
29
  /** How many checkpoints the authority stamped in this tick. */
30
30
  anchored: number;
31
+ /** How many erased rows this tick sealed the erasure of; on a sweep that failed part way, the rows sealed before the failure. */
32
+ erasures: number;
31
33
  /** Plain words on why a step did not happen. Present whenever `outcome` is not `sealed`, or anchoring did not run. */
32
34
  reason?: string;
33
35
  at: string;
@@ -1,4 +1,5 @@
1
- import { anchorCheckpoints, sealCheckpoint } from '@wtfalch/audit-sealing';
1
+ import { anchorCheckpoints, computeErasureHash, sealCheckpoint } from '@wtfalch/audit-sealing';
2
+ import { sql } from 'drizzle-orm';
2
3
  import { isUnavailable } from './connector.js';
3
4
  /**
4
5
  * The sealing and anchoring tick (foundry#929, audit#56). The host calls {@link sealLedgers} on
@@ -51,6 +52,7 @@ async function sealOne(deps, ledger, now) {
51
52
  ...base,
52
53
  outcome: 'failed',
53
54
  anchored: 0,
55
+ erasures: 0,
54
56
  reason: `could not open the ledger: ${plainFailure(error)}`,
55
57
  at: now().toISOString(),
56
58
  };
@@ -60,6 +62,7 @@ async function sealOne(deps, ledger, now) {
60
62
  ...base,
61
63
  outcome: 'skipped',
62
64
  anchored: 0,
65
+ erasures: 0,
63
66
  reason: `not sealed, the ledger is not reachable: ${connection.unavailable}`,
64
67
  at: now().toISOString(),
65
68
  };
@@ -68,17 +71,33 @@ async function sealOne(deps, ledger, now) {
68
71
  let outcome = 'unchanged';
69
72
  let treeSize;
70
73
  let anchored = 0;
74
+ let erasures = 0;
71
75
  try {
76
+ const progress = { sealed: 0 };
77
+ try {
78
+ await sealErasures(connection.handle, progress);
79
+ }
80
+ catch (error) {
81
+ if (isMissingGrant(error)) {
82
+ notes.push("erasures not sealed yet: the sealing role gets the grant on the app's next apply");
83
+ }
84
+ else {
85
+ outcome = 'failed';
86
+ notes.push(`sealing erasures failed: ${plainFailure(error)}`);
87
+ }
88
+ }
89
+ erasures = progress.sealed;
72
90
  try {
73
91
  const checkpoint = await sealCheckpoint(connection.handle, {
74
92
  ledger: ledger.ledger,
75
93
  signer: deps.signer,
76
94
  });
77
95
  if (checkpoint) {
78
- outcome = 'sealed';
96
+ if (outcome !== 'failed')
97
+ outcome = 'sealed';
79
98
  treeSize = checkpoint.tree_size;
80
99
  }
81
- else {
100
+ else if (outcome !== 'failed') {
82
101
  notes.push('nothing sealed, no entry since the last checkpoint');
83
102
  }
84
103
  }
@@ -87,7 +106,8 @@ async function sealOne(deps, ledger, now) {
87
106
  ...base,
88
107
  outcome: 'failed',
89
108
  anchored: 0,
90
- reason: `sealing failed: ${plainFailure(error)}`,
109
+ erasures,
110
+ reason: [`sealing failed: ${plainFailure(error)}`, ...notes].join('; '),
91
111
  at: now().toISOString(),
92
112
  };
93
113
  }
@@ -125,7 +145,33 @@ async function sealOne(deps, ledger, now) {
125
145
  outcome,
126
146
  ...(treeSize === undefined ? {} : { treeSize }),
127
147
  anchored,
148
+ erasures,
128
149
  ...(notes.length > 0 ? { reason: notes.join('; ') } : {}),
129
150
  at: now().toISOString(),
130
151
  };
131
152
  }
153
+ const rowsOf = (result) => Array.isArray(result) ? result : (result.rows ?? []);
154
+ /**
155
+ * An existing sealing role gets the erasure grants only on its app's next apply, so 42501 (insufficient privilege)
156
+ * and 42883 (undefined function) mean "not yet", not broken. Read on the error or the driver error it wraps.
157
+ */
158
+ function isMissingGrant(error) {
159
+ for (let e = error, depth = 0; e && depth < 3; depth++) {
160
+ const code = e.code;
161
+ if (code === '42501' || code === '42883')
162
+ return true;
163
+ e = e.cause;
164
+ }
165
+ return false;
166
+ }
167
+ async function sealErasures(handle, progress) {
168
+ const pending = rowsOf(await handle.execute(sql `select id, row_hash, erased_at from audit_pending_erasures()`));
169
+ for (const row of pending) {
170
+ if (!row.row_hash || row.erased_at === null || row.erased_at === undefined)
171
+ continue;
172
+ const erasedAt = row.erased_at instanceof Date ? row.erased_at : new Date(String(row.erased_at));
173
+ const erasureHash = await computeErasureHash(row.row_hash, erasedAt);
174
+ await handle.execute(sql `select audit_seal_erasure(${row.id}, ${erasureHash})`);
175
+ progress.sealed++;
176
+ }
177
+ }
@@ -26,7 +26,7 @@ export interface SealerRoleSql {
26
26
  }
27
27
  /**
28
28
  * Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
29
- * insert into the checkpoint, signing-key and anchor tables and run the two sealing functions, and holds nothing
29
+ * insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
30
30
  * else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
31
31
  * checkpoint as this role to prove the list is enough.
32
32
  */
@@ -36,7 +36,7 @@ const ident = (name) => `"${name.replace(/"/g, '""')}"`;
36
36
  const literal = (value) => `'${value.replace(/'/g, "''")}'`;
37
37
  /**
38
38
  * Makes `user` true: it can log in with `password`, is no superuser and bypasses no row security, may read and
39
- * insert into the checkpoint, signing-key and anchor tables and run the two sealing functions, and holds nothing
39
+ * insert into the checkpoint, signing-key and anchor tables and run the sealing functions (leaves, key retirement, pending erasures, erasure sealing), and holds nothing
40
40
  * else. The grants are listed here and in `AUDIT_SEALER_GRANTS`; `audit/sealer-role.test.ts` runs a real
41
41
  * checkpoint as this role to prove the list is enough.
42
42
  */
@@ -15,6 +15,7 @@ export async function runAuditSealTick(deps) {
15
15
  entryKey: ledger.entryKey,
16
16
  outcome: 'skipped',
17
17
  anchored: 0,
18
+ erasures: 0,
18
19
  reason,
19
20
  at: now().toISOString(),
20
21
  };
@@ -12,13 +12,15 @@ export declare const AUDIT_PACKAGE_VERSION = "0.14.0";
12
12
  /** Insert and read only: the runtime role has UPDATE, DELETE and TRUNCATE revoked on each. */
13
13
  export declare const AUDIT_APPEND_ONLY_TABLES: readonly string[];
14
14
  /**
15
- * What the app's runtime role may run: the two functions the chain is written through. Not granted: the erase
16
- * functions (`audit_erase_person`, `audit_seal_erasure`, `audit_pending_erasures`), because they change what the
17
- * ledger shows and whether the app may call them is an open decision (foundry#929 item 10); and the sealing
18
- * functions (`audit_chain_leaves`, `audit_retire_signing_key`), which only the platform's sealing role runs.
15
+ * What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
16
+ * functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
17
+ * (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
18
+ * `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
19
+ * sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
20
+ * this case).
19
21
  */
20
22
  export declare const AUDIT_APP_GRANTS: readonly string[];
21
- /** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
23
+ /** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
22
24
  export declare const AUDIT_SEALER_GRANTS: readonly string[];
23
25
  type Database = NonNullable<WorkerAppConfig['database']>;
24
26
  /**
@@ -16,16 +16,20 @@ export const AUDIT_APPEND_ONLY_TABLES = [
16
16
  'audit_anchors',
17
17
  ];
18
18
  /**
19
- * What the app's runtime role may run: the two functions the chain is written through. Not granted: the erase
20
- * functions (`audit_erase_person`, `audit_seal_erasure`, `audit_pending_erasures`), because they change what the
21
- * ledger shows and whether the app may call them is an open decision (foundry#929 item 10); and the sealing
22
- * functions (`audit_chain_leaves`, `audit_retire_signing_key`), which only the platform's sealing role runs.
19
+ * What the app's runtime role may run: the two functions the chain is written through. Not granted: the sealing
20
+ * functions (`audit_chain_leaves`, `audit_retire_signing_key`) and the erasure sealing functions
21
+ * (`audit_pending_erasures`, `audit_seal_erasure`), which only the platform's sealing role runs. An app may list
22
+ * `audit_erase_person(text, text, text)` itself and nothing else from the erase set: it pseudonymises, and the
23
+ * sealing role seals each erasure in the next seal tick (William, 2026-10-26, settles foundry#929 item 10 for
24
+ * this case).
23
25
  */
24
26
  export const AUDIT_APP_GRANTS = ['audit_chain_tail()', 'audit_chain_tail_v2()'];
25
- /** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key. */
27
+ /** What the platform's sealing role runs, beside the app's own: reads every leaf, retires a signing key, seals erasures. */
26
28
  export const AUDIT_SEALER_GRANTS = [
27
29
  'audit_chain_leaves(bigint, integer)',
28
30
  'audit_retire_signing_key(text)',
31
+ 'audit_pending_erasures()',
32
+ 'audit_seal_erasure(bigint, text)',
29
33
  ];
30
34
  const without = (list, drop) => (list ?? []).filter((item) => !drop.includes(item));
31
35
  /**
@@ -167,6 +167,8 @@ export interface WorkerAppWant {
167
167
  noDelete?: string[];
168
168
  grants?: string[];
169
169
  migrationsSha256?: string;
170
+ /** `uses.audit`: a hash of `AUDIT_SEALER_GRANTS`, so an applied app is planned again when the list changes and its sealing role gets the new grants. */
171
+ auditSealerGrants?: string;
170
172
  };
171
173
  /** `workerApp.triggers.crons`, sorted. Absent when none, so a Worker without cron keeps the marker it already has. */
172
174
  crons?: string[];
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
+ import { AUDIT_SEALER_GRANTS } from '../modules/audit-ledger.js';
2
3
  import { APPS_DOMAIN, heldEnvRef } from '../org.js';
3
4
  import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
4
5
  import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
@@ -203,6 +204,15 @@ export function wantedWorkerApp(org, entry, recorded, input) {
203
204
  ...(app.database.migrationsSha256
204
205
  ? { migrationsSha256: app.database.migrationsSha256 }
205
206
  : {}),
207
+ // The order of AUDIT_SEALER_GRANTS is part of the hash: a reorder plans every audit app once.
208
+ ...(entry.uses?.audit === true
209
+ ? {
210
+ auditSealerGrants: createHash('sha256')
211
+ .update(JSON.stringify(AUDIT_SEALER_GRANTS))
212
+ .digest('hex')
213
+ .slice(0, 16),
214
+ }
215
+ : {}),
206
216
  },
207
217
  }
208
218
  : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.68.3",
3
+ "version": "0.69.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",