@alma-harness/postgres 0.1.0 → 0.3.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/README.md CHANGED
@@ -3,7 +3,8 @@
3
3
  Reference storage adapters for [Alma](https://github.com/FabioFernandesCarneiro/alma),
4
4
  with row-level security as defense in depth.
5
5
 
6
- > **Status: pre-release.** Not yet published to npm.
6
+ > **Status: 0.2.0 on npm, pre-1.0.** The API is still moving; see the
7
+ > [roadmap](../../docs/architecture.md#12-adoption-roadmap) for where it stands.
7
8
 
8
9
  ## What it owns
9
10
 
@@ -18,7 +19,13 @@ with row-level security as defense in depth.
18
19
  030). The lease is one conditional upsert, so exactly one contender wins
19
20
  across pooled connections; the claim holds the replayable reply as `jsonb`
20
21
  and the app role DOES get delete on it, because §10 erasure must reach
21
- content.
22
+ content — wire the store into `createMemoryErasure({ turns })` so it does.
23
+ `purgeTurnClaimsBefore` is the claims' time-based retention (spec:
24
+ erasure-reaches-the-claims): it runs as the retention role, like the audit
25
+ sweep, since the app role's delete is scoped and serves erasure only.
26
+ `purgeExpiredLeases` sweeps leases that expired before a cutoff — the
27
+ orphan a crashed holder leaves, since erasure keeps leases (spec:
28
+ close-review-part-two). Every swept column carries an index.
22
29
  - `PostgresAuditLog` — the five audit trails (spec 038), written synchronously
23
30
  and with no buffering wrapper: a buffered sink that loses its buffer on a
24
31
  crash stops writing silently, which is the failure `AuditSinkError` was typed
@@ -35,16 +42,44 @@ with row-level security as defense in depth.
35
42
  deployment not connected as a superuser — FORCE ROW LEVEL SECURITY subjects
36
43
  even the table owner to the predicate.
37
44
  - `migrateSessionStore`, `migrateMemoryStores`, `migrateSpendStore`,
38
- `migrateTurnStore`, `migrateAuditLog`
45
+ `migrateTurnStore`, `migrateRoutineRunStore`, `migrateRoutineStore`,
46
+ `migrateAuditLog` —
39
47
  idempotent migrations; running them is the product's choice, typically at
40
48
  startup.
41
49
 
50
+ - `PostgresRoutineRunStore` — routine runs as metadata (spec:
51
+ postgres-routine-runs): the same fire again, today's count for the ceiling,
52
+ the last delivery's hash for the dedupe, durable across processes. The app
53
+ role gets no delete — a run record is retained like a trail — and
54
+ `purgeRoutineRunsBefore` sweeps by age as the retention role.
55
+ - `PostgresRoutineStore` — the routines themselves (spec: clock-tick), as
56
+ `jsonb` under the shared policy; `list` runs as `alma_scheduler`, the
57
+ role the tick assumes to read every scope, created by the migration.
58
+ Grant it to the login the tick connects as, like the other two roles.
59
+
42
60
  Every operation runs inside a transaction that assumes a dedicated
43
61
  non-superuser role and binds `alma.org` / `alma.uid` as transaction-local
44
62
  settings, which the RLS policies compare against. Application-level scoping is
45
63
  the first layer; this is the second, so a forgotten `WHERE` cannot leak across
46
64
  tenants.
47
65
 
