@alma-harness/postgres 0.1.0 → 0.2.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,10 @@ 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.
22
26
  - `PostgresAuditLog` — the five audit trails (spec 038), written synchronously
23
27
  and with no buffering wrapper: a buffered sink that loses its buffer on a
24
28
  crash stops writing silently, which is the failure `AuditSinkError` was typed
@@ -35,16 +39,39 @@ with row-level security as defense in depth.
35
39
  deployment not connected as a superuser — FORCE ROW LEVEL SECURITY subjects
36
40
  even the table owner to the predicate.
37
41
  - `migrateSessionStore`, `migrateMemoryStores`, `migrateSpendStore`,
38
- `migrateTurnStore`, `migrateAuditLog` —
42
+ `migrateTurnStore`, `migrateRoutineRunStore`, `migrateAuditLog` —
39
43
  idempotent migrations; running them is the product's choice, typically at
40
44
  startup.
41
45
 
46
+ - `PostgresRoutineRunStore` — routine runs as metadata (spec:
47
+ postgres-routine-runs): the same fire again, today's count for the ceiling,
48
+ the last delivery's hash for the dedupe, durable across processes. The app
49
+ role gets no delete — a run record is retained like a trail — and
50
+ `purgeRoutineRunsBefore` sweeps by age as the retention role.
51
+
42
52
  Every operation runs inside a transaction that assumes a dedicated
43
53
  non-superuser role and binds `alma.org` / `alma.uid` as transaction-local
44
54
  settings, which the RLS policies compare against. Application-level scoping is
45
55
  the first layer; this is the second, so a forgotten `WHERE` cannot leak across
46
56
  tenants.
47
57
 
