feat(#578): credential keychain data model + store (phase 1/4) - #769
Merged
Conversation
Data model and durable store for the credential keychain (#578), the credential-side counterpart to Privacy Shield: encrypted, fingerprinted credentials owned by a principal, and grants (audience scope, once vs standing, purpose, expiry, revocation) that let a principal use one. No route, no tool yet — phase 1 is data model + storage only, per the phase cut in docs/plans/phase4a-578-keychain-prompt-2026-08-20.md. - packages/harness-channel-sdk/src/credentials.ts (NEW, additive): Credential / CredentialGrant / CredentialStore types, InMemoryCredentialStore, isGrantActive, validateNewGrantInput, fingerprintSecret. Barrel export appended to the END of index.ts to avoid merge conflicts with the parallel #577 session. - src/credentials/crypto.ts: AES-256-GCM seal/unseal, reusing fileVault's resolveMasterKey under a DIFFERENT env var (CREDENTIAL_KEYCHAIN_KEY) and dev-key file — separate trust domain from the provider-secret vault, sharing only the key-resolution code. - src/credentials/postgresCredentialStore.ts: durable CredentialStore, built the same way as PostgresGrantStore / PostgresAttachmentBindingStore (does not own the pool, throws rather than swallows a failure). - src/credentials/credentialStoreFactory.ts: explicit Postgres-vs-in-memory choice, so the vault no-pool case is a stated decision, not an implicit fallback. - migrations/0040_credentials.sql: credentials + credential_grants tables. 0038 is reserved (#746); 0039 is turn_receipts (#757). Why a dedicated store instead of a second GrantStore: a credential grant needs expiry/purpose/once-vs-standing metadata GrantStore's capability-string model cannot express. The coarse layer (does this principal have any right to reach the broker at all) still reuses the existing GrantStore/resolveCapabilities mechanism in phase 2; this table is the fine layer underneath it. Rationale is written out in credentials.ts's module header and the migration's own comments. Tests: 59 (49 unit + 10 against a real Postgres, skips cleanly with no test DB configured). Mutation-tested: isGrantActive's expiry boundary, principal-canonicalisation in activeGrant (an earlier version of this test passed even with canonicalisation removed, because both principals were built via makePrincipal which already canonicalises — fixed to use a raw, non-canonical Principal literal so the store's own canonicalisation is what's under test), the activeGrant active-filter, and the revokeGrant/markGrantConsumed idempotency guards. Every mutant was caught after the fix; dist was rebuilt between channel-sdk mutation runs.
headerName only made sense for the "header" injection scheme. Phase 2 (the broker) also needs a query-parameter NAME for the "query-param" scheme, which had nowhere to go under the old field. Renamed before phase 2 lands on top of this, rather than working around the gap there: "header" uses it as the header name, "query-param" as the parameter name, "bearer"/"basic-password" ignore it (the whole secret IS the value). No behavioural change for "bearer" (the only scheme phase 1's own tests exercise) — this is a rename, not new logic.
`fsp.readdir(dir).catch(() => [])` inferred the catch handler's return as `never[]`, which the test/tsconfig.json project (checked separately from src/ per #573) flagged as a new, previously-unbaselined error — `npm run typecheck` (src-only) never saw it, only `npm run typecheck:test` does, and that is what CI's "Typecheck (test + scripts trees, ratchet)" step runs. Annotated the handler's return type explicitly. CI failed on this before the Test steps even ran (they were skipped, not green) — verified locally with `npm run typecheck:test`, now reporting "406 known error(s), no regressions".
This was referenced Aug 20, 2026
0040 and 0041 were both claimed on main while this branch was in flight (0040_privacy_miss_reports + 0040_skill_ownership_lifecycle, and 0041_receipt_hash_chain). The migrator tracks by full filename and applies in lexicographic order, so a duplicate prefix would run fine — but the series stays legible only if numbers keep meaning something. 0038 remains reserved for #746. No code references the filename (verified by grep).
This was referenced Aug 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 1/4 of #578 (credential keychain with grants and a broker). Data model
and durable store only — no route, no tool yet, per the phase cut in
docs/plans/phase4a-578-keychain-prompt-2026-08-20.md. Built inside its ownworktree/branch (
feat/578-keychain) since a parallel session is working#577 concurrently; surface separation from that prompt was followed (channel
SDK export appended at the end of
index.ts, new files only under acredentials/namespace, a fresh migration number, nothing touched undersrc/services/skill*,agentBuilder.ts, orsrc/routes/admin.ts).What this delivers
packages/harness-channel-sdk/src/credentials.ts(new, additive):Credential,CredentialGrant,CredentialStoretypes,InMemoryCredentialStore,isGrantActive,validateNewGrantInput,fingerprintSecret. Barrel-exported at the end ofpackages/harness-channel-sdk/src/index.tsto avoid merge conflicts withthe parallel Skills as scope-owned, shareable artifacts (grants, org promotion, git skill packs) #577 session's own additions there.
middleware/src/credentials/crypto.ts: AES-256-GCM seal/unseal, sameenvelope as the existing secret vault (
fileVault.ts), reusing itsresolveMasterKey— but under a different env var(
CREDENTIAL_KEYCHAIN_KEY) and dev-key filename, so the keychain and theprovider-secret vault are different trust domains that happen to share
code, not a shared key.
middleware/src/credentials/postgresCredentialStore.ts: the durableCredentialStore, built the same way asPostgresGrantStore/PostgresAttachmentBindingStore— does not own the pool, and every methodis allowed to throw rather than swallow a failure into an empty/absent
result (a caller that turned "Postgres is unreachable" into "no active
grant" would make an outage indistinguishable from an honest revocation).
middleware/src/credentials/credentialStoreFactory.ts: explicitPostgres-vs-in-memory choice (
createCredentialStore(pool, key)), so the"vault no-pool case" the scoping prompt calls out by name is a stated
decision with its own tests, not an implicit fallback.
middleware/migrations/0040_credentials.sql:credentials+credential_grantstables. 0038 is reserved for the Satellites epic(Epic: Satellites — outbound-only edge nodes (Raspberry Pi class) that pair with a device code, run selected agents, and bridge internal-only systems securely into the main omadia #746); 0039 landed as
turn_receipts(Persist per-turn audit receipts (receipt store) #757) while this branch was inflight; this PR uses 0040 (verified free against current
origin/mainafter merging it in).
Design decision: a dedicated store, not a second
GrantStoreThe issue frames credential grants as flowing "through the existing
GrantStoremechanism, not alongside it." That reuse is real but happens atthe coarse layer: phase 2's broker will gate broker access itself behind
an ordinary capability (
credential:broker:use) resolved the normal #575way, through the same
GrantStoreevery other capability goes through.What
GrantStorecannot express is the fine layer this PR owns: acapability grant is a bare boolean with no expiry, no purpose, no
once-vs-standing distinction, and no per-grant revocation trail. The issue
asks for all four explicitly. Encoding that into a synthetic capability
string would either lose the metadata or smuggle it into the string itself
(which
resolveCapabilitiestreats as opaque). A dedicated table is not aparallel grant system — it's the metadata
GrantStore's shape has no roomfor. Full reasoning is in
credentials.ts's module header and themigration's comments.
Blast radius
packages/harness-channel-sdk/src/index.ts(barrel export block appendedat the end — pure addition, no existing export touched).
middleware/src/index.ts(the composition root) yet —that's phase 2's job, when the broker actually consumes the store. Phase 1
is inert in production: no route, no tool, no call site.
src/services/skill*,agentBuilder.ts,src/routes/admin.ts,or any existing route.
CREATE TABLE IF NOT EXISTS, idempotent indexes,rollback comment) and does not touch any existing table.
Tests
59 tests, all green:
credentialKeychainDataModel.test.ts,credentialCrypto.test.ts,credentialStoreFactory.test.ts,postgresCredentialStoreFailure.test.ts) — no external dependency.postgresCredentialStore.pg.test.ts),run locally against
pgvector/pgvector:pg17(omadia-dev-postgres-1);skips cleanly with a logged reason when no test DB is configured
(
GRAPH_PG_TEST_URL/MEMORY_PG_TEST_URL/DATABASE_URL, issue Two scratch-container port traps: 55438 and 55439 are both claimed by hardcoded test defaults #572).0040_credentials.sqlfile applies cleanly(idempotent,
BEGIN; ...; ROLLBACK;) against the real Postgres, not justthe test file's inline schema copy.
npm run build(full ordered workspace build) andnpx tsc --noEmitclean;
eslintclean on all new/changed files; ran the existingtest/audience*.test.tssuite (118 tests) unmodified to confirm theadditive channel-sdk export causes no regression.
Mutation-check evidence
Rebuilt
packages/harness-channel-sdk/distbetween every channel-sdkmutation (per the scoping prompt's warning — tests import the package via
its built
dist, not source, so an unrebuilt mutant is invisible).isGrantActive: expiry boundary<=→<principalsMatch: droppedcanonicalizePrincipalRefmakePrincipal, which already canonicalises at construction, so the test never exercised the store's own canonicalisation. Fixed the test to use a raw, non-canonicalPrincipalobject literal (the shape a row reconstructed from a differently-spelled external source would take); re-ran — now caught. Reverted the mutation, kept the strengthened test.PostgresCredentialStore.activeGrant: dropped theisGrantActivefilter over query resultsoncegrant) first, confirmed they pass on unmutated code, then re-applied the mutation: both new tests failed as expected.PostgresCredentialStore.revokeGrant: droppedAND revoked_at IS NULLtruethen expectedfalse, gottrue).PostgresCredentialStore.markGrantConsumed: droppedAND consumed_at IS NULLAll five mutations reverted; final state (this PR) is the code shown in the
diff, tests re-verified green.
Migration number
0040.
origin/mainat merge time has 0039 (turn_receipts, #757) as thehighest; 0038 is reserved for #746. If the parallel #577 session or another
PR lands 0040 first, I'll renumber before merge — flagging here per the
binding surface-separation rule.
Open questions for Marcel
host/injectionScheme/allowedMethods/pathPrefixes)are stored now but unenforced until phase 2 — confirms the phase cut, not
raising a concern, just flagging that a
servicecredential created todaycarries no actual egress restriction until then.
CredentialInjectionSchemeis'bearer' | 'header' | 'basic-password' | 'query-param'— a guess at what phase 2 will need based on common API auth schemes. Happy
to narrow/widen once the broker's actual requirements are clearer.
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.