66
+ ## Before the first non-superuser connection
67
+
68
+ `SET LOCAL ROLE alma_app` admits only roles the connection's login is a
69
+ member of, and no migration grants that membership — it cannot know which
70
+ login you connect as. Once, as a role that may grant (the migrations'
71
+ superuser will do):
72
+
73
+ ```ts
74
+ await grantRole(pool, { to: "svc_myproduct" }); // alma_app
75
+ await grantRole(pool, { role: "alma_retention", to: "svc_ops" }); // the sweeps, if a different login
76
+ ```
77
+
78
+ Re-granting is a no-op, so a product may run it at every startup. Without it
79
+ the first least-privilege deployment fails every call with `permission denied
80
+ to set role` — fail-closed, and a surprise the superuser-connected test suites
81
+ cannot see (spec: erasure-reaches-the-claims).
82
+
48
83
  ```ts
49
84
  import { migrateMemoryStores, PostgresEpisodeStore } from "@alma-harness/postgres";
50
85
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { SessionStore, Scope, Msg, LoadOpts, ToolTrafficExpiry, EpisodeStore, CopySurface, EpisodeInput, Episode, EpisodeQuery, EpisodeQueryResult, ErasureSelector, TombstoneResult, ErasureWatermarkStore, ProfileStore, ProfileReadOpts, Profile, FactObservation, ObserveResult, InvalidateResult, SpendStore, SpendKey, SpendTotals, AuditLog, AccessEvent, RoutingEvent, CostEvent, RecallEvent, ContextEvent, TurnStore, LeaseOpts, TurnLease, TurnKey, TurnClaim, CompletedTurn } from '@alma-harness/core';
2
- export { AuditLog, EpisodeStore, ProfileStore, SessionStore, SpendStore, TurnStore } from '@alma-harness/core';
3
- import { Pool } from 'pg';
1
+ import { SessionStore, Scope, Msg, LoadOpts, ToolTrafficExpiry, EpisodeStore, CopySurface, EpisodeInput, Episode, EpisodeQuery, EpisodeQueryResult, ErasureSelector, TombstoneResult, ErasureWatermarkStore, ProfileStore, ProfileReadOpts, Profile, FactObservation, ObserveResult, InvalidateResult, SpendStore, SpendKey, SpendTotals, AuditLog, AccessEvent, RoutingEvent, CostEvent, RecallEvent, ContextEvent, TurnStore, LeaseOpts, TurnLease, TurnKey, TurnClaim, CompletedTurn, RoutineStore, Routine, StoredRoutine, RoutineRunStore, RoutineRun, RoutineRunOutcome } from '@alma-harness/core';
2
+ export { AuditLog, EpisodeStore, ProfileStore, RoutineRunStore, RoutineStore, SessionStore, SpendStore, TurnStore, assertIso8601 } from '@alma-harness/core';
3
+ import { Pool, PoolClient } from 'pg';
4
4
 
5
5
  /**
6
6
  * The RLS binding, shared by every Postgres store — spec 001, spec 011.
@@ -27,6 +27,39 @@ interface ScopedStoreOptions {
27
27
  */
28
28
  role?: string | null;
29
29
  }
30
+ /**
31
+ * One transaction as `role`, on a pooled connection: begin, `set local role`,
32
+ * `fn`, commit. Shared by the scoped stores and the retention sweeps (review
33
+ * of 056–058): the sweeps had copied the shape and dropped the one line that
34
+ * matters on failure. A failed ROLLBACK means the connection may still be
35
+ * inside an aborted transaction; handing it back to the pool would give the
36
+ * next borrower cascading errors, so it is destroyed instead of reused.
37
+ */
38
+ declare function inTransaction<T>(pool: Pool, role: string | null, fn: (client: PoolClient) => Promise<T>,
39
+ /** A per-transaction `statement_timeout`; the scoped path always sets one, the deployment reads may (spec: close-060-064-findings). */
40
+ statementTimeoutMs?: number | null): Promise<T>;
41
+ /**
42
+ * A retention sweep in BATCHES — spec: close-review-part-two. One unbounded
43
+ * `delete … where stamp < cutoff` on a backlog is one long transaction and a
44
+ * WAL burst, and under a `statement_timeout` fails with nothing freed. Each
45
+ * batch of `ctid`s is its own transaction as `role`; the count is summed.
46
+ * Table and column are module constants, validated as identifiers anyway.
47
+ */
48
+ declare function sweepBefore(pool: Pool, opts: {
49
+ role: string;
50
+ table: string;
51
+ column: string;
52
+ before: string;
53
+ batch?: number;
54
+ }): Promise<number>;
55
+ /** The three sweeps' one preamble (spec: close-060-064-findings): the role, the cutoff, the batch. */
56
+ declare function purgeBefore(pool: Pool, opts: {
57
+ table: string;
58
+ column: string;
59
+ before: string;
60
+ retentionRole?: string;
61
+ batch?: number;
62
+ }): Promise<number>;
30
63
 
31
64
  type PostgresSessionStoreOptions = ScopedStoreOptions;
32
65
  /**
@@ -99,6 +132,13 @@ declare class PostgresErasureWatermarks implements ErasureWatermarkStore {
99
132
  * them: defense-in-depth must not depend on a manual step.
100
133
  */
101
134
  declare const DEFAULT_RLS_ROLE = "alma_app";
135
+ /**
136
+ * Role the retention sweeps assume — spec 039 review. It exists because the
137
+ * app role deliberately cannot delete an audit row, and a sweep therefore
138
+ * cannot run as it; the claims' sweep uses it for the same separation of
139
+ * actors (spec: erasure-reaches-the-claims).
140
+ */
141
+ declare const DEFAULT_RETENTION_ROLE = "alma_retention";
102
142
  /**
103
143
  * Role and table names are interpolated into DDL (Postgres cannot parameterize
104
144
  * identifiers), so they are validated strictly — never raw.
@@ -116,8 +156,34 @@ declare function assertRoleIdentifier(role: string): void;
116
156
  * hardening lands on every table at once (review finding).
117
157
  */
118
158
  declare function rlsPolicySql(table: string, keys?: readonly ("org" | "uid")[]): string;
159
+ /**
160
+ * The retention actor's policies on one table — spec 039, shared since the
161
+ * claims got them too (spec: erasure-reaches-the-claims). TWO policies: a
162
+ * DELETE with a WHERE must SCAN the rows, and the scope-keyed policy is FOR
163
+ * ALL, which governs SELECT too. `name` keeps each table's existing policy
164
+ * names, so a migration on a populated database drops and recreates its own.
165
+ */
166
+ declare function retentionPolicySql(table: string, retentionRole: string, name: string): string;
119
167
  /** Creates the RLS role if it does not exist, tolerating a concurrent race. */
120
168
  declare function roleBootstrapSql(role: string): string;
169
+ /**
170
+ * Membership for the connection user — spec: erasure-reaches-the-claims.
171
+ * `SET LOCAL ROLE` admits only roles the SESSION user is a member of, and no
172
+ * migration can know which login the product connects as. The suites never
173
+ * noticed: they connect as a superuser, which may assume any role, so the
174
+ * first least-privilege deployment failed on every call. Re-granting is a
175
+ * no-op, so this is safe to run at every startup.
176
+ */
177
+ declare function grantRoleSql(role: string, to: string): string;
178
+ /**
179
+ * Run by a role that may grant (typically the migrations' superuser), once
180
+ * per login. Deliberately NOT part of any migration: the retention role must
181
+ * not reach the app's login by accident — the sweep is a different actor.
182
+ */
183
+ declare function grantRole(pool: Pool, opts: {
184
+ role?: string;
185
+ to: string;
186
+ }): Promise<void>;
121
187
  /**
122
188
  * DECISION (spec 001): SQL lives as a TS constant, not a .sql file — packages
123
189
  * ship TypeScript source in Phase 0/1, and a migration runner would be
@@ -245,15 +311,6 @@ declare const AUDIT_ROUTING_TABLE = "alma_audit_routing";
245
311
  declare const AUDIT_COST_TABLE = "alma_audit_cost";
246
312
  declare const AUDIT_RECALL_TABLE = "alma_audit_recall";
247
313
  declare const AUDIT_CONTEXT_TABLE = "alma_audit_context";
248
- /**
249
- * Role the retention sweep assumes — spec 039 review.
250
- *
251
- * It exists because the app role deliberately cannot delete (spec 038) and the
252
- * sweep therefore cannot run as it. Created and granted by the migration, for
253
- * the reason `roleBootstrapSql` exists at all: defence in depth must not depend
254
- * on a manual step (spec 001).
255
- */
256
- declare const DEFAULT_RETENTION_ROLE = "alma_retention";
257
314
  declare const AUDIT_TABLES: readonly ["alma_audit_access", "alma_audit_routing", "alma_audit_cost", "alma_audit_recall", "alma_audit_context"];
258
315
  /**
259
316
  * DECISION (spec 038): the primary key is a `uuid` defaulted by
@@ -323,6 +380,7 @@ type AuditTable = (typeof AUDIT_TABLES)[number];
323
380
  */
324
381
  declare function purgeAuditBefore(pool: Pool, windows: Partial<Record<AuditTable, string>>, opts?: {
325
382
  retentionRole?: string;
383
+ batch?: number;
326
384
  }): Promise<Partial<Record<AuditTable, number>>>;
327
385
 
328
386
  type PostgresTurnStoreOptions = ScopedStoreOptions;
@@ -372,6 +430,85 @@ declare class PostgresTurnStore implements TurnStore {
372
430
  erase(scope: Scope, sessionId?: string): Promise<void>;
373
431
  }
374
432
 
433
+ interface PostgresRoutineStoreOptions extends ScopedStoreOptions {
434
+ /** The role `list` assumes — the deployment actor. Default `alma_scheduler`. */
435
+ schedulerRole?: string;
436
+ }
437
+ /**
438
+ * Reference `RoutineStore` adapter — spec: clock-tick. Scoped writes and
439
+ * reads as the app role, like every store here; `list` as the scheduler
440
+ * role, the one cross-scope read, through the same transaction helper the
441
+ * sweeps use.
442
+ */
443
+ declare class PostgresRoutineStore implements RoutineStore {
444
+ #private;
445
+ constructor(pool: Pool, opts?: PostgresRoutineStoreOptions);
446
+ register(routine: Routine & {
447
+ registeredAt?: string;
448
+ }): Promise<void>;
449
+ cancel(scope: Scope, routineId: string): Promise<void>;
450
+ get(scope: Scope, routineId: string): Promise<StoredRoutine | null>;
451
+ list(): Promise<StoredRoutine[]>;
452
+ }
453
+
454
+ /**
455
+ * Schema and migration for the routine store — spec: clock-tick. One
456
+ * scope-stamped table; the routine as `jsonb`, the registration stamp as a
457
+ * column. The app role reads and writes its own scope's rows; the tick
458
+ * lists EVERY scope, so it runs as a role of its own with a policy that
459
+ * admits all rows — the retention role's shape, for the same reason: FORCE
460
+ * ROW LEVEL SECURITY binds the owner too.
461
+ */
462
+ declare const ROUTINES_TABLE = "alma_routines";
463
+ /** Role the tick assumes to list every scope's routines — spec: clock-tick. */
464
+ declare const DEFAULT_SCHEDULER_ROLE = "alma_scheduler";
465
+ declare function routineStoreMigrationSql(role?: string, schedulerRole?: string): string;
466
+ /** Idempotent; running it is the product's choice (typically at startup). */
467
+ declare function migrateRoutineStore(pool: Pool, opts?: {
468
+ role?: string;
469
+ schedulerRole?: string;
470
+ }): Promise<void>;
471
+
472
+ type PostgresRoutineRunStoreOptions = ScopedStoreOptions;
473
+ /**
474
+ * Reference `RoutineRunStore` adapter — spec: postgres-routine-runs. Same RLS
475
+ * discipline as every other store. The three reads the runner makes — the
476
+ * same fire again, today's count, the last run of an outcome — are one key
477
+ * lookup and one indexed range scan; `since` is compared as `timestamptz`,
478
+ * so a bound with an offset filters the same instants the reference does.
479
+ */
480
+ declare class PostgresRoutineRunStore implements RoutineRunStore {
481
+ #private;
482
+ constructor(pool: Pool, opts?: PostgresRoutineRunStoreOptions);
483
+ record(run: RoutineRun): Promise<void>;
484
+ get(scope: Scope, routineId: string, runId: string): Promise<RoutineRun | null>;
485
+ list(scope: Scope, routineId: string, opts?: {
486
+ since?: string;
487
+ outcome?: RoutineRunOutcome;
488
+ limit?: number;
489
+ }): Promise<RoutineRun[]>;
490
+ }
491
+
492
+ /**
493
+ * Schema and migration for routine runs — spec: postgres-routine-runs. One
494
+ * scope-stamped table under the shared policy generator, METADATA only:
495
+ * never the text delivered (the hash is what a store may keep, spec:
496
+ * routine-runner), so the app role gets no delete — a run record is retained
497
+ * like a trail, and swept by age as the retention actor.
498
+ */
499
+ declare const ROUTINE_RUNS_TABLE = "alma_routine_runs";
500
+ declare function routineRunStoreMigrationSql(role?: string, retentionRole?: string): string;
501
+ /** Idempotent; running it is the product's choice (typically at startup). */
502
+ declare function migrateRoutineRunStore(pool: Pool, opts?: {
503
+ role?: string;
504
+ retentionRole?: string;
505
+ }): Promise<void>;
506
+ /** Deletes run records started before the cutoff, as the retention role — the shape of every sweep here. */
507
+ declare function purgeRoutineRunsBefore(pool: Pool, before: string, opts?: {
508
+ retentionRole?: string;
509
+ batch?: number;
510
+ }): Promise<number>;
511
+
375
512
  /**
376
513
  * Schema and migration for the turn store — spec 030.
377
514
  *
@@ -400,12 +537,44 @@ declare const TURN_CLAIMS_TABLE = "alma_turn_claims";
400
537
  *
401
538
  * The grant carries `delete`: unlike spend counters, these rows hold content
402
539
  * (the reply verbatim) and §10 erasure must be able to remove them.
540
+ *
541
+ * DECISION (spec: erasure-reaches-the-claims): time-based retention of the
542
+ * claims runs as the retention role, never as the app role — the two actors
543
+ * spec 039 separated for the audit tables. The app role's delete is scoped
544
+ * by RLS and serves erasure; the sweep crosses scopes and needs its own
545
+ * policies, `for select` AND `for delete`, since a DELETE with a WHERE scans.
403
546
  */
404
- declare function turnStoreMigrationSql(role?: string): string;
547
+ declare function turnStoreMigrationSql(role?: string, retentionRole?: string): string;
405
548
  /** Idempotent; running it is the product's choice (typically at startup). */
406
549
  declare function migrateTurnStore(pool: Pool, opts?: {
407
550
  role?: string;
551
+ retentionRole?: string;
408
552
  }): Promise<void>;
553
+ /**
554
+ * Deletes claims created before the cutoff — spec: erasure-reaches-the-claims.
555
+ * The largest copy of content in this schema had no retention at all: one
556
+ * reply per delivered message, forever.
557
+ *
558
+ * Same shape as `purgeAuditBefore`, for the same reason: a plain function
559
+ * taking the pool, assuming the retention role for one transaction, crossing
560
+ * scopes by nature and never through the RLS-bound `inScope` path. A claim
561
+ * still in flight at that age belongs to a crashed turn, which spec 030
562
+ * already makes re-runnable, so it goes too. Leases are untouched: they
563
+ * expire on their own clock.
564
+ */
565
+ declare function purgeTurnClaimsBefore(pool: Pool, before: string, opts?: {
566
+ retentionRole?: string;
567
+ batch?: number;
568
+ }): Promise<number>;
569
+ /**
570
+ * Deletes leases that expired before the cutoff — spec: close-060-064-findings.
571
+ * Erasure leaves leases alone (spec: close-review-part-two), and a holder that
572
+ * crashed never releases; this bounds the orphans, as the retention role.
573
+ */
574
+ declare function purgeExpiredLeases(pool: Pool, before: string, opts?: {
575
+ retentionRole?: string;
576
+ batch?: number;
577
+ }): Promise<number>;
409
578
 
410
579
  /**
411
580
  * Schema and migration for the spend store — spec: spend-store.
@@ -437,4 +606,4 @@ declare function migrateSpendStore(pool: Pool, opts?: {
437
606
  role?: string;
438
607
  }): Promise<void>;
439
608
 
440
- export { AUDIT_ACCESS_TABLE, AUDIT_CONTEXT_TABLE, AUDIT_COST_TABLE, AUDIT_RECALL_TABLE, AUDIT_ROUTING_TABLE, AUDIT_TABLES, type AuditTable, DEFAULT_RETENTION_ROLE, DEFAULT_RLS_ROLE, EPISODES_TABLE, FACTS_TABLE, PostgresAuditLog, type PostgresAuditLogOptions, PostgresEpisodeStore, PostgresErasureWatermarks, type PostgresMemoryStoreOptions, PostgresProfileStore, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertRoleIdentifier, auditLogMigrationSql, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, rlsPolicySql, roleBootstrapSql, sessionStoreMigrationSql, spendStoreMigrationSql, turnStoreMigrationSql };
609
+ export { AUDIT_ACCESS_TABLE, AUDIT_CONTEXT_TABLE, AUDIT_COST_TABLE, AUDIT_RECALL_TABLE, AUDIT_ROUTING_TABLE, AUDIT_TABLES, type AuditTable, DEFAULT_RETENTION_ROLE, DEFAULT_RLS_ROLE, DEFAULT_SCHEDULER_ROLE, EPISODES_TABLE, FACTS_TABLE, PostgresAuditLog, type PostgresAuditLogOptions, PostgresEpisodeStore, PostgresErasureWatermarks, type PostgresMemoryStoreOptions, PostgresProfileStore, PostgresRoutineRunStore, type PostgresRoutineRunStoreOptions, PostgresRoutineStore, type PostgresRoutineStoreOptions, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, ROUTINES_TABLE, ROUTINE_RUNS_TABLE, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertRoleIdentifier, auditLogMigrationSql, grantRole, grantRoleSql, inTransaction, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateRoutineRunStore, migrateRoutineStore, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, purgeBefore, purgeExpiredLeases, purgeRoutineRunsBefore, purgeTurnClaimsBefore, retentionPolicySql, rlsPolicySql, roleBootstrapSql, routineRunStoreMigrationSql, routineStoreMigrationSql, sessionStoreMigrationSql, spendStoreMigrationSql, sweepBefore, turnStoreMigrationSql };