Development record
abcd keeps every external credential the same way
Press Release
One credential store, three homes the person chooses once, and every adapter reads through it; no secret ever enters the harness or the repository.
"OpenRouter today, a hosting provider next month, my transcript cloud after that, and each one wanted a key somewhere," said a product thinker. "Now abcd asks me once per service where the secret should live, explains what it unlocks first, and every part of abcd reads it the same way. Nothing lands in the harness's settings. Nothing lands in the repo."
Why This Matters
The first adapter to need a secret was given its own walkthrough and its own file; the second was about to be. The record's rule is one canonical primitive, and a secret is the last thing to have three copies of. Ruled 2026-09-22 (adr-2609221017021499): one store, three homes, one reader, scanned before written.
Mechanism
We expect one store with one reader to make every secret abcd holds auditable in one place and impossible to commit by accident, because the only write path runs the scanner and the only read path is named; shown wrong if a secret is found in a harness file or a tracked path after this ships, or if an adapter is found reading one another way.
Scope Conditions
- Holds on macOS with the Keychain and on Linux with a secret service; a platform with neither offers the two other homes and says why.
What's In Scope
- The store (
internal/core/credential):Resolve(name)for every adapter;Set(name, home, value)used only by the walkthrough; homesexternal(a pointer to an existing tool's configuration or an environment variable name),abcd(~/.abcd/credentials.json, mode 0600),keychain(the platform keychain under abcd's service name). - The walkthrough at
ahoy, through itd-63's explain-then-install mode: what the credential unlocks, what works without it, then the three homes with the keychain recommended in prose, then a verification call the calling adapter supplies. - Refusals: a name that resolves to nothing refuses naming the walkthrough; a write that would land in a tracked path is refused; the write path runs the secret scanner.
- The readers: the API adapter, the site setup and any later hook resolve by name; a review finding of any other read is a defect.
- The record: the run record names which credential names a run used, never a value.
What's Out of Scope
- Rotating or expiring credentials.
- Sharing a credential between machines.
- Any credential the host itself holds for its own model.
Decisions
Ruled by the product thinker on 2026-09-21, in the interview that filed and planned this intent (adr-2609221017021499 records the vocabulary rulings it rests on):
- One store, three homes, one reader, scanned before written (adr-2609221017021499).
- The keychain is recommended in prose; the choice is the person's.
Open Questions
None open.
Acceptance Criteria
- Given an adapter needing a credential that is not set, when it resolves the name, then it refuses naming the walkthrough, and no unauthenticated call is made.
- Given the walkthrough accepted for a service, when it runs, then it explains what the credential unlocks and what works without it, offers the three homes with the keychain recommended in the prose above the choice, stores the value in the chosen home, and verifies with the adapter's own call.
- Given any home, when the tree and the harness's settings are inspected afterwards, then neither carries the value; a write that would land in a tracked path is refused and the write path runs the scanner.
- Given the API adapter and the site setup, when they read their credentials, then both call the store by name, and a test walks the adapters for any other read.
- Given a run, when its record is read, then it names the credential names used and no value.
Audit Notes
Fidelity review — receipt rcp-ebf7d171b544 (verifier intent-auditor (autonomous run A, lane audits12) claude-fable-5-1).
Provenance: intent-auditor (autonomous run A, lane audits12)@claude-fable-5-1 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:6e9160f5189342c4d26eb9c54cbfc77bd3949c40c312c4c13af6576db25d1eb2 Input attestations: diff:38301e724..5923c49a3 (feat/credential-store, merged into integ/land-14) plus 278e266d8, f16941a3b, 89e77b7e6, 03374c1b2@-; tree:baf6f8443 (origin/main, the audit's BASE; go test ./internal/core/credential/ -count=1: ok, 41 tests)@-;
Acceptance rollup: MET 1 · MET_WITH_CONCERNS 4 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET: Store.Resolve returns a notSetError that names the walkthrough (store.go:104-110, tested at store_test.go:182); the API adapter resolves the key before complete() and refuses on ErrNotSet with no call (call.go:63-67, 83-87; connect_test.go:173 asserts zero calls); the site setup's host stage stops at no_credential without contacting the provider (setup.go:706-710; setup_test.go:257 asserts an empty call log).
evidence: internal/core/credential/store.go:109 — "is not set on this machine%s;
%sexplains what it unlocks and stores it" evidence: internal/core/credential/store_test.go:182 — "func TestAnUnsetNameRefusesNamingTheWalkthrough(t *testing.T) {" evidence: internal/core/oracle/call.go:86 — "which is not set on this machine, so no call is made" evidence: internal/core/oracle/connect_test.go:173 — "if p.calls.Load() != 0 {" evidence: internal/core/site/setup.go:709 — "credential on this machine, so the host was not contacted" evidence: internal/core/site/setup_test.go:257 — "if n := len(h.host.CallLog()); n != 0 {" - ac-2 — MET_WITH_CONCERNS: Service.Explain gives what it unlocks, what works without it, HomesProse (the keychain recommended in prose, never marked) and the three homes (walk.go:22-52; store_test.go:484); Walk verifies with the adapter's own call and only then calls Set (walk.go:114-117; store_test.go:444); the CLI and the plugin page are wired (ahoy_credential.go:189, 116; commands/ahoy.md:462). CONCERN: the person's choice of home is a --home flag on the CLI and the host's question tool on the plugin page (ahoy_credential.go:132; commands/ahoy.md:483-484); the CLI never asks, though spc-2609221017544877 scope 2 says 'the CLI asks on the terminal' and the press release says abcd 'asks me once per service'. evidence: internal/core/credential/walk.go:22 — "const HomesProse = "Where the credential lives is your choice of three, made once. The platform keychain is " +" evidence: internal/core/credential/walk.go:43 — "func (s Service) Explain() []string {" evidence: internal/core/credential/walk.go:114 — "if err := s.Verify(ctx, value); err != nil {" evidence: internal/core/credential/walk.go:117 — "changed, err := Set(home, s.Name, c)" evidence: internal/core/credential/store_test.go:444 — "func TestTheWalkthroughVerifiesBeforeItStores(t *testing.T) {" evidence: internal/core/credential/store_test.go:484 — "func TestTheWalkthroughExplainsFirst(t *testing.T) {" evidence: internal/surface/cli/ahoy_credential.go:132 — "cmd.Flags().StringVar(&home, "home", "", "where the credential lives: external" evidence: commands/ahoy.md:483 — "ask the technical facilitator for the home through" evidence: .abcd/development/specs/closed/spc-2609221017544877-abcd-keeps-every-external-credential-the.md:17 — "the CLI asks on the terminal, the plugin page through the host's question tool (criterion 2)"
- ac-3 — MET_WITH_CONCERNS: After a Set in each home no file under the home, .claude/settings.json included, carries the value except the owner-only abcd file (store_test.go:332); the abcd home's write is refused when ~/.abcd lies inside a git working tree and a pointer at a file inside one is refused (store.go:223; store_test.go:201); the index write runs scanner.ScanText and a finding refuses it (store.go:464, 481; store_test.go:312). CONCERN: the scanner runs over the index alone; credentials.json, the file that holds the value, is written unscanned by construction (credential.go:237), and only the abcd home's value write is refused inside a working tree: the index (names, homes, pointers) is written there after its scan, a narrowing ruled at review (278e266d8) and stated on the plugin page (commands/ahoy.md:488-489), so 'the write path runs the scanner' and 'a write that would land in a tracked path is refused' hold for the value, not for every write.
evidence: internal/core/credential/store.go:223 — "if c.Home == HomeABCD && workingTreeAbove(home, ".abcd") != "" {"
evidence: internal/core/credential/store.go:481 — "findings := scanner.ScanText(string(body), scanner.Identity{}, scanner.DefaultPatterns(), nil, IndexFileName)"
evidence: internal/core/credential/credential.go:237 — "if err := fsutil.WriteFileAtomicInRoot(dir, StoreFileName, append(body, '\n'), 0o600); err != nil {"
evidence: internal/core/credential/store_test.go:201 — "func TestAValueIntoAWorkingTreeIsRefused(t *testing.T) {"
evidence: internal/core/credential/store_test.go:237 — "func TestAHomeThatIsAWorkingTreeKeepsTheOtherHomes(t *testing.T) {"
evidence: internal/core/credential/store_test.go:312 — "func TestTheIndexWriteRunsTheScanner(t *testing.T) {"
evidence: internal/core/credential/store_test.go:332 — "func TestNeitherTheTreeNorTheHarnessCarriesTheValue(t *testing.T) {"
evidence: commands/ahoy.md:488 — "The abcd home is refused when
~/.abcdlies inside a git" - ac-4 — MET_WITH_CONCERNS: The API adapter resolves p.Key through a credential.Source that defaults to credential.UserStore() (call.go:76-91) and the site setup's host stage resolves adapter.CredentialName() the same way (setup.go:702-706); TestEveryReaderGoesThroughTheStore walks every non-test .go file under cmd/ and internal/ for a direct store, keychain or secret-shaped-environment read and passes at BASE (readers_test.go:34-83, run: ok). CONCERNS: APIConfig.Call has no production caller at BASE, declared in its own header as awaiting spc-2609251028149555 (call.go:10-13; route.go:39-42), so the API adapter's store read runs only under test while the site setup's is live; and the walk is a line-regex drift grep that its own comment says is 'not an evasion gate' (readers_test.go:30), so an aliased import or a name held in a variable passes it. evidence: internal/core/oracle/call.go:83 — "key, err := creds.Resolve(p.Key)" evidence: internal/core/oracle/call.go:81 — "creds = credential.UserStore()" evidence: internal/core/site/setup.go:706 — "token, err := src.Resolve(adapter.CredentialName())" evidence: internal/core/credential/readers_test.go:34 — "func TestEveryReaderGoesThroughTheStore(t *testing.T) {" evidence: internal/core/credential/readers_test.go:30 — "// evasion gate: a new reader written the obvious way fails here, naming the" evidence: internal/core/oracle/call.go:10 — "// No delegating verb dispatches through it yet: sending a step whose route" evidence: internal/surface/cli/route.go:41 — "// handed to the verbs yet: a route resolved to a provider would name a leg no"
- ac-5 — MET_WITH_CONCERNS: CallRecord carries Credential, the name of the credential the call used and never a key (call.go:33-36, set at call.go:69), the route receipt carries it as provider_call (receipt.go:79-86), and HostOutcome.Credential names the credential the host stage resolved (setup.go:158-160, 701); the tests assert the name is present and the value absent from the marshalled record (call_test.go:199-221, 112-113; credential_service_test.go:22-44). CONCERN: at BASE nothing in production calls APIConfig.Call or ReceiptRoute.WithCall (call.go:10-13), so a route receipt's provider_call is always null in a real run and the site setup's HostOutcome is the only run record that names a credential today.
evidence: internal/core/oracle/call.go:36 — "Credential string
json:"credential,omitempty"" evidence: internal/core/oracle/call.go:69 — "rec.Credential = p.Key" evidence: internal/core/oracle/receipt.go:79 — "ProviderCall *CallRecordjson:"provider_call"" evidence: internal/core/site/setup.go:701 — "Credential: adapter.CredentialName()}" evidence: internal/core/oracle/call_test.go:199 — "func TestTheReceiptCarriesTheProviderCall(t *testing.T) {" evidence: internal/core/oracle/call_test.go:113 — "t.Fatal("the record carries the key")" evidence: internal/core/site/credential_service_test.go:41 — "t.Fatal("the result carries the credential's value")" evidence: internal/core/oracle/call.go:12 — "// is spc-2609251028149555's (AC 3). Until then the setup's verification call"
Gap audit:
- honoured:
- One store, three homes, one reader by name: Store(home).Resolve routes the abcd, keychain and external homes through one function evidence: internal/core/credential/store.go:88 — "func Store(home string) Source { return store{home: home} }" evidence: internal/core/credential/store_test.go:140 — "func TestStoreResolvesEveryHome(t *testing.T) {"
- A name that resolves to nothing refuses naming the walkthrough, and no unauthenticated call is made evidence: internal/core/credential/store.go:109 — "explains what it unlocks and stores it" evidence: internal/core/oracle/connect_test.go:173 — "if p.calls.Load() != 0 {"
- The keychain is recommended in the prose above the choice and never as a marked option evidence: internal/core/credential/walk.go:22 — "The platform keychain is" evidence: internal/core/credential/store_test.go:484 — "func TestTheWalkthroughExplainsFirst(t *testing.T) {"
- The value is verified with the adapter's own call before it is stored, and the walkthrough's result never carries it evidence: internal/core/credential/walk.go:114 — "if err := s.Verify(ctx, value); err != nil {" evidence: internal/core/credential/walk.go:54 — "// WalkResult is what a walkthrough did. It never carries the value."
- Nothing lands in the harness's settings or the repository: no file under the home but the owner-only abcd file carries the value after a Set in any home evidence: internal/core/credential/store_test.go:337 — "for _, p := range []string{".claude/settings.json", "work/repo/.claude/settings.json", "work/repo/README.md"} {"
- The keychain value never reaches an argv, and the tool runs from a fixed system path evidence: internal/core/credential/keychain.go:9 — "// The value never reaches an argv, which a process listing shows: security" evidence: internal/core/credential/store_test.go:361 — "func TestTheKeychainValueNeverReachesAnArgv(t *testing.T) {"
- The record names the credential name and no value evidence: internal/core/oracle/call.go:33 — "// Credential is the name of the credential the call used, never its" evidence: internal/core/site/setup.go:158 — "// Credential is the name of the credential the stage resolved, never"
- Wired on both front doors: the CLI sub-verb and the plugin page
evidence: internal/surface/cli/ahoy_credential.go:68 — "func newAhoyCredentialCommand(asJSON *bool) *cobra.Command {"
evidence: commands/ahoy.md:462 — "##
credential— the credential store's walkthrough"
- diverged:
- 'The write path runs the scanner' (ac-3; adr-2609221017021499 ruling 4 'on any file it touches'): delivered over the index alone; credentials.json, which holds the value, is written unscanned by construction evidence: internal/core/credential/store.go:476 — "// scanIndex runs the secret scanner over the index's bytes before they are" evidence: internal/core/credential/credential.go:237 — "fsutil.WriteFileAtomicInRoot(dir, StoreFileName, append(body, '\n'), 0o600)"
- 'A write that would land in a tracked path is refused' (ac-3): delivered for the abcd home's value only; the index is written inside a working tree after its scan, a narrowing ruled at review (278e266d8) and documented evidence: internal/core/credential/store.go:218 — "// The abcd home is the one home that writes a value under ~/.abcd, so it" evidence: commands/ahoy.md:489 — "working tree (the keychain and an external home stay open"
- 'abcd asks me once per service where the secret should live' and spec scope 2 'the CLI asks on the terminal' (ac-2): the CLI takes the home as a --home flag and never asks; only the plugin page asks, through the host's question tool evidence: internal/surface/cli/ahoy_credential.go:132 — "cmd.Flags().StringVar(&home, "home", ""," evidence: commands/ahoy.md:484 — "your question tool; never ask for the value, and never pass it yourself: give" evidence: .abcd/development/specs/closed/spc-2609221017544877-abcd-keeps-every-external-credential-the.md:17 — "the CLI asks on the terminal"
- 'The API adapter ... resolve by name' and 'the run record names which credential names a run used' (ac-4, ac-5): the provider call and its receipt entry have no production producer at BASE; both are reachable only from tests until provider dispatch (spc-2609251028149555) lands evidence: internal/core/oracle/call.go:10 — "// No delegating verb dispatches through it yet: sending a step whose route" evidence: internal/surface/cli/route.go:46 — "var machineConnections = func() oracle.Connections { return oracle.NoConnections{} }"
- missing:
- A real keychain round trip: the keychain home's write and read have only run against the test binary's fake, on macOS and Linux alike; the real security/secret-tool round trip is owed (iss-2609281654467661, open, deferred past v0.11.0) evidence: internal/core/credential/store_test.go:24 — "// No test here touches the real keychain: the keychain home runs a fake," evidence: .abcd/work/issues/open/iss-2609281654467661-the-macos-keychain-home-s-write-and-read.md:12 — "deferred_after: "v0.11.0""
Scope-condition dispositions:
- cond-2609221017547155 — narrowed: The no-keychain branch holds and is tested: locateKeychain refuses on a GOOS with no tool or a missing binary with errKeychainAbsent naming the external and abcd homes (keychain.go:44-69; store_test.go:380). The 'holds on macOS with the Keychain and on Linux with a secret service' half is exercised only through the test binary's fake of security and secret-tool; the real tools have never been run, which the lane captured and deferred (iss-2609281654467661). narrowing: Holds for a platform with neither tool (the two other homes are offered and the refusal says why), and for macOS and Linux only as far as the fake keychain's emulation of the security and secret-tool argv/stdin contract goes; a real round trip on either platform is unexercised (iss-2609281654467661, open, deferred past v0.11.0). evidence: internal/core/credential/keychain.go:45 — "var errKeychainAbsent = errors.New("credential: the keychain home needs the platform keychain's tool " +" evidence: internal/core/credential/keychain.go:57 — "switch runtime.GOOS {" evidence: internal/core/credential/store_test.go:380 — "func TestAPlatformWithoutAKeychainOffersTheOtherHomes(t *testing.T) {" evidence: .abcd/work/issues/open/iss-2609281654467661-the-macos-keychain-home-s-write-and-read.md:16 — "The macOS keychain home's write and read have never run against the real security tool."
Grounds
- pursued: three adapters are about to be built and each would otherwise invent its own store; we expect one store to be the one place a secret is audited; shown wrong if a secret is found in a harness file or a tracked path, or an adapter reads one another way
Frontmatter
| id | itd-2609221017023290 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | standalone |
| severity | minor |
.abcd/development/intents/shipped/itd-2609221017023290-abcd-keeps-every-external-credential-the.md
open on GitHub ↗ · commit history ↗
References4
- itd-6RepoPrompt over MCP is one opt-in reviewer route, beside the host's own agentsrelated
- itd-63The Amateur Coder Is Told What Is Being Installed And Why, Not Just Asked To Run A Commandbuilds on
- itd-2609061543533170One verb takes a managed repository's site from the checkout to a live addressrelated
- itd-2609081951381895abcd ships an OpenAI-compatible API adapter, and a provider serves only the models it listsrelated
Referenced by3
- adr-2609221017021499Every external credential abcd holds is named in configuration and kept in one store the person chose; never the harness, never the repositoryrelated
- itd-2610161621364075The agents abcd launches on its own work inside a sandbox the operating system enforcesrelated
- spc-2609221017544877abcd-keeps-every-external-credential-the-same-way-oneimplemented by
mentions8
- dcn-26092200000000012026-09-22 — Model routing, after an independent research pass (product thinker, at an interactive interview). Jev (TypeSafe AI, on OpenRouter) is a typed-decision model, not a router: it fits abcd's closed-option judgements and nothing generative. Rulings: a provider adapter serves only the models it lists under a vendor denylist no listing overrides, everything else on the host (adr-2609221009491186); the OpenAI-compatible API adapter is planned with OpenRouter as configuration, a one-time walkthrough at ahoy and the key kept out of the harness (itd-2609081951381895); a decision adapter for the typed judgements runs in shadow as an abcd lab and a research note is the evidence a ruling turns a judgement type on with (itd-2609221009495079); escalation inside a lane is a rule on a failed fix round, recorded on the model tier (itd-2609170822093401); model per role, the pick and consistency are not routing and stand as ruled; itd-17's learned router is superseded. Every external credential abcd holds goes through one store with three homes the person chooses (adr-2609221017021499, itd-2609221017023290); the API adapter and the site setup read through it. The research is filed as research/notes/2026-09-22-jev-and-model-routing-sota.md.
- iss-2608231607594913Cloudflare Git-integration branch builds run for pull request branches even though wrangler.jsonc records them as disabled, so an unmerged commit reaches a third-party build system before review. wrangler.jsonc carries 'automatic production builds: disabled' and 'automatic branch builds: disabled', kept in-tree because the dashboard setting has no other durable record. On 2026-08-23 a Cloudflare build ran for a pull request branch at 10:00 UTC and the integration bot posted a successful-deployment comment naming the build id. That build was not Actions: .github/workflows/site.yml triggers on workflow_call, workflow_dispatch and push to main only, and it did not run on that commit; the workflow header itself states that the main-push preview replaces the Cloudflare branch builds that ran only the version command. Two consequences. Operationally, every pull request commit is cloned by a third party before merge, and Cloudflare exposes no delete endpoint for a build record or its logs, so whatever reaches a build log cannot be withdrawn without a vendor support request. Durably, the in-tree record is wrong in the confident direction: a reader checking whether branch builds run gets a clear no. Suggested direction: reconcile the dashboard setting with wrangler.jsonc and decide which is authoritative. A maintainer decides; this record reports.
- iss-2609281654467661The macOS keychain home's write and read have never run against the real security tool. Set stores a value through 'security -i' with the value hex-encoded behind -X on one stdin line (internal/core/credential/keychain.go), and Resolve reads it back with find-generic-password -w; a MaxValueBytes value (4096 bytes) makes that line about 8 KiB. Only the test binary's fake keychain (store_test.go TestMain) has proven the -X in, -w out round trip. If security's non-terminal line reader splits or truncates a long line, the read-back check after the write refuses loudly and echoes nothing, but a wrong item stays in the keychain for the person to remove by hand. One real-machine round trip is owed: a value of MaxValueBytes set through the keychain home, read back byte for byte, then the item removed. Surfaced by the review of the credential store lane (review-cred finding 5, unverified).
- iss-2609290825240166itd-2609221017023290 ac-3 promises that the store's write path runs the scanner and refuses a write that would land in a tracked path, and adr-2609221017021499 ruling 4 says the scanner runs on any file the write path touches; the delivered store scans the index (~/.abcd/credential-homes.json) alone, writes ~/.abcd/credentials.json, the file that holds the value, unscanned by construction (internal/core/credential/credential.go SetMachine), and refuses only the abcd home's value write inside a git working tree while the index is still written there (internal/core/credential/store.go Set, ruled at review in 278e266d8 and stated on commands/ahoy.md). The invariant the intent guards, no value in a tracked path or the harness's settings, holds and is tested; the record's wording does not match what ships, and a scan of the value file is unsatisfiable, since a real key is exactly what the scanner flags. The ADR and the intent should say the index is what is scanned and the value write is what is refused, or a ruling should say why the wording stands. Fidelity audit verdict: ac-3 MET_WITH_CONCERNS.
- iss-2609290825319136itd-2609221017023290 ac-2 promises a walkthrough that offers the three homes with the keychain recommended in the prose above the choice; its press release says abcd asks the person once per service where the secret should live, and spc-2609221017544877 scope 2 says the CLI asks on the terminal and the plugin page asks through the host's question tool. Delivered, the CLI's abcd ahoy credential <name> explains and lists the homes with a setup command for each and writes nothing, and the choice is a --home flag on a second invocation (internal/surface/cli/ahoy_credential.go newAhoyCredentialCommand); the CLI never asks. Only the plugin page asks, through the host's question tool (commands/ahoy.md, the credential section). A non-interactive CLI is consistent with every other abcd verb and with the value arriving on stdin only, so this may be the right shape, but the spec and the press release say otherwise and no recorded decision says the CLI's ask was dropped. Either the record says the CLI takes the home as a flag, or the walkthrough asks. Fidelity audit verdict: ac-2 MET_WITH_CONCERNS.
- itd-2610030821294016Connecting a model service from inside your coding session looks up the details and offers the choices
- spc-2609251028149555A failed fix round escalates one tier, and a provider route stays on its allowlist
- spc-2610031241482088connecting-a-model-service-from-inside-claude-code-works-out