58
+ ## Before the first non-superuser connection
59
+
60
+ `SET LOCAL ROLE alma_app` admits only roles the connection's login is a
61
+ member of, and no migration grants that membership — it cannot know which
62
+ login you connect as. Once, as a role that may grant (the migrations'
63
+ superuser will do):
64
+
65
+ ```ts
66
+ await grantRole(pool, { to: "svc_myproduct" }); // alma_app
67
+ await grantRole(pool, { role: "alma_retention", to: "svc_ops" }); // the sweeps, if a different login
68
+ ```
69
+
70
+ Re-granting is a no-op, so a product may run it at every startup. Without it
71
+ the first least-privilege deployment fails every call with `permission denied
72
+ to set role` — fail-closed, and a surprise the superuser-connected test suites
73
+ cannot see (spec: erasure-reaches-the-claims).
74
+
48
75
  ```ts
49
76
  import { migrateMemoryStores, PostgresEpisodeStore } from "@alma-harness/postgres";
50
77
 
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, RoutineRunStore, RoutineRun, RoutineRunOutcome } from '@alma-harness/core';
2
+ export { AuditLog, EpisodeStore, ProfileStore, RoutineRunStore, SessionStore, SpendStore, TurnStore } 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,29 @@ 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>): Promise<T>;
39
+ /**
40
+ * A retention sweep in BATCHES — spec: close-review-part-two. One unbounded
41
+ * `delete … where stamp < cutoff` on a backlog is one long transaction and a
42
+ * WAL burst, and under a `statement_timeout` fails with nothing freed. Each
43
+ * batch of `ctid`s is its own transaction as `role`; the count is summed.
44
+ * Table and column are module constants, validated as identifiers anyway.
45
+ */
46
+ declare function sweepBefore(pool: Pool, opts: {
47
+ role: string;
48
+ table: string;
49
+ column: string;
50
+ before: string;
51
+ batch?: number;
52
+ }): Promise<number>;
30
53
 
31
54
  type PostgresSessionStoreOptions = ScopedStoreOptions;
32
55
  /**
@@ -99,6 +122,13 @@ declare class PostgresErasureWatermarks implements ErasureWatermarkStore {
99
122
  * them: defense-in-depth must not depend on a manual step.
100
123
  */
101
124
  declare const DEFAULT_RLS_ROLE = "alma_app";
125
+ /**
126
+ * Role the retention sweeps assume — spec 039 review. It exists because the
127
+ * app role deliberately cannot delete an audit row, and a sweep therefore
128
+ * cannot run as it; the claims' sweep uses it for the same separation of
129
+ * actors (spec: erasure-reaches-the-claims).
130
+ */
131
+ declare const DEFAULT_RETENTION_ROLE = "alma_retention";
102
132
  /**
103
133
  * Role and table names are interpolated into DDL (Postgres cannot parameterize
104
134
  * identifiers), so they are validated strictly — never raw.
@@ -116,8 +146,36 @@ declare function assertRoleIdentifier(role: string): void;
116
146
  * hardening lands on every table at once (review finding).
117
147
  */
118
148
  declare function rlsPolicySql(table: string, keys?: readonly ("org" | "uid")[]): string;
149
+ /**
150
+ * The retention actor's policies on one table — spec 039, shared since the
151
+ * claims got them too (spec: erasure-reaches-the-claims). TWO policies: a
152
+ * DELETE with a WHERE must SCAN the rows, and the scope-keyed policy is FOR
153
+ * ALL, which governs SELECT too. `name` keeps each table's existing policy
154
+ * names, so a migration on a populated database drops and recreates its own.
155
+ */
156
+ declare function retentionPolicySql(table: string, retentionRole: string, name: string): string;
157
+ /** Rejects an unparseable ISO 8601 timestamp with the value in the message, before it reaches SQL. */
158
+ declare function assertIso8601(at: string, label?: string): string;
119
159
  /** Creates the RLS role if it does not exist, tolerating a concurrent race. */
120
160
  declare function roleBootstrapSql(role: string): string;
161
+ /**
162
+ * Membership for the connection user — spec: erasure-reaches-the-claims.
163
+ * `SET LOCAL ROLE` admits only roles the SESSION user is a member of, and no
164
+ * migration can know which login the product connects as. The suites never
165
+ * noticed: they connect as a superuser, which may assume any role, so the
166
+ * first least-privilege deployment failed on every call. Re-granting is a
167
+ * no-op, so this is safe to run at every startup.
168
+ */
169
+ declare function grantRoleSql(role: string, to: string): string;
170
+ /**
171
+ * Run by a role that may grant (typically the migrations' superuser), once
172
+ * per login. Deliberately NOT part of any migration: the retention role must
173
+ * not reach the app's login by accident — the sweep is a different actor.
174
+ */
175
+ declare function grantRole(pool: Pool, opts: {
176
+ role?: string;
177
+ to: string;
178
+ }): Promise<void>;
121
179
  /**
122
180
  * DECISION (spec 001): SQL lives as a TS constant, not a .sql file — packages
123
181
  * ship TypeScript source in Phase 0/1, and a migration runner would be
@@ -245,15 +303,6 @@ declare const AUDIT_ROUTING_TABLE = "alma_audit_routing";
245
303
  declare const AUDIT_COST_TABLE = "alma_audit_cost";
246
304
  declare const AUDIT_RECALL_TABLE = "alma_audit_recall";
247
305
  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
306
  declare const AUDIT_TABLES: readonly ["alma_audit_access", "alma_audit_routing", "alma_audit_cost", "alma_audit_recall", "alma_audit_context"];
258
307
  /**
259
308
  * DECISION (spec 038): the primary key is a `uuid` defaulted by
@@ -323,6 +372,7 @@ type AuditTable = (typeof AUDIT_TABLES)[number];
323
372
  */
324
373
  declare function purgeAuditBefore(pool: Pool, windows: Partial<Record<AuditTable, string>>, opts?: {
325
374
  retentionRole?: string;
375
+ batch?: number;
326
376
  }): Promise<Partial<Record<AuditTable, number>>>;
327
377
 
328
378
  type PostgresTurnStoreOptions = ScopedStoreOptions;
@@ -372,6 +422,46 @@ declare class PostgresTurnStore implements TurnStore {
372
422
  erase(scope: Scope, sessionId?: string): Promise<void>;
373
423
  }
374
424
 
425
+ type PostgresRoutineRunStoreOptions = ScopedStoreOptions;
426
+ /**
427
+ * Reference `RoutineRunStore` adapter — spec: postgres-routine-runs. Same RLS
428
+ * discipline as every other store. The three reads the runner makes — the
429
+ * same fire again, today's count, the last run of an outcome — are one key
430
+ * lookup and one indexed range scan; `since` is compared as `timestamptz`,
431
+ * so a bound with an offset filters the same instants the reference does.
432
+ */
433
+ declare class PostgresRoutineRunStore implements RoutineRunStore {
434
+ #private;
435
+ constructor(pool: Pool, opts?: PostgresRoutineRunStoreOptions);
436
+ record(run: RoutineRun): Promise<void>;
437
+ get(scope: Scope, routineId: string, runId: string): Promise<RoutineRun | null>;
438
+ list(scope: Scope, routineId: string, opts?: {
439
+ since?: string;
440
+ outcome?: RoutineRunOutcome;
441
+ limit?: number;
442
+ }): Promise<RoutineRun[]>;
443
+ }
444
+
445
+ /**
446
+ * Schema and migration for routine runs — spec: postgres-routine-runs. One
447
+ * scope-stamped table under the shared policy generator, METADATA only:
448
+ * never the text delivered (the hash is what a store may keep, spec:
449
+ * routine-runner), so the app role gets no delete — a run record is retained
450
+ * like a trail, and swept by age as the retention actor.
451
+ */
452
+ declare const ROUTINE_RUNS_TABLE = "alma_routine_runs";
453
+ declare function routineRunStoreMigrationSql(role?: string, retentionRole?: string): string;
454
+ /** Idempotent; running it is the product's choice (typically at startup). */
455
+ declare function migrateRoutineRunStore(pool: Pool, opts?: {
456
+ role?: string;
457
+ retentionRole?: string;
458
+ }): Promise<void>;
459
+ /** Deletes run records started before the cutoff, as the retention role — the shape of every sweep here. */
460
+ declare function purgeRoutineRunsBefore(pool: Pool, before: string, opts?: {
461
+ retentionRole?: string;
462
+ batch?: number;
463
+ }): Promise<number>;
464
+
375
465
  /**
376
466
  * Schema and migration for the turn store — spec 030.
377
467
  *
@@ -400,12 +490,35 @@ declare const TURN_CLAIMS_TABLE = "alma_turn_claims";
400
490
  *
401
491
  * The grant carries `delete`: unlike spend counters, these rows hold content
402
492
  * (the reply verbatim) and §10 erasure must be able to remove them.
493
+ *
494
+ * DECISION (spec: erasure-reaches-the-claims): time-based retention of the
495
+ * claims runs as the retention role, never as the app role — the two actors
496
+ * spec 039 separated for the audit tables. The app role's delete is scoped
497
+ * by RLS and serves erasure; the sweep crosses scopes and needs its own
498
+ * policies, `for select` AND `for delete`, since a DELETE with a WHERE scans.
403
499
  */
404
- declare function turnStoreMigrationSql(role?: string): string;
500
+ declare function turnStoreMigrationSql(role?: string, retentionRole?: string): string;
405
501
  /** Idempotent; running it is the product's choice (typically at startup). */
406
502
  declare function migrateTurnStore(pool: Pool, opts?: {
407
503
  role?: string;
504
+ retentionRole?: string;
408
505
  }): Promise<void>;
506
+ /**
507
+ * Deletes claims created before the cutoff — spec: erasure-reaches-the-claims.
508
+ * The largest copy of content in this schema had no retention at all: one
509
+ * reply per delivered message, forever.
510
+ *
511
+ * Same shape as `purgeAuditBefore`, for the same reason: a plain function
512
+ * taking the pool, assuming the retention role for one transaction, crossing
513
+ * scopes by nature and never through the RLS-bound `inScope` path. A claim
514
+ * still in flight at that age belongs to a crashed turn, which spec 030
515
+ * already makes re-runnable, so it goes too. Leases are untouched: they
516
+ * expire on their own clock.
517
+ */
518
+ declare function purgeTurnClaimsBefore(pool: Pool, before: string, opts?: {
519
+ retentionRole?: string;
520
+ batch?: number;
521
+ }): Promise<number>;
409
522
 
410
523
  /**
411
524
  * Schema and migration for the spend store — spec: spend-store.
@@ -437,4 +550,4 @@ declare function migrateSpendStore(pool: Pool, opts?: {
437
550
  role?: string;
438
551
  }): Promise<void>;
439
552
 
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 };
553
+ 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, PostgresRoutineRunStore, type PostgresRoutineRunStoreOptions, PostgresSessionStore, type PostgresSessionStoreOptions, PostgresSpendStore, type PostgresSpendStoreOptions, PostgresTurnStore, type PostgresTurnStoreOptions, ROUTINE_RUNS_TABLE, SCOPE_STATE_TABLE, SPEND_SESSIONS_TABLE, SPEND_TENANT_DAYS_TABLE, type ScopedStoreOptions, TURN_CLAIMS_TABLE, TURN_LEASES_TABLE, assertIso8601, assertRoleIdentifier, auditLogMigrationSql, grantRole, grantRoleSql, inTransaction, memoryStoreMigrationSql, migrateAuditLog, migrateMemoryStores, migrateRoutineRunStore, migrateSessionStore, migrateSpendStore, migrateTurnStore, purgeAuditBefore, purgeRoutineRunsBefore, purgeTurnClaimsBefore, retentionPolicySql, rlsPolicySql, roleBootstrapSql, routineRunStoreMigrationSql, sessionStoreMigrationSql, spendStoreMigrationSql, sweepBefore, turnStoreMigrationSql };