architecture.md gains a Managed credential custody section: the single
refresh owner, the store-port lock, per-process single flight, typed
revoked and refresh-failed answers, the provider descriptor token fields
and the one token request, the mode x role gates, custody-only
completions, verification layer and follow-ups by id. The spotify finding
now matches the removed spiverse profile; JOYSTICK joins the provider env
lists. implementation-plan and service-contract lose two stale lines.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes StrykerJS survivors on the diff: the cap at exactly 365 days against a
second over, the rotating-exchange failure's typed code (and that a
non-rotating provider is unaffected), and the start refusal's error word.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joystick rotates refresh tokens, so a code-exchange answer whose refresh_token
is absent or empty would store a credential the first refresh revokes. The
shared exchange now throws a typed ProviderResponseError before the identity
is resolved or anything is written. The residual-risk comment on the in-flight
refresh map cites its follow-up.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Delivers a joystick connect through a backend_callback handoff and checks the
posted body has no refreshToken or refresh and empty capabilities, so the
handoff spreading `issued` instead of `delivered` fails.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A value like 3e11 overflowed the Date the expiry is stored as and surfaced
as an untyped error. usableExpiresIn now caps a usable value at 365 days;
the refresh path is pinned on memory and live Postgres.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
callbackMint now takes the connection: custody_only is a connect for a
managed-connection-only provider bound to connection.kind creator with a
targetId. Anything else is refused at start with creator_connection_required
before any redirect, and at the callback from the signed state before the
code exchange. The streampixels profile note says what Heimdall guarantees
and records the S1 rule that SP's redeem treats a non-creator Joystick
completion as an error, never a sign-in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every shared capability needs a non-empty anyOf, so an empty fact set already
yields no capabilities; the explicit empty list beside it was a second owner
no test could tell apart from the first.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh and exchange answers now type as ExpiringTokenSet, and a rotate
decision requires tokenExpiresAt, so neither store has a no-expiry rule left
to mutate: a NULL expiry can only be written by a revoke.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Re-signs a joystick connect state as sign_in and link with the app's own key
and shows the callback refuses before the code exchange and writes nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token answer without a usable expires_in (absent, non-numeric, a string that
is not a positive integer) now takes the descriptor's
defaultAccessTokenLifetimeSeconds (3600 for every provider), so no issued
token has a NULL expiry that dueForRefresh would never refresh. A 2xx whose
refresh_token is present but not a string is the typed
provider_body_not_token_set (502 managed_credential_refresh_failed, tokens
kept); an empty string stays a missing token, so a rotating provider still
fails closed. The expiry rule is now pinned on memory and both live Postgres
rigs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The start gate only refused early. The callback ignored the signed state's
mode, so a Joystick connect for StreamPixels redeemed into a viewer_access
session. callbackMint(profile, provider, mode) now decides at the callback:
identity providers keep a viewer session (Bifrost discord connect unchanged);
a connect for a managed-connection-only provider stores custody and mints a
claim with no facts, no capabilities and no refresh token (the refresh route
would recompute viewer_access from the account); anything else is refused.
StreamPixels' redeem reads only account, provider, mode, connection and
claimSet.identities, so it accepts the capability-free claim unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A JSON array has no string access_token, so the typeof check refuses it;
the separate Array.isArray clause was an equivalent mutant's worth of code.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first gate refused connect for a provider outside managedConnectionProviders,
which broke Bifrost's existing Discord connect. sign_in and link still take
identityProviders only, so a managed-only provider (Joystick) can never sign
anyone in or link; connect takes either list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Callers that arrive while a refresh of one linked identity is in flight in
this process join it and receive its success or failure, instead of queuing
on the row lock and presenting the same refresh token again behind a failed
holder. The entry lives as long as the attempt, so a later request retries.
The store lock stays the cross-process guard; the residual risk (one
presentation per process per burst) is written into the service contract.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A grant verdict is read from a 400 body by exact code, never from a 401, a
5xx or a 2xx. A 2xx that is not an object with a non-empty string
access_token and no error is refused as provider_body_not_token_set, so a
malformed success keeps the tokens and surfaces as the typed 502 for every
provider, rotating or not. A refresh answer without expires_in stores the
expiry as unknown rather than keeping the already-due one; an unknown expiry
is never due, so the next resolve does not refresh again.
Pins Soul's three surviving mutants: substring code match, the dropped
expiry rule, and the snapshot refresh token on a non-rotating provider.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sign_in and link now take only identityProviders; connect takes only
managedConnectionProviders. supportsProvider(), which unioned the role lists
with entitlementSources and never looked at the mode, is deleted: a Joystick
sign_in for streampixels no longer starts, so a Deru sign-in cannot rewrite
his managed credential row. No built-in profile had a provider reachable
only through the union.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Added after a diff-scoped StrykerJS run left survivors: no-lock paths, the
skew boundary, decision kinds, a vanished row, open-transaction hygiene on
the live store, and the token request's method, signal, token type and
scope handling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The credential write returns updated_at; a Date there broke the equality the
port promises against the in-memory store.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Recipe optional_environment gains the Joystick client id and secret file;
service-contract documents the row lock, rotation fail-closed and the 410/502
answers; the StreamPixels profile names Heimdall as sole holder of provider
refresh tokens (F5).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pg returns a Date for TIMESTAMPTZ; a byte-identity check on a kept credential
saw a Date where the port types a string.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Joystick joins the catalog (Basic client auth, rotatesRefreshTokens), resolves
identity by channel id, and is a streampixels managed-connection provider only:
it is not an identity provider for the profile (ruling signin-waits-for-public-bot).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
refreshLinkedIdentityCredentialIfNeeded is deleted. refreshManagedCredential
owns the policy; HeimdallStore.updateLinkedIdentityCredential owns the row
(SELECT ... FOR UPDATE in Postgres, a per-id chain in memory), so a refresh
token is presented once across concurrent requests and processes. invalid_grant
clears the tokens (410 managed_credential_revoked); any other failure keeps
them (502 managed_credential_refresh_failed). The "?? refreshToken" keep-old
fallback is gone; keeping it is an explicit branch for non-rotating providers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Endpoint, client-auth method (RFC 7591 names) and rotation now live on
ProviderDescriptor; requestProviderTokens is the only code that talks to a
token endpoint. Failure labels are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Points at campaign joystick:campaign:self; flags the 1086aee deploy fact as
last-recorded (2026-08-22, ~100 commits behind HEAD); drops the stale E:
path and repixelizer-venv rehydrate command from the handoff.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7's Y2, treating only single quotes as quoted text, survived:
no test gave the splitter a double-quoted identifier. One case now holds
an identifier containing " AND (" and checks it stays whole.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Soul pass 7, S7-2. The out-of-scope list named CHECK constraints, triggers
and row-level security. It now also names unique indexes beyond or
stricter than the required keys (including leftovers of an interrupted
concurrent build, which are enforced once ready), NOT NULL columns the
schema does not declare and that have no default, and column-level SELECT
under SELECT * or RETURNING *, which needs every column of the table.
Documentation only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 7, S7-1. The exploration always called loadConfig with no
arguments, and the candidate socket alone is refused (it needs the base
URL), so neither the Idunn candidate path nor --state-root was ever run: a
secret read only there (Y6, Y7) went unseen.
The recipe's [service] arguments after the entry script, each binding
given a probe path, are now the argv of an Idunn-shaped run, together with
a loopback candidate socket and its base URL. That run is added on its own
and to every combined run. A test pins the parsed argv, that both
environment names are declared in the recipe, and that loadConfig accepts
the shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The mutations dropping indisvalid or indisready each survived alone,
because a failed CREATE INDEX CONCURRENTLY clears both. Postgres never
marks an index valid before it is ready (DROP INDEX CONCURRENTLY clears
validity first), so indisready adds nothing and is deleted. A fixture now
holds an index that is ready but not valid, the state an interrupted DROP
INDEX CONCURRENTLY leaves, set directly in pg_index by the admin role.
Comparing the raw declared predicate instead of Postgres's printing of it
survived too: the schema's one predicate splits into the same conditions
either way. A live test now pins printedDeclaredPredicates to the printed
predicate of the applied attempt index, so the printing itself is
checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's coverage test lists every method on PostgresStore as an
operation the workload must call, and the private predicate printer
counted as one. It is a helper of checkSchema, not a store operation, so
it becomes a function of the pool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`collation` is reserved in Postgres, so the key check's query failed with
42601 on every database and every start refused. The live block showed it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 6, F5 and F6. The all-at-once run set every name to one probe
value and died in loadConfig's first lines, so reads gated on a specific
value (Z4) or on two values late in the function (Z5) were missed, and a
copy of process.env taken at module load (Z6) was never the recorder.
Each name now gets each probe value alone, and the probe values include
every string literal in config.ts; the combined runs set every name at once
to values loadConfig accepted for it alone. config.ts is imported afresh
while the recorder is process.env, so a module-scope capture records too.
The remaining limit is stated in the test.
failureOf in the app tests now prints the error with util.inspect as well,
so a non-enumerable cause carrying the body (Z3) shows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
Soul pass 5, S5-7. The recording environment saw only the reads an empty
environment makes, so a secret read on a branch a value opens (X6) was
missed, and a read from a spread copy of the environment (X7) was
invisible.
loadConfig now runs over many environments until no run reads a new
name: every secret found so far set in plaintext, then each name read so
far set to each of five probe values, then all of them set to one probe
value at once; a run that throws keeps the reads it made first. Copying or
enumerating the environment (the Proxy's ownKeys and
getOwnPropertyDescriptor traps) is recorded, and a test refuses it, since
reads from a copy cannot be seen. HMAC joins the secret-shaped backstop
pattern.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-4: the provider-error tests proved only that the word
CANARY was absent, so a message carrying a 16-byte slice of the body (X1,
X2) passed, and the Bifrost success reply was checked with
objectContaining, so a 9-byte body head (X5) rode along. Each provider
error's message is now pinned exactly, the Bifrost success reply is pinned
as exactly status, supportFact and bifrostStatus, and every canary body
starts with the canary, so any prefix of it shows.
S5-5: errorIdentity's class and code filters had no test (X3, X4). Cases
now show a code or class name that carries input, is too long, is not a
string, or comes from a thrown non-Error or null is dropped, and a short
identifier survives.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul pass 5, S5-3: auth_completion_created stored completion.code, which
the unauthenticated redeem route turns into the session until it expires
(300 s by default), so the audit trail could redeem a live sign-in. The
two auth_completion_redeemed events (the HTTP redeem route and the private
command plane's redeem by attempt) stored the spent code as well.
All three payloads drop it. Nothing reads audit payloads back: the store
has no audit read, and no repository indexed by VoidBot consumes these
events. The event's sessionId already names the completion (one per
callback), so no replacement key is added. This matches Heimdall's own
witness contract, which says codes are redacted from audit exports.
Tests drive a sign-in through the HTTP redeem route and through the plane,
pin each payload exactly, and check the code appears in no audit event.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The startup check's refusal left the store's pool open: the process exits
on it in production, but the connection outlived the refusal. createStore
now closes a store it refuses.
Two live cases the first run showed undefended (mutants D6 and D9): a
unique key replaced by a plain index on the same columns, and by a unique
index on other columns of the same count. The read-only-role case also
checks that no session of the role outlives the refusal. The fixture's
owner pool ignores the connection ends its own forced drop causes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F1's second half, tested where it is decided: the Discord token exchange
(a 2xx body that is not JSON, and a 401), the Discord guild lookup and the
YouTube channel lookup each fail with a ProviderResponseError whose code
and status name the failure, and no printed form of the error (message,
stack, JSON) holds a byte of the canary body. Until now only the callers'
fixed answers were pinned, so a body in the message went unseen.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The store's ON CONFLICT clauses and the foreign keys need a unique,
non-partial index on exactly the key's columns; whether it is the primary
key changes nothing a request does. Requiring the primary flag added a
refusal no failing request motivated, so it is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>