Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
import { randomUUID } from "node:crypto" ;
import { Pool } from "pg" ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
import { afterAll , afterEach , beforeAll , describe , expect , it } from "vitest" ;
2026-04-26 12:50:07 +01:00
import { newDb } from "pg-mem" ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
import { type HeimdallConfig } from "../src/config.js" ;
import { createStore } from "../src/store/index.js" ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
import {
createPostgresStore ,
PostgresStore ,
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
checkSchemaRequirements ,
HEIMDALL_SCHEMA_REQUIREMENTS ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
predicateConditions ,
2026-09-30 21:28:50 +02:00
printedDeclaredPredicates ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
samePredicate ,
STORE_TABLE_PRIVILEGES ,
} from "../src/store/postgres.js" ;
import { REQUIRED_COLUMNS , REQUIRED_KEYS , REQUIRED_TABLES } from "../src/store/schema.js" ;
2026-04-26 12:50:07 +01:00
describe ( "PostgresStore" , ( ) = > {
it ( "round-trips core auth records through postgres" , async ( ) = > {
const db = newDb ( ) ;
const adapter = db . adapters . createPg ( ) ;
const pool = new adapter . Pool ( ) ;
const store = new PostgresStore ( pool ) ;
await store . ensureSchema ( ) ;
const now = "2026-04-26T12:00:00.000Z" ;
const account = await store . createAccount ( {
createdAt : now ,
lastSeenAt : now ,
displayName : "Meta" ,
primaryEmail : "meta@gamecult.org" ,
} ) ;
await store . upsertLinkedIdentity ( {
accountId : account.id ,
provider : "discord" ,
providerUserId : "discord-user-123" ,
username : "meta" ,
displayName : "Meta" ,
primaryEmail : "meta@gamecult.org" ,
accessTokenEncrypted : "encrypted-access-token" ,
refreshTokenEncrypted : "encrypted-refresh-token" ,
tokenExpiresAt : "2026-04-26T13:00:00.000Z" ,
scopes : [ "identify" , "email" ] ,
profileJson : { id : "discord-user-123" , username : "meta" } ,
createdAt : now ,
updatedAt : now ,
} ) ;
const found = await store . findAccountByLinkedIdentity ( "discord" , "discord-user-123" ) ;
expect ( found ) . toEqual (
expect . objectContaining ( {
id : account.id ,
displayName : "Meta" ,
primaryEmail : "meta@gamecult.org" ,
} )
) ;
const linkedIdentities = await store . listLinkedIdentitiesForAccount ( account . id ) ;
expect ( linkedIdentities ) . toEqual ( [
{
provider : "discord" ,
providerUserId : "discord-user-123" ,
username : "meta" ,
displayName : "Meta" ,
} ,
] ) ;
2026-04-26 16:23:29 +01:00
const storedIdentity = await store . findStoredLinkedIdentity ( "discord" , "discord-user-123" ) ;
expect ( storedIdentity ) . toEqual (
expect . objectContaining ( {
provider : "discord" ,
accessTokenEncrypted : "encrypted-access-token" ,
refreshTokenEncrypted : "encrypted-refresh-token" ,
} )
) ;
2026-04-26 12:50:07 +01:00
await store . createCapabilityGrant ( {
accountId : account.id ,
scopeType : "app" ,
scopeId : "repixelizer" ,
capability : "app_access" ,
source : "manual" ,
status : "active" ,
note : "Test grant" ,
createdAt : now ,
updatedAt : now ,
} ) ;
const grants = await store . listActiveGrants ( account . id , "repixelizer" , "2026-04-26T12:01:00.000Z" ) ;
expect ( grants ) . toEqual ( [
expect . objectContaining ( {
capability : "app_access" ,
scopeType : "app" ,
scopeId : "repixelizer" ,
} ) ,
] ) ;
await store . createSession ( {
id : "session-123" ,
accountId : account.id ,
appSlug : "repixelizer" ,
createdAt : now ,
lastSeenAt : now ,
expiresAt : "2026-04-26T13:00:00.000Z" ,
accessRevision : 1 ,
claimsJson : {
iss : "https://heimdall.gamecult.org" ,
aud : "repixelizer" ,
} ,
} ) ;
await store . upsertEntitlementSnapshot ( {
accountId : account.id ,
provider : "discord" ,
scope : "repixelizer:discord_role_access:gamecult-guild" ,
evaluatedAt : now ,
isAllowed : true ,
reasonCode : "matched_role" ,
reasonDetail : "Matched test role" ,
rawSummaryJson : { matchedRoles : [ "role-repixelizer" ] } ,
} ) ;
await store . createAuditEvent ( {
accountId : account.id ,
sessionId : "session-123" ,
appSlug : "repixelizer" ,
eventType : "oauth_callback_succeeded" ,
eventPayloadJson : { provider : "discord" } ,
createdAt : now ,
} ) ;
2026-04-26 15:23:04 +01:00
const completion = await store . createAuthCompletion ( {
appSlug : "repixelizer" ,
provider : "discord" ,
mode : "sign_in" ,
accountId : account.id ,
sessionId : "session-123" ,
returnTo : "https://repixelizer.gamecult.org/app/" ,
createdAt : now ,
expiresAt : "2026-04-26T12:05:00.000Z" ,
payloadJson : {
status : "success" ,
provider : "discord" ,
appSlug : "repixelizer" ,
} ,
} ) ;
const consumed = await store . consumeAuthCompletion ( "repixelizer" , completion . code , "2026-04-26T12:01:00.000Z" ) ;
expect ( consumed ) . toEqual (
expect . objectContaining ( {
code : completion.code ,
appSlug : "repixelizer" ,
consumedAt : "2026-04-26T12:01:00.000Z" ,
} )
) ;
const consumedAgain = await store . consumeAuthCompletion ( "repixelizer" , completion . code , "2026-04-26T12:02:00.000Z" ) ;
expect ( consumedAgain ) . toBeNull ( ) ;
2026-08-22 00:44:57 +02:00
const attempt = await store . createAuthAttempt ( {
handle : "attempt-123" ,
appSlug : "ghostlight" ,
provider : "discord" ,
mode : "sign_in" ,
returnTo : "https://yggdrasil.gamecult.org/ghostlight/" ,
createdAt : now ,
expiresAt : "2026-04-26T12:05:00.000Z" ,
} ) ;
expect ( attempt . status ) . toBe ( "pending" ) ;
expect ( ( await store . updateAuthAttempt ( "ghostlight" , attempt . handle , {
status : "completed" ,
at : "2026-04-26T12:01:00.000Z" ,
} ) ) ? . status ) . toBe ( "completed" ) ;
expect ( ( await store . updateAuthAttempt ( "ghostlight" , attempt . handle , {
status : "pending" ,
at : "2026-04-26T12:02:00.000Z" ,
} ) ) ? . status ) . toBe ( "completed" ) ;
const privateReceipt = await store . createPrivateCommandReceipt ( {
appSlug : "ghostlight" ,
idempotencyKey : "idem-123" ,
requestFingerprint : "fingerprint-123" ,
status : "accepted" ,
contentSchema : "heimdall.auth_begin_receipt.v1" ,
envelopeBase64 : "encrypted-messagepack" ,
createdAt : now ,
expiresAt : "2026-04-26T12:01:00.000Z" ,
} ) ;
expect ( await store . findPrivateCommandReceipt ( "ghostlight" , "idem-123" ) ) . toEqual ( privateReceipt ) ;
await expect ( store . createPrivateCommandReceipt ( {
. . . privateReceipt ,
requestFingerprint : "different-command" ,
} ) ) . rejects . toThrow ( "Idempotency key was reused" ) ;
2026-04-26 12:50:07 +01:00
await store . close ( ) ;
} ) ;
} ) ;
2026-09-30 15:30:15 +02:00
describe ( "createPostgresStore" , ( ) = > {
// pg emits an idle client's connection loss on the pool. With no listener
// that event kills the process, and Node prints the pg Client with its
// connection parameters.
it ( "survives an idle connection's loss and says so by code alone" , async ( ) = > {
const store = createPostgresStore ( "postgres://heimdall:CANARYpw@127.0.0.1:1/heimdall" ) ;
const pool = ( store as unknown as { pool : { emit ( event : string , . . . args : unknown [ ] ) : boolean } } ) . pool ;
const logged : string [ ] = [ ] ;
const originalError = console . error ;
console . error = ( . . . args : unknown [ ] ) = > void logged . push ( args . map ( String ) . join ( " " ) ) ;
try {
const terminated = Object . assign ( new Error ( "terminating connection due to administrator command CANARY" ) , {
code : "57P01" ,
client : { connectionParameters : { password : "CANARYpw" } } ,
} ) ;
expect ( ( ) = > pool . emit ( "error" , terminated , terminated . client ) ) . not . toThrow ( ) ;
} finally {
console . error = originalError ;
await store . close ( ) ;
}
expect ( logged ) . toEqual ( [
"Heimdall lost an idle Postgres connection (57P01); the pool reconnects on the next query." ,
] ) ;
} ) ;
} ) ;
2026-09-30 15:49:23 +02:00
describe ( "PostgresStore.checkSchema" , ( ) = > {
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "derives every table, typed column and unique key from the schema" , ( ) = > {
expect ( REQUIRED_TABLES ) . toEqual ( [
"accounts" ,
"linked_identities" ,
"sessions" ,
"auth_attempts" ,
"private_command_receipts" ,
"auth_completions" ,
"capability_grants" ,
"entitlement_snapshots" ,
"audit_events" ,
] ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
expect ( REQUIRED_COLUMNS ) . toHaveLength ( 85 ) ;
expect ( REQUIRED_COLUMNS ) . toEqual (
expect . arrayContaining ( [
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
{ table : "auth_completions" , column : "code" , type : "TEXT" , nullable : false } ,
{ table : "sessions" , column : "access_revision" , type : "INTEGER" , nullable : false } ,
{ table : "linked_identities" , column : "profile_json" , type : "JSONB" , nullable : false } ,
{ table : "linked_identities" , column : "username" , type : "TEXT" , nullable : true } ,
{ table : "entitlement_snapshots" , column : "is_allowed" , type : "BOOLEAN" , nullable : false } ,
{ table : "auth_completions" , column : "attempt_id" , type : "TEXT" , nullable : true } ,
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
] )
) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
expect ( REQUIRED_KEYS ) . toHaveLength ( 12 ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
expect ( REQUIRED_KEYS ) . toEqual (
expect . arrayContaining ( [
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
{ table : "linked_identities" , columns : "provider,provider_user_id" , predicate : "" } ,
{ table : "private_command_receipts" , columns : "app_slug,idempotency_key" , predicate : "" } ,
{ table : "entitlement_snapshots" , columns : "account_id,provider,scope" , predicate : "" } ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
{ table : "auth_completions" , columns : "app_slug,attempt_id" , predicate : "attempt_id IS NOT NULL AND consumed_at IS NULL" } ,
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
] )
) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
// Soul pass 6: stripping parentheses and case merged predicates of
// different meaning. These are Postgres's own printed forms.
it ( "splits a printed predicate only at its top-level ANDs" , ( ) = > {
expect ( predicateConditions ( "((a OR b) AND c)" ) ) . toEqual ( [ "a OR b" , "c" ] ) ;
expect ( predicateConditions ( "(a OR (b AND c))" ) ) . toEqual ( [ "a OR (b AND c)" ] ) ;
expect ( predicateConditions ( "((attempt_id IS NOT NULL) AND (consumed_at IS NULL))" ) ) . toEqual ( [
"attempt_id IS NOT NULL" ,
"consumed_at IS NULL" ,
] ) ;
expect ( predicateConditions ( "(s = 'x AND (y'::text)" ) ) . toEqual ( [ "s = 'x AND (y'::text" ] ) ;
2026-09-30 23:18:53 +02:00
expect ( predicateConditions ( '(("odd AND (name" IS NULL) AND b)' ) ) . toEqual ( [ '"odd AND (name" IS NULL' , "b" ] ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
expect ( predicateConditions ( "" ) ) . toEqual ( [ ] ) ;
2026-09-30 15:49:23 +02:00
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "matches predicates by their conditions, never by case or by dropping structure" , ( ) = > {
const keys = [ "app_slug" , "attempt_id" ] ;
expect ( samePredicate ( "((a OR b) AND c)" , "(a OR (b AND c))" , keys , false ) ) . toBe ( false ) ;
expect ( samePredicate ( "(s = 'Pending'::text)" , "(s = 'pending'::text)" , keys , false ) ) . toBe ( false ) ;
expect ( samePredicate ( "((a) AND (b))" , "((b) AND (a))" , keys , false ) ) . toBe ( true ) ;
expect ( samePredicate ( "((a) AND (b))" , "((a) AND (b) AND (c))" , keys , false ) ) . toBe ( false ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "lets a key column's IS NOT NULL be absent only where NULL keys cannot collide" , ( ) = > {
const keys = [ "app_slug" , "attempt_id" ] ;
const declared = "((attempt_id IS NOT NULL) AND (consumed_at IS NULL))" ;
expect ( samePredicate ( declared , "(consumed_at IS NULL)" , keys , false ) ) . toBe ( true ) ;
expect ( samePredicate ( declared , "(consumed_at IS NULL)" , keys , true ) ) . toBe ( false ) ;
expect ( samePredicate ( declared , "((consumed_at IS NULL) AND (other IS NOT NULL))" , keys , false ) ) . toBe ( false ) ;
2026-09-30 15:49:23 +02:00
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "requires a key without a predicate to have none" , ( ) = > {
expect ( samePredicate ( "" , "" , [ "id" ] , false ) ) . toBe ( true ) ;
expect ( samePredicate ( "" , "(id IS NOT NULL)" , [ "id" ] , false ) ) . toBe ( false ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
/ * *
* Every operation the store offers , once , in an order that makes each one
* meaningful . Returns the names it called , so a test can prove it covered the
* store . Throws on the first operation the database refuses .
* /
async function exerciseStore ( store : PostgresStore ) : Promise < Set < string > > {
const called = new Set < string > ( ) ;
const s = new Proxy ( store , {
get ( target , name , receiver ) {
const value = Reflect . get ( target , name , receiver ) as unknown ;
if ( typeof value !== "function" ) return value ;
return ( . . . args : unknown [ ] ) = > {
called . add ( String ( name ) ) ;
return ( value as ( . . . a : unknown [ ] ) = > unknown ) . apply ( target , args ) ;
} ;
} ,
} ) ;
const now = "2026-04-26T12:00:00.000Z" ;
const later = "2999-01-01T00:00:00.000Z" ;
const account = await s . createAccount ( { createdAt : now , lastSeenAt : now , displayName : "Probe" } ) ;
await s . touchAccount ( account . id , now , { displayName : "Probe 2" } ) ;
await s . findAccountById ( account . id ) ;
const identity = {
accountId : account.id ,
provider : "discord" as const ,
providerUserId : "u1" ,
scopes : [ "identify" ] ,
profileJson : { } ,
createdAt : now ,
updatedAt : now ,
} ;
2026-10-19 11:11:34 +02:00
const linked = await s . upsertLinkedIdentity ( identity ) ;
await s . updateLinkedIdentityCredential ( linked . id , async ( ) = > ( { kind : "keep" } ) ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
await s . upsertLinkedIdentity ( { . . . identity , username : "again" } ) ;
await s . findAccountByLinkedIdentity ( "discord" , "u1" ) ;
await s . findStoredLinkedIdentity ( "discord" , "u1" ) ;
await s . findStoredLinkedIdentityForAccount ( account . id , "discord" ) ;
await s . listStoredLinkedIdentitiesForAccount ( account . id ) ;
await s . listLinkedIdentitiesForAccount ( account . id ) ;
await s . createCapabilityGrant ( {
accountId : account.id ,
scopeType : "app" ,
scopeId : "ghostlight" ,
capability : "app_access" ,
source : "probe" ,
status : "active" ,
createdAt : now ,
updatedAt : now ,
} ) ;
await s . listActiveGrants ( account . id , "ghostlight" , now ) ;
const session = { id : randomUUID ( ) , accountId : account.id , appSlug : "ghostlight" as const , createdAt : now , lastSeenAt : now , expiresAt : later , accessRevision : 1 , claimsJson : { } } ;
await s . createSession ( session ) ;
await s . createSession ( { . . . session , lastSeenAt : later } ) ;
await s . findSession ( "ghostlight" , session . id ) ;
await s . revokeSession ( "ghostlight" , session . id , account . id , 1 , now ) ;
const attempt = await s . createAuthAttempt ( { appSlug : "ghostlight" , provider : "discord" , mode : "sign_in" , returnTo : "https://x.test/" , createdAt : now , expiresAt : later } ) ;
await s . findAuthAttempt ( "ghostlight" , attempt . handle ) ;
await s . updateAuthAttempt ( "ghostlight" , attempt . handle , { status : "completed" , at : now } ) ;
const receipt = { appSlug : "ghostlight" as const , idempotencyKey : "k" , requestFingerprint : "f" , status : "accepted" , contentSchema : "c" , envelopeBase64 : "e" , createdAt : now , expiresAt : later } ;
await s . createPrivateCommandReceipt ( receipt ) ;
await s . createPrivateCommandReceipt ( receipt ) ;
await s . findPrivateCommandReceipt ( "ghostlight" , "k" ) ;
const completion = { appSlug : "ghostlight" as const , provider : "discord" as const , mode : "sign_in" as const , accountId : account.id , sessionId : session.id , returnTo : "https://x.test/" , createdAt : now , expiresAt : later , payloadJson : { } } ;
const byCode = await s . createAuthCompletion ( completion ) ;
await s . consumeAuthCompletion ( "ghostlight" , byCode . code , now ) ;
await s . createAuthCompletion ( { . . . completion , attemptId : attempt.handle } ) ;
await s . consumeAuthCompletionByAttempt ( "ghostlight" , attempt . handle , now ) ;
const snapshot = { accountId : account.id , provider : "discord" as const , scope : "ghostlight:probe" , evaluatedAt : now , isAllowed : true , reasonCode : "ok" , rawSummaryJson : { } } ;
await s . upsertEntitlementSnapshot ( snapshot ) ;
await s . upsertEntitlementSnapshot ( { . . . snapshot , isAllowed : false } ) ;
await s . createAuditEvent ( { eventType : "probe" , eventPayloadJson : { } , createdAt : now } ) ;
return called ;
}
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
// Against a real Postgres, through the startup path itself: createStore, with
// and without applying the schema. HEIMDALL_TEST_PG_ADMIN_URL names a role
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
// that may create databases and roles. Each case clones a template database
// that already holds the schema, and runs as its own login role.
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
const adminUrl = process . env . HEIMDALL_TEST_PG_ADMIN_URL ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
describe . skipIf ( ! adminUrl ) ( "startup against a live Postgres (HEIMDALL_TEST_PG_ADMIN_URL)" , { timeout : 120_000 } , ( ) = > {
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
const refusal = ( code : string ) = > ` Postgres storage could not be prepared ( ${ code } ); check GC_ACCESS_DATABASE_URL_FILE. ` ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const suffix = randomUUID ( ) . replace ( /-/g , "" ) . slice ( 0 , 12 ) ;
const template = ` heimdall_template_ ${ suffix } ` ;
const role = ` heimdall_app_ ${ suffix } ` ;
const rolePassword = "CANARYrolepw" ;
const admin = adminUrl ? new Pool ( { connectionString : adminUrl , max : 2 } ) : undefined ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
const cleanup : Array < ( ) = > Promise < void > > = [ ] ;
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
/ * *
* A pool the test itself uses . The forced drops end its connections ,
* possibly while pg is still closing them after end ( ) resolved ; that is
* cleanup , not a finding , so it is not left to become an unhandled error .
* /
function fixturePool ( connectionString : string ) : Pool {
const pool = new Pool ( { connectionString , max : 2 } ) ;
pool . on ( "error" , ( ) = > undefined ) ;
return pool ;
}
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
function urlFor ( database : string , user? : string , password? : string ) : string {
const url = new URL ( adminUrl ! ) ;
url . pathname = ` / ${ database } ` ;
if ( user ) url . username = user ;
if ( password ) url . password = password ;
return url . toString ( ) ;
}
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
beforeAll ( async ( ) = > {
await admin ! . query ( ` CREATE DATABASE ${ template } ` ) ;
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
const pool = fixturePool ( urlFor ( template ) ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
await new PostgresStore ( pool ) . ensureSchema ( ) ;
await pool . end ( ) ;
await admin ! . query ( ` CREATE ROLE ${ role } LOGIN PASSWORD ' ${ rolePassword } ' ` ) ;
} , 120 _000 ) ;
afterEach ( async ( ) = > {
for ( const step of cleanup . splice ( 0 ) . reverse ( ) ) await step ( ) ;
} ) ;
afterAll ( async ( ) = > {
await admin ! . query ( ` DROP DATABASE IF EXISTS ${ template } ` ) ;
await admin ! . query ( ` DROP ROLE IF EXISTS ${ role } ` ) ;
await admin ! . end ( ) ;
} , 120 _000 ) ;
/** A clone of the applied template, and a pool on it as its owner (the admin role). */
async function appliedDatabase ( ) : Promise < { name : string ; owner : Pool } > {
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
const name = ` heimdall_check_ ${ randomUUID ( ) . replace ( /-/g , "" ) } ` ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
await admin ! . query ( ` CREATE DATABASE ${ name } TEMPLATE ${ template } ` ) ;
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
const owner = fixturePool ( urlFor ( name ) ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
cleanup . push ( async ( ) = > {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
await owner . end ( ) ;
await admin ! . query ( ` DROP DATABASE IF EXISTS ${ name } WITH (FORCE) ` ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
return { name , owner } ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
}
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
/** Grants the app role exactly `privileges`, per table, and nothing else. */
async function grant ( owner : Pool , privileges : Readonly < Record < string , readonly string [ ] > > ) : Promise < void > {
await owner . query ( ` GRANT USAGE ON SCHEMA public TO ${ role } ` ) ;
for ( const [ table , wanted ] of Object . entries ( privileges ) ) {
if ( wanted . length > 0 ) await owner . query ( ` GRANT ${ wanted . join ( ", " ) } ON ${ table } TO ${ role } ` ) ;
}
}
/ * *
* Starts Heimdall 's store on `url` and reports "started" or the refusal' s
* message . A refusal must also leave no session of its own behind : the
* refused store ' s pool is closed before the error is thrown .
* /
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
async function start ( url : string , applySchemaOnStartup : boolean ) : Promise < string > {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const tagged = new URL ( url ) ;
const tag = ` heimdall_start_ ${ randomUUID ( ) . replace ( /-/g , "" ) . slice ( 0 , 16 ) } ` ;
tagged . searchParams . set ( "application_name" , tag ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
try {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const store = await createStore ( {
storage : { backend : "postgres" , databaseUrl : tagged.toString ( ) , applySchemaOnStartup } ,
} as HeimdallConfig ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
await store . close ( ) ;
return "started" ;
} catch ( error ) {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
let sessions = - 1 ;
for ( let attempt = 0 ; attempt < 50 && sessions !== 0 ; attempt += 1 ) {
if ( attempt > 0 ) await new Promise ( ( resolve ) = > setTimeout ( resolve , 100 ) ) ;
const result = await admin ! . query < { n : number } > (
"SELECT count(*)::int AS n FROM pg_stat_activity WHERE application_name = $1" ,
[ tag ]
) ;
sessions = result . rows [ 0 ] ! . n ;
}
expect ( sessions , "a refused store left its connection open" ) . toBe ( 0 ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
return ( error as Error ) . message ;
}
}
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it ( "starts on an applied database as its owner, applying or not" , async ( ) = > {
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
const { name } = await appliedDatabase ( ) ;
expect ( await start ( urlFor ( name ) , false ) ) . toBe ( "started" ) ;
expect ( await start ( urlFor ( name ) , true ) ) . toBe ( "started" ) ;
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it ( "serves every store operation with exactly the privileges it checks for" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , STORE_TABLE_PRIVILEGES ) ;
expect ( await start ( urlFor ( name , role , rolePassword ) , false ) ) . toBe ( "started" ) ;
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
const pool = fixturePool ( urlFor ( name , role , rolePassword ) ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
cleanup . push ( async ( ) = > {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
await pool . end ( ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const called = await exerciseStore ( new PostgresStore ( pool ) ) ;
const offered = Object . getOwnPropertyNames ( PostgresStore . prototype ) . filter (
( method ) = > ! [ "constructor" , "ensureSchema" , "checkSchema" , "close" ] . includes ( method )
) ;
expect ( [ . . . called ] . sort ( ) ) . toEqual ( offered . sort ( ) ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it ( "refuses each privilege taken away, and the store needs each one" , async ( ) = > {
const pairs = Object . entries ( STORE_TABLE_PRIVILEGES ) . flatMap ( ( [ table , wanted ] ) = > wanted . map ( ( privilege ) = > ( { table , privilege } ) ) ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
expect ( Object . keys ( STORE_TABLE_PRIVILEGES ) . sort ( ) ) . toEqual ( [ . . . REQUIRED_TABLES ] . sort ( ) ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
for ( const { table , privilege } of pairs ) {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , { . . . STORE_TABLE_PRIVILEGES , [ table ] : STORE_TABLE_PRIVILEGES [ table ] ! . filter ( ( p ) = > p !== privilege ) } ) ;
const message = await start ( urlFor ( name , role , rolePassword ) , false ) ;
expect ( message , ` ${ table } without ${ privilege } ` ) . toBe ( refusal ( "SCHEMA_PRIVILEGES" ) ) ;
expect ( message ) . not . toContain ( "CANARY" ) ;
Schema check: a unique key is matched by shape, not name; quiet fixture pools
The rule mutation that stopped checking a named unique index by its name
survived, and rightly: an index of the same table, key columns, predicate,
uniqueness and immediacy keeps R21.1 whatever it is called, and the named
index's existence is already required as a relation. The name condition
and REQUIRED_KEYS' index field are deleted.
The live block failed 5 of 10 repeated runs with unhandled 57P01 errors:
a test's own pool, already ended, still had connections closing when the
forced drop terminated them, and nothing listened. Every pool the tests
open now goes through fixturePool, which ignores those errors; the store's
own pool keeps its listener and is not a fixture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:30:40 +02:00
const pool = fixturePool ( urlFor ( name , role , rolePassword ) ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const failure = await exerciseStore ( new PostgresStore ( pool ) ) . then (
( ) = > undefined ,
( error : unknown ) = > ( error as { code? : string } ) . code
) ;
await pool . end ( ) ;
expect ( failure , ` the store runs without ${ privilege } on ${ table } , so the check need not ask for it ` ) . toBe ( "42501" ) ;
for ( const step of cleanup . splice ( 0 ) . reverse ( ) ) await step ( ) ;
}
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it ( "refuses to apply the schema as a role that may not, and closes the store" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , STORE_TABLE_PRIVILEGES ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
expect ( await start ( urlFor ( name , role , rolePassword ) , true ) ) . toBe ( refusal ( "42501" ) ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
} ) ;
2026-09-30 21:28:50 +02:00
it ( "prints each declared predicate exactly as Postgres prints the applied index's" , async ( ) = > {
const { owner } = await appliedDatabase ( ) ;
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
const printed = await printedDeclaredPredicates ( owner , HEIMDALL_SCHEMA_REQUIREMENTS ) ;
2026-09-30 21:28:50 +02:00
const applied = await owner . query < { predicate : string } > (
"SELECT pg_get_expr(indpred, indrelid) AS predicate FROM pg_index WHERE indexrelid = 'auth_completions_attempt_unconsumed_unique_idx'::regclass"
) ;
const attemptKey = REQUIRED_KEYS . findIndex ( ( key ) = > key . predicate !== "" ) ;
expect ( printed [ attemptKey ] ) . toBe ( applied . rows [ 0 ] ! . predicate ) ;
expect ( printed . filter ( ( predicate ) = > predicate !== "" ) ) . toHaveLength ( 1 ) ;
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it ( "refuses a table whose primary key was dropped, even after applying the schema" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await owner . query ( "ALTER TABLE auth_completions DROP CONSTRAINT auth_completions_pkey" ) ;
2026-09-30 17:29:19 +02:00
expect ( await start ( urlFor ( name ) , false ) ) . toBe ( refusal ( "SCHEMA_MISSING" ) ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
expect ( await start ( urlFor ( name ) , true ) ) . toBe ( refusal ( "SCHEMA_MISSING" ) ) ;
2026-09-30 17:29:19 +02:00
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "serves every store operation with each privilege granted column by column" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await owner . query ( ` GRANT USAGE ON SCHEMA public TO ${ role } ` ) ;
for ( const [ table , wanted ] of Object . entries ( STORE_TABLE_PRIVILEGES ) ) {
const columns = REQUIRED_COLUMNS . filter ( ( column ) = > column . table === table ) . map ( ( column ) = > column . column ) . join ( ", " ) ;
for ( const privilege of wanted ) await owner . query ( ` GRANT ${ privilege } ( ${ columns } ) ON ${ table } TO ${ role } ` ) ;
}
expect ( await start ( urlFor ( name , role , rolePassword ) , false ) ) . toBe ( "started" ) ;
const pool = fixturePool ( urlFor ( name , role , rolePassword ) ) ;
cleanup . push ( async ( ) = > {
await pool . end ( ) ;
} ) ;
await exerciseStore ( new PostgresStore ( pool ) ) ;
} ) ;
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
it ( "refuses a privilege missing on one column of another table" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , { . . . STORE_TABLE_PRIVILEGES , auth_completions : [ ] } ) ;
const columns = REQUIRED_COLUMNS . filter ( ( column ) = > column . table === "auth_completions" && column . column !== "attempt_id" )
. map ( ( column ) = > column . column )
. join ( ", " ) ;
for ( const privilege of STORE_TABLE_PRIVILEGES . auth_completions ! ) {
await owner . query ( ` GRANT ${ privilege } ( ${ columns } ) ON auth_completions TO ${ role } ` ) ;
}
expect ( await start ( urlFor ( name , role , rolePassword ) , false ) ) . toBe ( refusal ( "SCHEMA_PRIVILEGES" ) ) ;
} ) ;
it ( "names the TEMPORARY privilege when the role may not create temporary tables" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , STORE_TABLE_PRIVILEGES ) ;
await owner . query ( ` REVOKE TEMPORARY ON DATABASE ${ name } FROM PUBLIC ` ) ;
expect ( await start ( urlFor ( name , role , rolePassword ) , false ) ) . toBe (
"Postgres storage could not be prepared (SCHEMA_TEMP_PRIVILEGE); grant the database role TEMPORARY on its database."
) ;
} ) ;
// The schema's own predicate splits into the same conditions raw or
// printed, so only a predicate whose printed form differs (a literal gains
// its cast) shows that the check compares Postgres's printing.
it ( "matches a declared predicate that Postgres prints differently from how it is written" , async ( ) = > {
const { owner } = await appliedDatabase ( ) ;
await owner . query ( "CREATE TABLE predicate_probe (id INTEGER NOT NULL, state TEXT, attempts INTEGER)" ) ;
await owner . query ( "CREATE UNIQUE INDEX predicate_probe_live ON predicate_probe (id) WHERE state = 'live' AND attempts > 0" ) ;
const requirements = {
tables : [ "predicate_probe" ] ,
columns : [
{ table : "predicate_probe" , column : "id" , type : "INTEGER" , nullable : false } ,
{ table : "predicate_probe" , column : "state" , type : "TEXT" , nullable : true } ,
{ table : "predicate_probe" , column : "attempts" , type : "INTEGER" , nullable : true } ,
] ,
keys : [ { table : "predicate_probe" , columns : "id" , predicate : "state = 'live' AND attempts > 0" } ] ,
privileges : { } ,
} ;
expect ( ( await printedDeclaredPredicates ( owner , requirements ) ) [ 0 ] ) . toBe ( "((state = 'live'::text) AND (attempts > 0))" ) ;
await expect ( checkSchemaRequirements ( owner , requirements ) ) . resolves . toBeUndefined ( ) ;
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
it ( "refuses a privilege held on only some of a table's columns" , async ( ) = > {
const { name , owner } = await appliedDatabase ( ) ;
await grant ( owner , { . . . STORE_TABLE_PRIVILEGES , audit_events : [ ] } ) ;
await owner . query ( ` GRANT INSERT (id) ON audit_events TO ${ role } ` ) ;
expect ( await start ( urlFor ( name , role , rolePassword ) , false ) ) . toBe ( refusal ( "SCHEMA_PRIVILEGES" ) ) ;
} ) ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
// Each damage below leaves a database whose shape still resembles the
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
// schema but on which a store write fails or R21.1 no longer holds. A step
// starting with "!" is expected to fail and leave its mark (a failed
// CREATE INDEX CONCURRENTLY leaves an invalid index behind).
const seedDuplicates = [
"INSERT INTO accounts VALUES ('acc', now(), now(), null, null)" ,
"INSERT INTO sessions VALUES ('s', 'acc', 'ghostlight', now(), now(), now() + interval '1 hour', '{}', 1)" ,
] ;
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
it . each ( [
[ "a key made DEFERRABLE (ON CONFLICT refuses it as an arbiter)" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE private_command_receipts DROP CONSTRAINT private_command_receipts_pkey" , "ALTER TABLE private_command_receipts ADD PRIMARY KEY (app_slug, idempotency_key) DEFERRABLE INITIALLY IMMEDIATE" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "a key narrowed to one column, the other only INCLUDEd" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" , "CREATE UNIQUE INDEX li_include ON linked_identities(provider) INCLUDE (provider_user_id)" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "a key widened by an expression column" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" , "CREATE UNIQUE INDEX li_expr ON linked_identities(provider, provider_user_id, lower(username))" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "a key replaced by a partial one" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE entitlement_snapshots DROP CONSTRAINT entitlement_snapshots_account_id_provider_scope_key" , "CREATE UNIQUE INDEX es_partial ON entitlement_snapshots(account_id, provider, scope) WHERE is_allowed" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "a key replaced by a plain index on the same columns" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" , "CREATE INDEX li_plain ON linked_identities(provider, provider_user_id)" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "a key replaced by one on other columns of the same count" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" , "CREATE UNIQUE INDEX li_other ON linked_identities(provider, username)" ] ] ,
[ "a key left only as an invalid index by a failed concurrent build" ,
[ . . . seedDuplicates ,
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"INSERT INTO linked_identities (id, account_id, provider, provider_user_id, created_at, updated_at) VALUES ('l1', 'acc', 'youtube', 'dup', now(), now()), ('l2', 'acc', 'youtube', 'dup', now(), now())" ,
"!CREATE UNIQUE INDEX CONCURRENTLY li_ccnew ON linked_identities(provider, provider_user_id)" ,
"DELETE FROM linked_identities WHERE id = 'l2'" ] ] ,
[ "the attempt index left invalid over duplicate attempts by a failed concurrent build" ,
[ . . . seedDuplicates ,
"DROP INDEX auth_completions_attempt_unconsumed_unique_idx" ,
"INSERT INTO auth_completions (code, attempt_id, app_slug, provider, mode, account_id, session_id, return_to, payload_json, created_at, expires_at) VALUES ('c1', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour'), ('c2', 'att', 'ghostlight', 'discord', 'sign_in', 'acc', 's', 'https://x/', '{}', now(), now() + interval '1 hour')" ,
"!CREATE UNIQUE INDEX CONCURRENTLY auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL" ] ] ,
2026-09-30 21:28:50 +02:00
// An interrupted DROP INDEX CONCURRENTLY leaves an index ready but not
// valid; the fixture sets that state directly (the admin role may).
[ "a key left only as an index that is ready but not valid" ,
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"CREATE UNIQUE INDEX li_half_dropped ON linked_identities(provider, provider_user_id)" ,
"UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'li_half_dropped'::regclass" ] ] ,
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
[ "a key's first column compared under a case-insensitive collation" ,
[ "CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)" ,
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"CREATE UNIQUE INDEX li_ci_first ON linked_identities(provider COLLATE heimdall_ci, provider_user_id)" ] ] ,
// Soul pass 7: the key index compares under "C", but the column itself is
// case-insensitive, so the store's own lookups by provider_user_id match
// other users' ids.
[ "a column declared under a case-insensitive collation, its key index under C" ,
[ "CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)" ,
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"ALTER TABLE linked_identities ALTER COLUMN provider_user_id TYPE text COLLATE heimdall_ci" ,
'CREATE UNIQUE INDEX li_c ON linked_identities(provider, provider_user_id COLLATE "C")' ] ] ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "a key compared under a case-insensitive collation (two users' ids become one row)" ,
[ "CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)" ,
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"CREATE UNIQUE INDEX li_ci ON linked_identities(provider, provider_user_id COLLATE heimdall_ci)" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "the attempt index replaced by a plain one of the same name" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "the attempt index made unique on every row (no predicate)" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id)" ] ] ,
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
[ "the attempt index given another predicate" ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL" ] ] ,
[ "the attempt index narrowed by an extra condition" ,
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE attempt_id IS NOT NULL AND consumed_at IS NULL AND app_slug <> 'ghostlight'" ] ] ,
[ "the attempt index with a condition ORed into its predicate" ,
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) WHERE (attempt_id IS NOT NULL OR app_slug = 'x') AND consumed_at IS NULL" ] ] ,
[ "the attempt index made NULLS NOT DISTINCT without excluding NULL attempts" ,
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX auth_completions_attempt_unconsumed_unique_idx ON auth_completions(app_slug, attempt_id) NULLS NOT DISTINCT WHERE consumed_at IS NULL" ] ] ,
[ "a column retyped" , [ "ALTER TABLE sessions ALTER COLUMN access_revision TYPE TEXT" ] ] ,
[ "a created table's column dropped" , [ "ALTER TABLE auth_completions DROP COLUMN mode" ] ] ,
[ "NOT NULL added to a column the store leaves null" , [ "ALTER TABLE auth_completions ALTER COLUMN attempt_id SET NOT NULL" ] ] ,
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
[ "NOT NULL added to a nullable column of another table" ,
[ "UPDATE linked_identities SET username = ''" , "ALTER TABLE linked_identities ALTER COLUMN username SET NOT NULL" ] ] ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
] ) ( "refuses %s" , async ( _damage , steps ) = > {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const { name , owner } = await appliedDatabase ( ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
for ( const step of steps ) {
if ( step . startsWith ( "!" ) ) await expect ( owner . query ( step . slice ( 1 ) ) ) . rejects . toBeDefined ( ) ;
else await owner . query ( step ) ;
}
2026-09-30 17:29:19 +02:00
expect ( await start ( urlFor ( name ) , false ) ) . toBe ( refusal ( "SCHEMA_MISSING" ) ) ;
} ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
// Each change below leaves a database the store serves exactly as before.
it . each ( [
[ "a key index that carries extra INCLUDE columns" ,
[ "ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" , "CREATE UNIQUE INDEX li_covering ON linked_identities(provider, provider_user_id) INCLUDE (username)" ] ] ,
[ "the attempt index under another name" , [ "ALTER INDEX auth_completions_attempt_unconsumed_unique_idx RENAME TO attempt_idx_renamed" ] ] ,
[ "the attempt index's conditions in the other order" ,
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX attempt_reordered ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL AND attempt_id IS NOT NULL" ] ] ,
// NULL attempt ids never collide in a unique index that treats NULLs as
// distinct (the default), so dropping that condition keeps the same rows unique.
[ "the attempt index without its redundant attempt_id IS NOT NULL" ,
[ "DROP INDEX auth_completions_attempt_unconsumed_unique_idx" , "CREATE UNIQUE INDEX attempt_unconsumed ON auth_completions(app_slug, attempt_id) WHERE consumed_at IS NULL" ] ] ,
[ "a lookup index dropped" , [ "DROP INDEX audit_events_lookup_idx" , "DROP INDEX auth_completions_lookup_idx" ] ] ,
Schema check: deterministic column collations, a named TEMP refusal, and kills for the printer
Soul pass 7, S7-3 and S7-4, P7-3 and P7-5.
Columns (S7-3): every declared column must be under a deterministic
collation. A case-insensitive provider_user_id whose key index compares
under "C" passed, and the store's own lookups then matched other users'
ids.
The check becomes checkSchemaRequirements(pool, requirements), with
Heimdall's store calling it with HEIMDALL_SCHEMA_REQUIREMENTS (its tables,
columns, keys and privileges). That seam lets a live test check a
predicate Postgres prints differently from how it is written (a literal
gains its cast): the check must still pass, so comparing the raw text
instead of the printed form (P7-3's Y1) now fails.
A role that may not create temporary tables was refused with 42501 and
told to check GC_ACCESS_DATABASE_URL_FILE (P7-5). The predicate printer now
refuses it as SCHEMA_TEMP_PRIVILEGE, and startup says to grant the role
TEMPORARY on its database, in fixed text.
New fixtures (S7-4): a case-insensitive collation on a key's first column
(Y3), NOT NULL added to a nullable column of linked_identities (Y5), and
every privilege on auth_completions except on attempt_id (Y8) are refused;
a key index that INCLUDEs an undeclared case-insensitive column starts
(Y4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:52:41 +02:00
// Only a key's own columns decide which rows collide; an INCLUDEd column's
// collation does not, whatever it is.
[ "a key index that INCLUDEs an undeclared case-insensitive column" ,
[ "CREATE COLLATION heimdall_ci (provider = icu, locale = 'und-u-ks-level2', deterministic = false)" ,
"ALTER TABLE linked_identities ADD COLUMN operator_note text COLLATE heimdall_ci" ,
"ALTER TABLE linked_identities DROP CONSTRAINT linked_identities_provider_provider_user_id_key" ,
"CREATE UNIQUE INDEX li_note ON linked_identities(provider, provider_user_id) INCLUDE (operator_note)" ] ] ,
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
] ) ( "accepts %s" , async ( _change , steps ) = > {
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
const { name , owner } = await appliedDatabase ( ) ;
Schema check: valid, deterministic keys; Postgres-printed predicates; column grants; nullability
Soul pass 6, F1-F4, F7 and F8.
Keys (F1, F2): a key is met only by a unique index that is valid and
ready, so the invalid index a failed CREATE UNIQUE INDEX CONCURRENTLY
leaves behind (every sign-in failing with 42P10, or one consume taking
two rows) is refused; and whose key columns compare under deterministic
collations, so a case-insensitive collation cannot merge two providers'
user ids into one row and move an identity between accounts.
Predicates (F3, F7): the declared predicates are printed by Postgres
itself, by indexing a temporary copy of each keyed table's declared
columns in one transaction that is rolled back, and compared with the
index's printed predicate as sets of top-level AND-ed conditions, split
by nesting and never inside quoted text. Case and structure are kept, so
((a OR b) AND c) and (a OR (b AND c)) differ, as do 'Pending' and
'pending', and an extra condition narrows the predicate and is refused.
Conditions in another order match.
F4: lookup-only indexes are no longer required (REQUIRED_RELATIONS is now
REQUIRED_TABLES; unique indexes are checked by shape in REQUIRED_KEYS), so
a renamed attempt index or a dropped lookup index starts. Privileges are
checked per column with has_column_privilege on every column the schema
declares, which a table grant satisfies; a role granted each needed
privilege column by column starts and runs every store operation, and one
holding INSERT on only one audit column is refused. On (c): a unique index
treats NULL keys as distinct unless it is NULLS NOT DISTINCT, so rows with
a NULL attempt_id never collide and `attempt_id IS NOT NULL` is redundant;
that condition may be absent from the attempt index's predicate, except on
a NULLS NOT DISTINCT index (refused). A key declared without a predicate
must have none, since ON CONFLICT cannot infer a partial arbiter.
F8: a column the schema leaves nullable must be nullable; NOT NULL added
to attempt_id is refused. CHECK constraints, triggers and row-level
security stay out of scope, as the schema comment says.
Every change is a live fixture: invalid identity key, invalid attempt
index over duplicate attempts, case-insensitive key, narrowed and
OR-widened predicates, NULLS NOT DISTINCT, NOT NULL added, partial column
grants are refused; reordered and unredundant predicates, a renamed
attempt index, dropped lookup indexes and column-by-column grants start.
The fake-pool code-mapping tests are replaced by unit tests of the
predicate comparison.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 20:40:12 +02:00
for ( const step of steps ) await owner . query ( step ) ;
Startup proves the database can take Heimdall's writes
Soul pass 4, F4: the startup check passed on databases where every request
still failed: a role granted SELECT only (O2), and a table whose primary
key and key column were dropped (N), including on a start that applied the
schema, since CREATE ... IF NOT EXISTS leaves a damaged table as it is.
Every start now ends in checkSchema, after applying the schema or not. It
requires, read from the schema SQL: every relation; every column the
created tables declare, not only the added one (read from pg_attribute, so
a column the role may not see is not reported as missing); every primary
key and unique constraint as a non-partial unique index on exactly its
columns, which is what the store's ON CONFLICT clauses and the foreign
keys rely on; and SELECT, INSERT and UPDATE on every table. A missing
piece refuses with SCHEMA_MISSING, a missing privilege with
SCHEMA_PRIVILEGES, through the same fixed startup message.
A live-Postgres block drives createStore itself, skipped unless
HEIMDALL_TEST_PG_ADMIN_URL names a role that may create databases and
roles: a read-only role (and its canary password stays out of the
message), a dropped primary key with and without applying, a unique key
replaced by a partial one, and a dropped column.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 17:15:34 +02:00
Schema check: keys as the store relies on them, typed columns, per-table privileges
Soul pass 5, S5-1 and S5-2. checkSchema passed four databases the store
could not serve, and demanded privileges the store never uses.
Keys: every primary key and unique constraint, and each unique index the
schema creates by name, must stand as an immediate (not deferrable) unique
index whose key columns, counted by indnkeyatts and excluding INCLUDE
columns, are exactly the declared ones, with exactly the declared
predicate. A named index is checked by its name, so the partial attempt
index R21.1 depends on cannot be swapped for a plain or unpredicated one of
the same name. Predicates compare with case, parentheses and white space
removed, the form both the schema text and pg_get_expr reduce to.
Columns: each carries its declared type, compared as a type OID through
to_regtype.
Privileges: STORE_TABLE_PRIVILEGES in postgres.ts says what the store's
statements do to each table. audit_events needs INSERT only;
capability_grants and private_command_receipts need no UPDATE; nothing
needs DELETE. REQUIRED_TABLE_PRIVILEGES and REQUIRED_TABLES are gone.
The live block now proves the map both ways against a real Postgres: a
role granted exactly the map starts and runs every store operation (the
test checks it called every method), and for each privilege in the map,
a role granted all but that one is refused at startup and fails the same
workload with 42501. Each damage Soul found (a DEFERRABLE key, an INCLUDE
key, the attempt index replaced by a plain one of the same name, a retyped
column), plus an expression-widened key, an unpredicated or re-predicated
attempt index, is refused; a key index with extra INCLUDE columns starts.
Every refusal also proves its store left no connection open (tagged by
application_name), on the no-apply, apply and 42501 paths. Cases clone a
template database and the block has a 120 s budget, instead of building
the schema per case under the 5 s default that timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:55:45 +02:00
expect ( await start ( urlFor ( name ) , false ) ) . toBe ( "started" ) ;
2026-09-30 15:49:23 +02:00
} ) ;
} ) ;