Преглед изворни кода

Merge pull request #2509 from deepseek-harness/feat/pi-ai-auth-alignment

feat(credentials): abstract credentials service and support OAuth login
Yichen Jiang пре 1 месец
родитељ
комит
aef4e1c9b7
100 измењених фајлова са 4404 додато и 299 уклоњено
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml
  2. 2 2
      .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
  3. 2 2
      .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml
  5. 2 2
      .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md
  6. 2 2
      .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml
  8. 68 0
      .agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md
  9. 68 0
      .agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md
  10. 2 2
      .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml
  11. 2 0
      .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md
  12. 2 0
      .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md
  13. 1 1
      AGENTS.md
  14. 1 1
      apps/cli/tests/built-bin.e2e.ts
  15. 1 0
      apps/web/tests/snapshots/models-settings/empty.expected.md
  16. 1 0
      apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md
  17. 2 2
      docs/capability-seams.i18n.yaml
  18. 5 0
      docs/capability-seams.md
  19. 5 0
      docs/capability-seams.zh.md
  20. 2 2
      docs/config-catalog.i18n.yaml
  21. 2 1
      docs/config-catalog.md
  22. 2 1
      docs/config-catalog.zh.md
  23. 2 2
      docs/event-producer-consumer.i18n.yaml
  24. 3 1
      docs/event-producer-consumer.md
  25. 3 1
      docs/event-producer-consumer.zh.md
  26. 2 2
      docs/module-graph.i18n.yaml
  27. 14 8
      docs/module-graph.md
  28. 14 8
      docs/module-graph.zh.md
  29. 2 2
      docs/subsystems/credentials.i18n.yaml
  30. 162 7
      docs/subsystems/credentials.md
  31. 162 7
      docs/subsystems/credentials.zh.md
  32. 2 2
      packages/README.i18n.yaml
  33. 1 1
      packages/README.md
  34. 1 1
      packages/README.zh.md
  35. 1 1
      packages/api/remotes/src/remote-events.ts
  36. 3 3
      packages/client/runtime/tests/wire-events.client.spec.ts
  37. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  38. 0 0
      packages/client/ui-settings-models/README.md
  39. 0 0
      packages/client/ui-settings-models/README.zh.md
  40. 1 1
      packages/client/ui-settings-models/src/client/index.ts
  41. 2 2
      packages/client/ui-settings-models/tests/apply.client.spec.ts
  42. 2 2
      packages/client/ui-settings-plugins/README.i18n.yaml
  43. 1 1
      packages/client/ui-settings-plugins/README.md
  44. 1 1
      packages/client/ui-settings-plugins/README.zh.md
  45. 1 1
      packages/client/ui-settings-plugins/src/client/index.ts
  46. 2 2
      packages/client/ui-settings-plugins/tests/apply.client.spec.ts
  47. 2 2
      packages/credentials/README.i18n.yaml
  48. 5 4
      packages/credentials/README.md
  49. 5 4
      packages/credentials/README.zh.md
  50. 6 0
      packages/credentials/authorization/README.i18n.yaml
  51. 76 0
      packages/credentials/authorization/README.md
  52. 76 0
      packages/credentials/authorization/README.zh.md
  53. 51 0
      packages/credentials/authorization/package.json
  54. 437 0
      packages/credentials/authorization/src/index.ts
  55. 45 0
      packages/credentials/authorization/src/invariant.ts
  56. 91 0
      packages/credentials/authorization/src/types.ts
  57. 465 0
      packages/credentials/authorization/tests/authorization.spec.ts
  58. 87 0
      packages/credentials/authorization/tests/invariant.spec.ts
  59. 70 0
      packages/credentials/authorization/tests/memory.ts
  60. 27 0
      packages/credentials/authorization/tsconfig.json
  61. 2 2
      packages/credentials/credentials-local/README.i18n.yaml
  62. 27 5
      packages/credentials/credentials-local/README.md
  63. 27 5
      packages/credentials/credentials-local/README.zh.md
  64. 471 32
      packages/credentials/credentials-local/src/index.ts
  65. 1 1
      packages/credentials/credentials-local/src/invariant.ts
  66. 31 1
      packages/credentials/credentials-local/tests/drain.spec.ts
  67. 64 25
      packages/credentials/credentials-local/tests/local.spec.ts
  68. 140 0
      packages/credentials/credentials-local/tests/migration.spec.ts
  69. 349 0
      packages/credentials/credentials-local/tests/records.spec.ts
  70. 43 11
      packages/credentials/credentials-local/tests/review-fixes.spec.ts
  71. 18 18
      packages/credentials/credentials-local/tests/watcher.spec.ts
  72. 2 2
      packages/credentials/credentials/README.i18n.yaml
  73. 26 4
      packages/credentials/credentials/README.md
  74. 26 4
      packages/credentials/credentials/README.zh.md
  75. 195 17
      packages/credentials/credentials/src/index.ts
  76. 3 3
      packages/credentials/credentials/src/invariant.ts
  77. 63 5
      packages/credentials/credentials/src/types.ts
  78. 16 3
      packages/credentials/credentials/tests/credentials.spec.ts
  79. 1 1
      packages/credentials/credentials/tests/invariant.spec.ts
  80. 46 3
      packages/credentials/credentials/tests/memory.ts
  81. 159 4
      packages/extensions/tool-cordis/src/api-catalog.ts
  82. 2 2
      packages/host/apiproxy/README.i18n.yaml
  83. 0 0
      packages/host/apiproxy/README.md
  84. 0 0
      packages/host/apiproxy/README.zh.md
  85. 38 5
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  86. 1 1
      packages/llm/llm-deepseek/tests/adapter.e2e.ts
  87. 2 2
      packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
  88. 2 2
      packages/llm/llm-deepseek/tests/loader-composition.spec.ts
  89. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  90. 3 3
      packages/llm/llm-pi-ai/README.md
  91. 3 3
      packages/llm/llm-pi-ai/README.zh.md
  92. 2 0
      packages/llm/llm-pi-ai/package.json
  93. 29 5
      packages/llm/llm-pi-ai/src/adapter.ts
  94. 207 0
      packages/llm/llm-pi-ai/src/auth.ts
  95. 0 20
      packages/llm/llm-pi-ai/src/catalog.ts
  96. 20 18
      packages/llm/llm-pi-ai/src/index.ts
  97. 161 0
      packages/llm/llm-pi-ai/src/login.ts
  98. 2 0
      packages/llm/llm-pi-ai/tests/adapter.spec.ts
  99. 39 0
      packages/llm/llm-pi-ai/tests/auth-double.ts
  100. 172 0
      packages/llm/llm-pi-ai/tests/auth.spec.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md
-2026-07-30-credential-boundaries-and-atomic-registration.md: 69ddfdcf3cd6c15218c672d43cc5aadfd48a0223
-2026-07-30-credential-boundaries-and-atomic-registration.zh.md: d234a073467c743aa90e8e60f1f96f4d288fd651
+2026-07-30-credential-boundaries-and-atomic-registration.md: 95c16ef2bf3d82d7b8b53e3975ece3f0c63402f3
+2026-07-30-credential-boundaries-and-atomic-registration.zh.md: 56ce0d8cbfc0f9023aca406ad43797e7347fdc77

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.md

@@ -8,7 +8,7 @@ English | [中文](2026-07-30-credential-boundaries-and-atomic-registration.zh.m
 
 ## Problem
 
-The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
+The credential path leaked across boundaries it had drawn. The shipped surfaces hoisted `$DSH_HOME/.env` into `process.env` before cordis booted, so on the next run `credentials-local` classified every key it had stored itself as a read-only ambient launch override: `describe()` reported `source: 'env'` with `writable: false`, `set`/`unset` rejected as shadowed, and a key stored from the web page or TUI became unrotatable and undeletable while the adapter kept using the value captured at launch. The store's own write path repeated the settings-file defects the settings write-path note fixes (two independent chains, whole-file render from a stale cache), plus editor bugs of its own: a physical line inside another key's quoted multi-line value read as an assignment, CRLF endings degraded to LF, a multi-line entry reported `writable: true` while `set` always threw, and `credentials/reference-updated` was emitted bare after the commit, so one broken observer made a durable write look failed. On the read side, the file's `0600` mode stops other OS users but not the model, whose bash and filesystem tools run as the same user.
 
 Two request-path defects sat beside them. DeepSeek resolved connection and credential facts independently, so a settings generation the resolver rejected could still pair its credential choice with the previous generation's endpoint. pi-ai handed the SDK `undefined` when a configured `apiKeyEnv` resolved to nothing, letting pi-ai's own environment discovery authenticate with an unrelated provider key — another tenant, silently billed. And its route swap disposed the old registration before creating the new one: a route another adapter owned dropped every existing route, after which the facts cache could equal the registry's, so restoring the working configuration never re-applied.
 
@@ -22,7 +22,7 @@ Two request-path defects sat beside them. DeepSeek resolved connection and crede
 
 **Route replacement is a registry operation, not a caller sequence.** `registerAdapter` returns a handle carrying `replace(providers)`: the candidate set is validated in full first (conflicts, names, provider metadata), then swapped in one synchronous section. A refused replacement leaves the previous routes registered and serving, and the caller's facts cache only advances after the registry actually holds the new set, so reverting to a working configuration re-applies. pi-ai's registration facts are sorted by provider, so a settings document that merely reorders its keys is no longer a route change.
 
-**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
+**Contained publication for committed credential writes.** `CredentialProvider.notifyUpdated` fans `credentials/reference-updated` out one listener at a time; sync throws and async rejections are logged without changing the committed operation's outcome, and `INVARIANT`-coded failures rethrow after every listener ran — the same shape the settings seam uses for `settings/updated`. `installSettingsSection`'s cleanup now distinguishes its two triggers: a provider detaching still falls back to the composition entry and re-derives, while the consumer's own unload returns immediately instead of re-registering routes during teardown.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-30-credential-boundaries-and-atomic-registration.zh.md

@@ -10,7 +10,7 @@ Status: implemented
 
 凭据路径越过它自己划下的边界发生了泄漏。已交付的各个面在 Cordis 启动之前就把 `$DSH_HOME/.env` 提升进了 `process.env`,于是下一次运行时,`credentials-local` 会把它自己存下的每个键都判成来自环境的只读启动覆盖:`describe()` 报告 `source: 'env'` 且 `writable: false`,`set`/`unset` 以被遮蔽为由拒绝,从 web 页面或 TUI 存入的密钥既无法轮换也无法删除,而适配器还在继续使用启动时捕获的那个值。
 
-存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
+存储自身的写路径重演了 settings 写路径 note 在 settings-file 修掉的那些缺陷(两条相互独立的链、从陈旧缓存渲染整份文件),还叠加了编辑器自己的缺陷:另一个键的带引号多行值内部的一条物理行会被读成赋值,CRLF 行尾会退化成 LF,多行条目报告 `writable: true` 而 `set` 总是抛错,`credentials/reference-updated` 又在提交之后裸发,于是一个出错的观察者就能让一次已经落盘的写入看起来失败。
 
 在读取一侧,文件的 `0600` 权限挡得住其他 OS 用户,却挡不住模型:它的 bash 与文件系统工具就以同一个用户身份运行。
 
@@ -26,7 +26,7 @@ Status: implemented
 
 **路由替换是注册表的操作,不是调用方的一串步骤。**`registerAdapter` 返回一个携带 `replace(providers)` 的句柄:候选集合先被完整校验(冲突、名称、提供方元数据),再在一个同步区段内完成替换。被拒绝的替换会让先前的路由保持注册并继续服务,而调用方的事实缓存只有在注册表确实持有新集合之后才会推进,因此改回可用配置时会重新生效。pi-ai 的注册事实按提供方排序,因此仅仅调换键顺序的设置文档不再算作路由变更。
 
-**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
+**已提交的凭据写入采用收容式发布。**`CredentialProvider.notifyUpdated` 逐个监听器扇出 `credentials/reference-updated`;同步抛错与异步 rejection 都只记日志,不改变已提交操作的结果,而带 `INVARIANT` 代码的失败会在每个监听器都运行完之后重抛——与 settings seam 处理 `settings/updated` 的形状相同。`installSettingsSection` 的清理现在会区分它的两个触发来源:提供方脱离时仍回退到组合的 entry 配置并重新推导,而消费方自身卸载时立即返回,不再在拆卸过程中重新注册路由。
 
 ## 曾考虑的替代方案
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md
-2026-08-10-remote-event-delivery.md: 9c2b5087772a5a343514d1766a14e90edb261813
-2026-08-10-remote-event-delivery.zh.md: 213715f5d9efcc11290059e5c5b0c06bbd7e255d
+2026-08-10-remote-event-delivery.md: ee3d9884b53f5fa5d0b0072660888c5f4d283b1b
+2026-08-10-remote-event-delivery.zh.md: a51a0ade174e70177f438a4bbc6bcd452807f436

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md

@@ -8,7 +8,7 @@ English | [中文](2026-08-10-remote-event-delivery.zh.md)
 
 [Typert Gateway targeted method calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) cover only the request/response shape and deliberately leave Session event streams and stateful interactions to separate designs. Every **one-way Host-to-consumer push** therefore still rides the legacy API Proxy.
 
-The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
+The Host owns a family of one-way events whose payloads are already JSON and whose emission never binds an AgentScope: `agent-preset/selected`, `commands/change`, `credentials/reference-updated`, `llm/adapters-updated`, and `settings/document-updated`. Reaching one UI subscriber took four hops: the Host cordis event, a hand-written `HostFrame` variant plus its zod branch in apiproxy, a hand-written bridge in client/runtime that re-emitted it as a Client cordis event, and finally the consumer's `ctx.on(...)`. Adding one such event edited five places (frame union, zod union, host-stream listener, client bridge, a duplicated Client-side `Events` declaration), and not one of them stated a new fact: the name, the payload type, and the emission point were all declared by the owner package's cordis `Events` merge.
 
 That duplicated declaration is also **lossy**: the Client side restates it as `settings/changed(ns: string)`, flattening a branded type into bare `string` — the opposite of the Remote method contract, where a consumer type points at the business package's one canonical symbol.
 
@@ -75,7 +75,7 @@ Delivery shares no implementation with the cordis event system: one-way only, no
 export const API_REMOTE_FORWARDED_EVENTS = [
   'agent-preset/selected',
   'commands/change',
-  'credentials/updated',
+  'credentials/reference-updated',
   'llm/adapters-updated',
   'settings/document-updated',
 ] as const

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md

@@ -8,7 +8,7 @@ Status: implemented
 
 [Typert Remote 方法调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md)只覆盖「一次请求一个结果」的定向调用,明确把 Session 事件流与有状态交互留在别处;Host 向消费端的**单向事件推送**因此仍然全部压在遗留的 API Proxy 上。
 
-Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
+Host 拥有 `agent-preset/selected`、`commands/change`、`credentials/reference-updated`、`llm/adapters-updated`、`settings/document-updated` 这五条单向事件;它们既不依赖 AgentScope,载荷也本来就是 JSON。过去每条都要穿过 host cordis 事件、apiproxy 手写帧、client/runtime 手写桥和 Client 事件别名才能抵达 UI,而这些层没有陈述 owner 事件之外的新事实。
 
 那份重复声明还是**有损**的:client 侧写成 `settings/changed(ns: string)`,brand 类型在这一跳被拍平成裸 `string`,与 Remote 方法侧「消费端类型指向业务包唯一符号」的既有契约相反。
 
@@ -75,7 +75,7 @@ $dispatch(event: string, args: readonly unknown[]): void
 export const API_REMOTE_FORWARDED_EVENTS = [
   'agent-preset/selected',
   'commands/change',
-  'credentials/updated',
+  'credentials/reference-updated',
   'llm/adapters-updated',
   'settings/document-updated',
 ] as const

+ 6 - 0
.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md
+2026-08-13-credential-records-and-authorization-flows.md: a52d85854c8c660b4965d07a13a2dbfefbac5723
+2026-08-13-credential-records-and-authorization-flows.zh.md: 1b6960cd9cff5e7320816a1528e3c621fea41691

+ 68 - 0
.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.md

@@ -0,0 +1,68 @@
+# Agent Note: Credential records and authorization flows
+
+Status: implemented
+
+English | [中文](2026-08-13-credential-records-and-authorization-flows.zh.md)
+
+## Problem
+
+The harness credential plane could only express one kind of secret: a value behind an environment-variable name. `CredentialRef` is a POSIX identifier, resolution layers the process environment over a managed file and `.env` fallbacks, and every consumer reads it per operation. That covers an API key exactly and covers nothing else.
+
+Some credentials are not values a deployment can be told to store. They are obtained — by a conversation with a human who opens a page, approves an account, and pastes a code back — and what comes out is a token document with a refresh half that rotates behind the user's back. pi-ai models this directly (`Credential = ApiKeyCredential | OAuthCredential`, an app-owned `CredentialStore`, `Models.login()`), and the harness had nowhere to put any of it. `PiAiAdapter` built its collection with `createModels()` and no options, so the store was pi-ai's in-memory default: empty at every boot, discarded on every configuration change. `openai-codex`, whose only method is OAuth, therefore failed every request with `Provider is not configured` — [withheld from the directory](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) as a release fix, which removed the broken offer without adding the capability.
+
+Two further gaps followed from the same missing plane. A provider's own ambient discovery ran against the raw process environment, so a key held by the credential seam was invisible to it and a local credential file was never even looked for. And a login had no surface to run from, because nothing in the harness could ask a human a question on a plugin's behalf.
+
+## Decision
+
+Three seams, each owning one question, and every pi-ai concept behind an adapter inside `llm-pi-ai`.
+
+**`dsh-credentials` grows a second key space.** A `CredentialRef` answers *what is behind this environment-variable name*; a `CredentialKey` answers *what credential does this plugin hold for this id*. The record union is `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }` — the api-key half structural because the seam can describe it, the grant half opaque because a library that owns a token format keeps owning it. The only constraint on a payload is that it survives a JSON round trip, enforced on the way in and on the way out.
+
+The key is `<scope>/<id>` where the scope is the **owning plugin's registered name**, not the provider's. A user knows `openai-codex`; which adapter family answers for the bytes inside that record is exactly what a bare provider name loses. Two plugins serving the same provider name would read each other's payload, and a record left by an uninstalled plugin could not be told from a live one. The `/` also keeps the two grammars disjoint, so the key spaces cannot collide. This assumes one adapter registers a given provider route, which the LLM registry already enforces.
+
+Records do not layer. There is no environment an authorization grant could be read from, so presence of the record is the whole fact, and the empty-value rule that governs references does not apply: an `api-key` record carrying neither a key nor env states that its owner confirmed ambient authentication, which is configured.
+
+**`dsh-authorization` owns the conversation, never the protocol.** A plugin that knows how to obtain its own credential registers a flow under the `CredentialKey` that flow writes. The seam runs one attempt per key, routes a neutral vocabulary of notices and prompts, and settles. A second authorization protocol arrives as another flow rather than as another seam, and a surface that renders one flow renders all of them.
+
+Two choices carry the weight:
+
+- **The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. This is what lets `Models.login()` — which persists through the store adapter as part of logging in — stay the single writer, instead of the credential being copied back out and written a second time.
+- **The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the page that asked, a headless caller supplies an interaction that declines, and there is no ambient provider to be absent or ambiguous between two open tabs.
+
+**`llm-pi-ai` holds all three translations.** `credentialStoreFrom` maps pi-ai's `CredentialStore` onto records; `authContextFrom` answers pi-ai's ambient questions from the credential seam, then the launch environment, with file existence checked against the host process's filesystem; `registerPiAiFlows` restates pi-ai's `AuthEvent`/`AuthPrompt` in the neutral vocabulary and runs `Models.login()`. Every collection is built with the first two, which is what keeps a signed-in provider signed in across the collection rebuild a configuration change causes. With a posture that works, the directory stops withholding OAuth-only routes and `openai-codex` is offered again.
+
+The credential plane stays optional, as it already was for reference resolution. Reads answer "nothing stored" without a credentials service, because such a composition genuinely holds no credential; writes refuse by name, because a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam through `ctx.inject`, so a headless or ACP composition mounts with no sign-in and nothing else changed.
+
+### Two mechanisms the seams needed underneath
+
+`withFileLock` takes a per-call wait limit. pi-ai runs an OAuth refresh *inside* `credentials.modify()`, so the record write path holds the lock across a network round trip; the 2s default was chosen for a render-and-rename and would fail every other writer of the document. The retry cadence stays fixed — that is a protocol constant — while the wait is sized by the longest holder a contender can meet: refs and records share one file and one lock, so every writer of the document (`DOCUMENT_LOCK_WAIT_MS`, reference writes and record deletes included) waits an OAuth refresh out, not only the mutation that runs one.
+
+The seam's edges get the same discipline as its write path. A prompt decline is an outcome, not a breakage — an interaction rejects with `AuthorizationDeclinedError` and the attempt settles `cancelled` — while a notice a surface cannot render is logged and lost rather than failing the flow, and `authorization/settled` fans out with contained listener failures on the credentials seam's terms. On the store side, an api-key record is admitted before it is rendered (what `parseRecord` refuses at the next boot is refused at the write), and `llm-pi-ai` asks `isCredentialKeySegment` before addressing a record, so an arbitrary hand-declared route key reads as "nothing stored" instead of throwing mid-resolution.
+
+Withdrawal settles an attempt whether or not its flow reacts to the signal. A flow is supposed to stop when its signal fires, but one that does not would hold its key for the life of the process, and a wedged key is indistinguishable from a busy one from outside. The orphaned run is left to finish on its own.
+
+## Alternatives considered
+
+- **Putting the pi-ai `CredentialStore` shape into the seam itself.** It is the shape that works and it is already designed. It also names `api_key`/`oauth` as the world's two credential kinds and keys by provider id, which is the ownership loss above; a second adapter family would have to pretend to be pi-ai to participate. The record union is deliberately one step more abstract in exactly two places — the key, and the opacity of a grant.
+- **A dedicated login-interaction seam beside `user-questions`.** Authorization prompts look like questions, and reusing `ctx.userQuestions` was tempting. But that seam is built for a model's tool call to pause on an agent's behalf: it validates the calling agent, refuses a delegated caller, and has one ambient UI provider. An authorization prompt has no agent, must reach the configuration page that started it, and can be withdrawn per prompt by a browser callback winning a race. The vocabularies overlap; the lifecycles do not.
+- **Reading `~/.codex/auth.json` into a store.** It makes Codex work without any of this, and pi-ai would own the refresh. It also binds the harness to another tool's private file format for one provider, and leaves every other login unbuilt.
+- **Joining a second `begin()` to the attempt already running.** Friendlier than refusing, until two humans are answering the same flow's questions. Refusal with `inFlight` on the entry lets a surface disable the button rather than discover the state by error.
+- **Keeping the OAuth-only withholding as a safety net.** It would now hide a provider that works. The predicate is deleted rather than left inert; `docs/subsystems/credentials.md` and the package READMEs carry what replaced it.
+
+## Consequences
+
+`.credentials.yaml` gains a version and two sections. A boot upgrades the recognized pre-release flat layout in place — an all-string flat mapping nests verbatim under `refs:` under the writer lock — because a key stored through the Models page by an earlier internal build must survive the layout change without a hand edit and without its model requests failing. Any flat shape the recognizer cannot prove it understands keeps the by-name refusal with the hand migration stated in the message; the parser itself still reads exactly one layout, and the migration step retires with the pre-release stance at the first tagged release. Every fixture in the repo that wrote the flat document was rewritten; the llm suites' fixtures were missed by the record change itself and fixed here.
+
+`openai-codex` returns to the provider picker and to the Models page directory. Signing in is offered for every installed provider that ships a login, which today is all 38 — 31 collect a key through pi-ai's own prompt, six offer that beside a subscription login, and Codex offers only the subscription login.
+
+What this does not yet include is the surface: the wire contract that carries notices and prompts to the browser, and the Models-page control that starts a login. Until that lands, the flows are reachable only in-process, and a deployment still configures a key by typing it into the settings form.
+
+Two limits are recorded in the package READMEs rather than fixed. An attempt is not durable, so reloading the page mid-login abandons it. And signing out is `deleteRecord`, which forgets the record locally without telling the issuer; a provider needing a server-side revoke has nowhere to declare it.
+
+## Testing
+
+The seam's suite pins the lifecycle it owns: single-flight refusal and release, withdrawal before the flow starts and during it, a flow that ignores its signal, the commit confirmation, and the settlement event including the `failed` case a caller sees as a thrown error. The invariant companion pins that a settled key is a free key, because a wedged one is otherwise invisible.
+
+`llm-pi-ai` covers the three translations against a real `$DSH_HOME` document — an api-key credential field by field, an OAuth credential verbatim including its refresh half, a foreign plugin's record skipped by scope, and the write refusal without a credentials service — plus every `AuthEvent` and `AuthPrompt` member restated, with `Models.login()` mocked at the collection boundary since a real one opens a browser. Two real-composition tests boot the plugin with and without the authorization seam.
+
+The `models-settings` and `onboarding-usable-provider` web e2e goldens regain exactly the `openai-codex` option line they lost when it was withheld — the whole assembled-application difference this change makes today, because the Models page has no login control yet to record.

+ 68 - 0
.agents/notes/implemented/architecture/2026-08-13-credential-records-and-authorization-flows.zh.md

@@ -0,0 +1,68 @@
+# Agent Note: 凭据记录与授权 flow
+
+Status: implemented
+
+[English](2026-08-13-credential-records-and-authorization-flows.md) | 中文
+
+## Problem
+
+harness 的凭据平面只能表达一种机密:藏在某个环境变量名之后的值。`CredentialRef` 是一个 POSIX 标识符,解析时按进程环境、受管文件、`.env` 回退分层,每个消费方按操作读取。这恰好覆盖 API key,此外什么都不覆盖。
+
+有些凭据不是"可以让部署方去存"的值。它们是被**取得**的——与人对话:对方打开页面、批准账号、把码粘回来——产出的是一份带 refresh 半边、会在用户背后轮换的 token 文档。pi-ai 直接建模了这一点(`Credential = ApiKeyCredential | OAuthCredential`、由应用拥有的 `CredentialStore`、`Models.login()`),而 harness 无处安放其中任何一项。`PiAiAdapter` 用不带参数的 `createModels()` 构造集合,于是那个 store 就是 pi-ai 的内存默认实现:每次启动为空,每次配置变更被丢弃。只以 OAuth 认证的 `openai-codex` 因此每个请求都以 `Provider is not configured` 失败——[被目录withheld](../bug-fix/2026-08-13-oauth-only-providers-withheld.md) 作为发布前修复,它移除了错误的供给,但没有补上能力。
+
+同一处缺失还带来另外两个缺口。提供方自带的凭据发现是对着裸进程环境跑的,因此凭据 seam 保管的密钥对它不可见,本地凭据文件更是从未被查找过。而登录没有任何界面可以发起,因为 harness 里没有任何东西能代替插件向人发问。
+
+## Decision
+
+三个 seam,各自拥有一个问题;所有 pi-ai 概念都藏在 `llm-pi-ai` 内部的适配器背后。
+
+**`dsh-credentials` 长出第二个键空间。** `CredentialRef` 回答*这个环境变量名背后是什么*;`CredentialKey` 回答*这个插件为这个 id 持有什么凭据*。记录联合体是 `{ kind: 'api-key', key?, env? } | { kind: 'grant', payload }`——api-key 那半是结构化的,因为 seam 能描述它;grant 那半是不透明的,因为拥有 token 格式的库应当继续拥有它。对 payload 的唯一约束是它能原样通过一次 JSON 往返,读写两个方向都会校验。
+
+键的形式是 `<scope>/<id>`,其中 scope 是**拥有该记录的插件的注册名**,不是提供方名。用户知道的是 `openai-codex`;究竟哪个 adapter 家族为记录里的字节负责,恰恰是裸提供方名会丢掉的信息。服务同一个提供方名的两个插件会互相读到对方的 payload,已卸载插件留下的记录也无法与仍在使用的区分开。`/` 同时让两种文法互斥,两个键空间因此不可能相撞。这以"同一个 provider 路由只由一个 adapter 注册"为前提,而 LLM 注册表本就强制了这一点。
+
+记录不分层。授权 grant 没有任何"环境"可供读取,因此记录是否存在就是全部事实,管辖引用的空值规则在此不适用:一条既无 key 也无 env 的 `api-key` 记录,陈述的是其拥有者确认了环境认证可用,这属于已配置。
+
+**`dsh-authorization` 拥有对话,从不拥有协议。** 知道如何取得自己那份凭据的插件,以该 flow 写入的 `CredentialKey` 注册。seam 对每个键同时只跑一次尝试,路由一套中立的 notice/prompt 词汇,然后结算。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam;能渲染一个 flow 的界面就能渲染全部 flow。
+
+两个选择承担了主要分量:
+
+- **写入由 flow 拥有。** `run()` 返回即表示记录已通过 `ctx.credentials` 提交;seam 核实的是本次尝试期间观察到的提交——只看记录存在与否,会让重新授权把陈旧记录冒充成新鲜的——并拒绝返回时没提交记录的 flow。正是这一点让 `Models.login()`——它把持久化当作登录的一部分,经由 store 适配器完成——保持为唯一写入方,而不是把凭据复制出来再写第二遍。
+- **交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个页面,无头调用方传入一个直接拒绝的交互实现,也不存在"环境提供方缺席"或"该归两个已打开标签页中哪一个"的问题。
+
+**三处翻译全都留在 `llm-pi-ai`。** `credentialStoreFrom` 把 pi-ai 的 `CredentialStore` 映射到记录;`authContextFrom` 先查凭据 seam 再查启动环境来回答 pi-ai 的环境提问,文件存在性则按宿主进程的文件系统判断;`registerPiAiFlows` 把 pi-ai 的 `AuthEvent`/`AuthPrompt` 重述为中立词汇并运行 `Models.login()`。每个集合都用前两者构造,正是这一点让已登录的提供方在配置变更导致集合重建之后仍然处于登录状态。有了行得通的姿态之后,目录不再扣留仅 OAuth 的路由,`openai-codex` 重新被提供。
+
+凭据平面仍是可选的,正如它在引用解析上一贯如此。没有凭据服务时读取回答"未存储",因为这样的组合确实不持有任何凭据;写入则指名拒绝,因为一次 grant 凭空蒸发的登录会先报告成功、再让每个请求失败。flow 注册通过 `ctx.inject` 限定在授权 seam 之下,因此 headless 或 ACP 组合挂载后没有登录能力,其余一切不变。
+
+### seam 底下需要的两处机制
+
+`withFileLock` 接受按调用声明的等待上限。pi-ai 在 `credentials.modify()` **内部**执行 OAuth 刷新,因此记录写入路径要跨越一次网络往返持锁;2 秒的默认值是按"渲染并 rename"的量级选的,会让该文档的每一个其他写入方失败。重试节奏保持固定——那是协议常量——而等待时长按争用方可能遇到的最长持锁方来定:refs 与 records 共享同一份文件、同一把锁,因此该文档的每一个写入方(`DOCUMENT_LOCK_WAIT_MS`,含引用写入与记录删除)都要等得起一次 OAuth 刷新,而不只是执行刷新的那个 mutation。
+
+seam 的边缘与写入路径同一纪律。prompt 被拒是结果而非故障——交互实现以 `AuthorizationDeclinedError` 拒绝,尝试以 `cancelled` 结算;渲染不了 notice 的界面只丢那条 notice、绝不拖垮 flow;`authorization/settled` 按 credentials seam 的条款以遏制方式分发监听器故障。存储侧,api-key 记录在渲染前先行准入(`parseRecord` 下次启动会拒绝的,写入时就拒绝),`llm-pi-ai` 在寻址记录前先问 `isCredentialKeySegment`,任意手写路由键读作「没有存储任何东西」,而不是在解析途中抛错。
+
+撤销会结算一次尝试,无论其 flow 是否响应信号。flow 本应在信号触发时停止,但不停止的那个会把键占到进程结束,而被卡住的键从外部看与忙碌中的键无法区分。被遗弃的执行体听任其自行结束。
+
+## Alternatives considered
+
+- **把 pi-ai 的 `CredentialStore` 形状直接放进 seam。** 那是行得通且已经设计好的形状。它同时把 `api_key`/`oauth` 定为世上仅有的两种凭据类别,并以 provider id 为键,也就是上文那种所有权丢失;第二个 adapter 家族将不得不假装自己是 pi-ai 才能参与。记录联合体刻意只在两处更抽象一步——键,以及 grant 的不透明性。
+- **在 `user-questions` 旁再建一个专用登录交互 seam。** 授权提示看起来就像问题,复用 `ctx.userQuestions` 很有诱惑力。但那个 seam 是为"模型的工具调用代表 agent 暂停"而建的:它校验调用方 agent、拒绝被委派的调用方、只有一个环境 UI 提供方。授权提示没有 agent,必须抵达发起它的配置页面,还可能被浏览器回调赢得竞速后按单个提示撤下。词汇重叠,生命周期不重叠。
+- **把 `~/.codex/auth.json` 读进一个 store。** 这能让 Codex 在不做上述任何事的情况下工作,刷新也由 pi-ai 负责。它同时为了一个提供方把 harness 绑死在另一个工具的私有文件格式上,且其余所有登录仍然没有着落。
+- **让第二次 `begin()` 并入已在运行的尝试。** 比拒绝更友好,直到两个人在回答同一个 flow 的问题为止。以 entry 上的 `inFlight` 配合拒绝,界面得以禁用按钮,而不是靠报错才发现状态。
+- **把"仅 OAuth 则扣留"当安全网保留。** 它现在会藏起一个能用的提供方。该判定被删除而不是留成惰性代码;`docs/subsystems/credentials.md` 与包 README 承载了取代它的内容。
+
+## Consequences
+
+`.credentials.yaml` 增加了版本与两个分区。启动时会把能精确识别的发布前扁平布局原地升级——全字符串的扁平 mapping 在写锁下逐字下沉到 `refs:` 之下——因为早期内测构建经模型页面存下的密钥必须在布局变更后继续可用,不能要求手工编辑,也不能让模型请求失败。识别器无法证明自己理解的扁平形态仍被指名拒绝,迁移办法写在报错信息里;解析器本身始终只读一种布局,迁移步骤将随发布前立场在首个正式版本时移除。仓库中所有写扁平文档的 fixture 都已改写;llm 各套件的 fixture 被记录改动本身漏掉了,在此补上。
+
+`openai-codex` 回到提供方选择器与 Models 页目录。凡是自带登录的已安装提供方都会得到登录入口,而今天这是全部 38 个——31 个经 pi-ai 自己的提示收取密钥,6 个在此之外还提供订阅登录,Codex 只提供订阅登录。
+
+尚未包含的是界面:把 notice 与 prompt 送到浏览器的 wire 契约,以及 Models 页上发起登录的控件。在那之前,flow 只能在进程内触达,部署方仍然通过在设置表单里输入密钥来配置。
+
+有两项限制记在包 README 里而非就地修复。一次尝试不可持久,登录途中刷新页面会丢弃它。登出即 `deleteRecord`,它只在本地遗忘而不通知签发方;需要服务端吊销的提供方无处声明这一点。
+
+## Testing
+
+seam 自己的套件钉住它拥有的生命周期:单飞的拒绝与释放、flow 启动前与进行中的撤销、一个忽略自身信号的 flow、提交核实,以及包含"调用方看到的是抛出错误"那种 `failed` 情形的结算事件。invariant companion 钉住"已结算的键就是空闲的键",因为被卡住的键否则不可见。
+
+`llm-pi-ai` 针对一份真实的 `$DSH_HOME` 文档覆盖三处翻译——逐字段的 api-key 凭据、连 refresh 半边一起原样保存的 OAuth 凭据、按 scope 跳过的他插件记录,以及没有凭据服务时的写入拒绝——外加每一个 `AuthEvent` 与 `AuthPrompt` 成员的重述;`Models.login()` 在集合边界处被 mock,因为真实登录会打开浏览器。两个真实组合测试分别在挂载与不挂载授权 seam 的情况下启动插件。
+
+`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好收回了被扣留时失去的那一行 `openai-codex` 选项——这是本次改动今天在装配后的应用上造成的全部差异,因为 Models 页还没有可录制的登录控件。

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md
-2026-08-13-oauth-only-providers-withheld.md: 8c85c2900fd0af7c97a3ac8f04d3c6a2f469f8b0
-2026-08-13-oauth-only-providers-withheld.zh.md: 8f05d096a5d474407585bcbd84bd47da8a1abb2e
+2026-08-13-oauth-only-providers-withheld.md: 138cae6f56c0d8355d8db3bddd27aede7caf5b09
+2026-08-13-oauth-only-providers-withheld.zh.md: da8326d1ea1772623367a4a6a1477686b41d268e

+ 2 - 0
.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.md

@@ -38,6 +38,8 @@ Resolution is untouched. A profile naming `apiKeyEnv` on an OAuth-only route sti
 
 Two adjacent gaps remain and are recorded in the package README: a route naming no credential still resolves through the catalog provider's own discovery, which reads process environment variables only — not `~/.aws/credentials`, and not the harness credential seam — and the resulting failure is still the catch-all `PI_AI_ERROR`.
 
+The withholding has since been retired: [credential records and authorization flows](../architecture/2026-08-13-credential-records-and-authorization-flows.md) gave the adapter a durable credential store and a login flow, so `openai-codex` is offered again and `catalogProviderTakesApiKey` is gone. The two boundaries below are what kept that reversal to one predicate and one directory filter.
+
 ## Testing
 
 Package tests pin both halves of the union: the withheld route is absent from `listConfigurableProviders()` while `anthropic` and `openai` stay, and a stored `openai-codex` profile still produces a full entry with `declared: false`. The existing resolution tests are unchanged and still pass, which is what shows the withholding did not narrow what a hand-written profile can serve. The `models-settings` and `onboarding-usable-provider` web e2e goldens lost exactly the `openai-codex` option line, recorded against the real assembled application.

+ 2 - 0
.agents/notes/implemented/bug-fix/2026-08-13-oauth-only-providers-withheld.zh.md

@@ -38,6 +38,8 @@ resolution 未被触动。在仅 OAuth 的路由上指定 `apiKeyEnv` 的 profil
 
 两处相邻缺口仍在,并记录在包 README 中:不指定凭据的路由仍走 catalog 提供方自带的发现,而它只读进程环境变量——不读 `~/.aws/credentials`,也不读 harness 凭据 seam——且由此产生的失败仍是兜底的 `PI_AI_ERROR`。
 
+该扣留其后已被撤销:[凭据记录与授权 flow](../architecture/2026-08-13-credential-records-and-authorization-flows.md) 为适配器补上了可持久的凭据存储与登录 flow,因此 `openai-codex` 重新被提供,`catalogProviderTakesApiKey` 也已删除。下文那两条边界正是把这次反转限制在一个判定函数与一个目录过滤器之内的原因。
+
 ## 测试
 
 包测试钉住联合的两半:不予提供的路由不出现在 `listConfigurableProviders()` 中,而 `anthropic` 与 `openai` 仍在;已存储的 `openai-codex` profile 仍产出完整条目且 `declared: false`。既有的 resolution 测试未改动且依然通过,这正是「不提供」没有收窄手写 profile 可服务范围的证据。`models-settings` 与 `onboarding-usable-provider` 两条 web e2e golden 恰好各少了 `openai-codex` 这一行选项,录自真实装配的应用。

+ 1 - 1
AGENTS.md

@@ -37,7 +37,7 @@ packages/    @deepseek-ai/dsh-<pkg> workspaces at packages/<group>/<pkg>/
   session/     durable session data: persistence, projection, titles, telemetry
   identity/    anonymous identity
   settings/    user-settings capability + file provider
-  credentials/ credential-reference capability + env/.env provider
+  credentials/ credential/authorization capabilities + env/.env provider
   acp/         automation-only Agent Client Protocol server
   interaction/ approval/interaction capabilities, permission, commands, ask-user
   boot/        shared app-bin glue

+ 1 - 1
apps/cli/tests/built-bin.e2e.ts

@@ -426,7 +426,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
     })
     const home = mkdtempSync(join(tmpdir(), 'dsh-home-environment-'))
     const project = mkdtempSync(join(tmpdir(), 'dsh-home-project-'))
-    writeFileSync(join(home, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
+    writeFileSync(join(home, '.credentials.yaml'), `version: 1\nrefs:\n  DEEPSEEK_API_KEY: ${apiKey}\n`, { mode: 0o600 })
     createEnvironmentProbeProfile(home, project)
     try {
       const result = await runBuiltBin(

+ 1 - 0
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -44,6 +44,7 @@
     - option "moonshotai-cn"
     - option "nvidia"
     - option "openai"
+    - option "openai-codex"
     - option "opencode"
     - option "opencode-go"
     - option "openrouter"

+ 1 - 0
apps/web/tests/snapshots/onboarding-usable-provider/dismissed.expected.md

@@ -48,6 +48,7 @@
     - option "moonshotai-cn"
     - option "nvidia"
     - option "openai"
+    - option "openai-codex"
     - option "opencode"
     - option "opencode-go"
     - option "openrouter"

+ 2 - 2
docs/capability-seams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/capability-seams.md
-capability-seams.md: a84a6d6e0836524e1f39f2fd067ae1f6570741a5
-capability-seams.zh.md: b6b0aba72729f9b9b752347ab00cd76a45c248d9
+capability-seams.md: 9e99ebbdc0e3af22f9939c690ead479d4d20b00c
+capability-seams.zh.md: 611902310e3472e05eaa92985011eb852bbeee6d

+ 5 - 0
docs/capability-seams.md

@@ -51,6 +51,8 @@ flowchart LR
   pkg_credentials["credentials"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   pkg_credentials_local["credentials-local"]
+  pkg_authorization["authorization"]
+  svc_authorization["ctx.authorization<br/>Authorization flow registry"]
   pkg_session_telemetry["session-telemetry"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -210,6 +212,7 @@ flowchart LR
   pkg_approval --> svc_approval
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
+  pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_code_runtime --> svc_codeRuntime
@@ -315,6 +318,7 @@ flowchart LR
   svc_approval --> pkg_tools
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_llm_pi_ai
+  svc_authorization --> pkg_llm_pi_ai
   svc_clientModules --> pkg_hmr
   svc_codeRuntime --> pkg_tools
   svc_compaction --> pkg_compaction_basic
@@ -432,6 +436,7 @@ flowchart LR
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. |
+| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |

+ 5 - 0
docs/capability-seams.zh.md

@@ -53,6 +53,8 @@ flowchart LR
   pkg_credentials["credentials"]
   svc_credentials["ctx.credentials<br/>Credential seam"]
   pkg_credentials_local["credentials-local"]
+  pkg_authorization["authorization"]
+  svc_authorization["ctx.authorization<br/>Authorization flow registry"]
   pkg_session_telemetry["session-telemetry"]
   svc_sessionTelemetry["ctx.sessionTelemetry<br/>Session telemetry seam"]
   pkg_session_telemetry_otel["session-telemetry-otel"]
@@ -212,6 +214,7 @@ flowchart LR
   pkg_approval --> svc_approval
   pkg_attachment --> svc_attachments
   pkg_attachment_local --> svc_attachments
+  pkg_authorization --> svc_authorization
   pkg_bash_local --> svc_shell
   pkg_bash_sandbox --> svc_shell
   pkg_code_runtime --> svc_codeRuntime
@@ -317,6 +320,7 @@ flowchart LR
   svc_approval --> pkg_tools
   svc_attachments --> pkg_host_runtime
   svc_attachments --> pkg_llm_pi_ai
+  svc_authorization --> pkg_llm_pi_ai
   svc_clientModules --> pkg_hmr
   svc_codeRuntime --> pkg_tools
   svc_compaction --> pkg_compaction_basic
@@ -434,6 +438,7 @@ flowchart LR
 | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 |
 | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 |
 | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 |
+| `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 |
 | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 |
 | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 |
 | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 |

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: d25843091d453687bad5035c49bcb200cb03fff2
-config-catalog.zh.md: 1cc3002799afc10ca84f74044c7b28f91490ffa3
+config-catalog.md: 36f3e96e69207076b1a1bdae58032f7b3d0c1b8f
+config-catalog.zh.md: 8e66b1b2c9c4e54be33fd9c2ccff5b8a3494aab6

+ 2 - 1
docs/config-catalog.md

@@ -567,7 +567,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts)
+Source: [`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
 
 <a id="deepseek-aidsh-e2b"></a>
 
@@ -3192,6 +3192,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
+- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))

+ 2 - 1
docs/config-catalog.zh.md

@@ -569,7 +569,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/credentials/credentials-local/src/index.ts:55`](../packages/credentials/credentials-local/src/index.ts)
+来源:[`packages/credentials/credentials-local/src/index.ts:64`](../packages/credentials/credentials-local/src/index.ts)
 
 <a id="deepseek-aidsh-e2b"></a>
 
@@ -3195,6 +3195,7 @@ export interface Config {
 - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts))
 - `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts))
 - `@deepseek-ai/dsh-api-remotes`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts))
+- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
 - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime`([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))

+ 2 - 2
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: d3901b1ddc7d99d4960324ab7459388fa4612bbe
-event-producer-consumer.zh.md: 346a293a226719f781a9587eb0adefcf3f6bed08
+event-producer-consumer.md: d68f92d317dec2fd05813c1ce487bb88c369bd6e
+event-producer-consumer.zh.md: 5b3454e6000f4e1e017cb494423736f2c0f75f31

+ 3 - 1
docs/event-producer-consumer.md

@@ -22,6 +22,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
+| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
@@ -29,7 +30,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
-| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
+| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |

+ 3 - 1
docs/event-producer-consumer.zh.md

@@ -24,6 +24,7 @@
 | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:178`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, `apiproxy`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server` |
 | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:278`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
 | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/index.ts:30`](../packages/interaction/user-approval/src/index.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` |
+| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
 | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `apiproxy` |
 | `cordis/dynamic-package` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:379`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/dynamic-retract` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:385`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
@@ -31,7 +32,8 @@
 | `cordis/inspect-query-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:397`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/request-run` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:367`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
 | `cordis/request-run-resolved` | `emit` | [`packages/extensions/cordis-host-runner/src/types.ts:373`](../packages/extensions/cordis-host-runner/src/types.ts) | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) (`emit`) | `apiproxy` |
-| `credentials/updated` | `emit` | [`packages/credentials/credentials/src/types.ts:29`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
+| `credentials/record-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:87`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
+| `credentials/reference-updated` | `emit` | [`packages/credentials/credentials/src/types.ts:75`](../packages/credentials/credentials/src/types.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) |
 | `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | `apiproxy`, [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
 | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:66`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 22532a825ccb65affceca16a23b73169828bf3f1
-module-graph.zh.md: 148e23eeb051c700715f242f37d98a21c9358754
+module-graph.md: f17c65854dbf349adba5ff99288676a4f7eb7402
+module-graph.zh.md: 31608370c550ae7e32e1395e9f6833abf3d096c8

+ 14 - 8
docs/module-graph.md

@@ -179,6 +179,7 @@ flowchart TD
     pkg_tmux_context["tmux-context"]
   end
   subgraph group_credentials["packages/credentials"]
+    pkg_authorization["authorization"]
     pkg_credentials["credentials"]
     pkg_credentials_local["credentials-local"]
   end
@@ -419,13 +420,6 @@ flowchart TD
   pkg_llm_deepseek --> pkg_llm
   pkg_llm_deepseek --> pkg_settings
   pkg_llm_deepseek --> pkg_timeout
-  pkg_llm_pi_ai --> pkg_attachment
-  pkg_llm_pi_ai --> pkg_credentials
-  pkg_llm_pi_ai --> pkg_invariants
-  pkg_llm_pi_ai --> pkg_launch_environment
-  pkg_llm_pi_ai --> pkg_llm
-  pkg_llm_pi_ai --> pkg_settings
-  pkg_llm_pi_ai --> pkg_timeout
   pkg_session --> pkg_brand
   pkg_session --> pkg_invariants
   pkg_session --> pkg_llm
@@ -439,9 +433,20 @@ flowchart TD
   pkg_skill --> pkg_scope
   pkg_web --> pkg_invariants
   pkg_web --> pkg_llm
+  pkg_authorization --> pkg_credentials
+  pkg_authorization --> pkg_invariants
+  pkg_authorization --> pkg_llm
   pkg_lsp --> pkg_brand
   pkg_lsp --> pkg_invariants
   pkg_lsp --> pkg_llm
+  pkg_llm_pi_ai --> pkg_attachment
+  pkg_llm_pi_ai --> pkg_authorization
+  pkg_llm_pi_ai --> pkg_credentials
+  pkg_llm_pi_ai --> pkg_invariants
+  pkg_llm_pi_ai --> pkg_launch_environment
+  pkg_llm_pi_ai --> pkg_llm
+  pkg_llm_pi_ai --> pkg_settings
+  pkg_llm_pi_ai --> pkg_timeout
   pkg_agent --> pkg_invariants
   pkg_agent --> pkg_llm
   pkg_agent --> pkg_scope
@@ -1503,12 +1508,13 @@ flowchart TD
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
-| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
+| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
+| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
 | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |

+ 14 - 8
docs/module-graph.zh.md

@@ -181,6 +181,7 @@ flowchart TD
     pkg_tmux_context["tmux-context"]
   end
   subgraph group_credentials["packages/credentials"]
+    pkg_authorization["authorization"]
     pkg_credentials["credentials"]
     pkg_credentials_local["credentials-local"]
   end
@@ -421,13 +422,6 @@ flowchart TD
   pkg_llm_deepseek --> pkg_llm
   pkg_llm_deepseek --> pkg_settings
   pkg_llm_deepseek --> pkg_timeout
-  pkg_llm_pi_ai --> pkg_attachment
-  pkg_llm_pi_ai --> pkg_credentials
-  pkg_llm_pi_ai --> pkg_invariants
-  pkg_llm_pi_ai --> pkg_launch_environment
-  pkg_llm_pi_ai --> pkg_llm
-  pkg_llm_pi_ai --> pkg_settings
-  pkg_llm_pi_ai --> pkg_timeout
   pkg_session --> pkg_brand
   pkg_session --> pkg_invariants
   pkg_session --> pkg_llm
@@ -441,9 +435,20 @@ flowchart TD
   pkg_skill --> pkg_scope
   pkg_web --> pkg_invariants
   pkg_web --> pkg_llm
+  pkg_authorization --> pkg_credentials
+  pkg_authorization --> pkg_invariants
+  pkg_authorization --> pkg_llm
   pkg_lsp --> pkg_brand
   pkg_lsp --> pkg_invariants
   pkg_lsp --> pkg_llm
+  pkg_llm_pi_ai --> pkg_attachment
+  pkg_llm_pi_ai --> pkg_authorization
+  pkg_llm_pi_ai --> pkg_credentials
+  pkg_llm_pi_ai --> pkg_invariants
+  pkg_llm_pi_ai --> pkg_launch_environment
+  pkg_llm_pi_ai --> pkg_llm
+  pkg_llm_pi_ai --> pkg_settings
+  pkg_llm_pi_ai --> pkg_timeout
   pkg_agent --> pkg_invariants
   pkg_agent --> pkg_llm
   pkg_agent --> pkg_scope
@@ -1505,12 +1510,13 @@ flowchart TD
 | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) |
 | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
 | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
-| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) |
 | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
 | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
+| [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
 | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
+| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) |
 | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
 | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) |
 | [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) |

+ 2 - 2
docs/subsystems/credentials.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/credentials.md
-credentials.md: fe480c4311dd5f7e0b1f26996ad53d5f0cd79e7d
-credentials.zh.md: 1d8712f8b01a6d6b92e2036172c151d22728f2a4
+credentials.md: 459511965d796cd34d1a817110016bcb922d2988
+credentials.zh.md: d833f7897008dd9f9c062d8fbefc1e4ba9c0d959

+ 162 - 7
docs/subsystems/credentials.md

@@ -47,7 +47,7 @@ interface CredentialInfo {
 
 ## Change commits
 
-`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
+`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration surfaces refreshing a "configured" badge.
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -57,11 +57,76 @@ interface CredentialInfo {
 
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
+<a id="ctxauthorization--authorizationservice"></a>
+
+### `ctx.authorization` — `AuthorizationService`
+
+`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
+
+```ts cordis-catalog
+/**
+ * Offer a way to obtain one credential. One flow per key: two plugins
+ * claiming the same key would each write a record in their own format, and
+ * whichever ran last would leave the other reading a payload it cannot parse.
+ *
+ * @param flow - the key it writes, its label, its methods, and its runner.
+ * @returns Disposer that withdraws this flow.
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+ */
+registerFlow(flow: AuthorizationFlow): () => void
+
+/**
+ * Every registered flow, for a surface listing what can be authorized.
+ * @returns one entry per flow, in registration order.
+ */
+list(): readonly AuthorizationEntry[]
+
+/**
+ * One registered flow.
+ * @param key - the credential record to ask about.
+ * @returns the entry, or undefined when no flow claims that key.
+ */
+describe(key: CredentialKey): AuthorizationEntry | undefined
+
+/**
+ * Withdraw the attempt running for a key, if any. Separate from the
+ * request's own signal because a request/response transport answers a Cancel
+ * button on a second call, with no handle on the first one's signal.
+ * @param key - the credential record whose attempt should stop.
+ */
+cancel(key: CredentialKey): void
+
+/**
+ * Run one attempt to authorize a key, and report how it ended.
+ *
+ * One attempt per key at a time. A second caller is refused rather than
+ * joined: the two would be prompting different humans through the same flow,
+ * and the second would answer questions the first was asked.
+ *
+ * @param request - the key, the method, the surface, and the cancel signal.
+ * @returns `authorized` once the flow's record is committed during this
+ *   attempt and observed, or `cancelled` when the human declined or the
+ *   caller withdrew.
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+ *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+ *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+ *   `NOT_COMMITTED` when the flow resolved without committing a record
+ *   during the attempt.
+ */
+async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:182`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
 
-Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
+Abstract credential service over two key spaces that answer two questions.
+
+A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
+
+A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock).
 
 ```ts cordis-catalog
 /**
@@ -99,17 +164,107 @@ abstract set(ref: CredentialRef, value: string): Promise<void>
  * @param ref - the reference to remove.
  */
 abstract unset(ref: CredentialRef): Promise<void>
+
+/**
+ * Read one stored record. The value is returned as its owner wrote it; a
+ * {@link GrantRecord} payload is not interpreted on the way out.
+ * @param key - the record to read.
+ * @returns the record, or `undefined` while none is stored.
+ */
+abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>
+
+/**
+ * Describe one record for configuration surfaces without exposing its value.
+ * @param key - the record to describe.
+ * @returns presence, discriminant, and writability.
+ */
+abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>
+
+/**
+ * Enumerate every stored record's address and tag. Unlike the reference
+ * half, which has no enumeration because configuration surfaces learn which
+ * references exist from settings schemas, records have no such discovery
+ * path: a surface that cannot list them cannot show what a user is
+ * authorized for, nor find an orphan left by an uninstalled plugin.
+ * @returns every stored record, values excluded.
+ */
+abstract listRecords(): Promise<readonly CredentialRecordEntry[]>
+
+/**
+ * Serialized read-modify-write over one record — the only write path.
+ * `mutate` sees the record as it stands at the moment the write is
+ * exclusive, and returning `undefined` leaves the entry untouched. Exclusion
+ * holds across processes where the backing store supports it, which is what
+ * makes a token refresh safe: two processes rotating one refresh token
+ * concurrently would otherwise lose whichever wrote first.
+ * @param key - the record to modify.
+ * @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
+ * @returns the record after the write, or the current one when `mutate` declined.
+ */
+abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>, ): Promise<CredentialRecord | undefined>
+
+/**
+ * Remove one record; removing an absent record is a no-op.
+ * @param key - the record to remove.
+ */
+abstract deleteRecord(key: CredentialKey): Promise<void>
+```
+
+Source: [`packages/credentials/credentials/src/index.ts:177`](../../packages/credentials/credentials/src/index.ts)
+
+<a id="authorization-events"></a>
+
+### `authorization/*` events
+
+<a id="authorizationsettled--emit"></a>
+
+#### `authorization/settled` — emit
+
+One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
+
+```ts cordis-catalog
+/**
+ * One authorization attempt has finished and released its key. Fires for
+ * every terminal outcome, failures included, so a surface watching a key it
+ * did not start (a second browser tab) learns the attempt is over.
+ * @mode emit
+ * @param key - the credential record the finished attempt was authorizing.
+ * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+ */
+'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
 ```
 
-Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts)
+Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
 
 <a id="credentials-events"></a>
 
 ### `credentials/*` events
 
-<a id="credentialsupdated--emit"></a>
+<a id="credentialsrecord-updated--emit"></a>
+
+#### `credentials/record-updated` — emit
+
+Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
+
+```ts cordis-catalog
+/**
+ * Committed change to a stored credential record: a `modifyRecord` that
+ * wrote, a `deleteRecord` that removed, or an external edit observed in
+ * storage. Separate from `credentials/reference-updated` because the two key
+ * grammars are disjoint — a listener that received both on one event could
+ * not tell which space a subject belongs to. Listener failures are
+ * contained on the same terms as `credentials/reference-updated`.
+ * @param key - the record whose stored value changed.
+ * @mode emit
+ */
+'credentials/record-updated'(key: CredentialKey): void
+```
+
+Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
+
+<a id="credentialsreference-updated--emit"></a>
 
-#### `credentials/updated` — emit
+#### `credentials/reference-updated` — emit
 
 Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
 
@@ -126,8 +281,8 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
  * @param ref - the reference whose stored value changed.
  * @mode emit
  */
-'credentials/updated'(ref: CredentialRef): void
+'credentials/reference-updated'(ref: CredentialRef): void
 ```
 
-Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts)
+Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
 <!-- END GENERATED cordis-surface -->

+ 162 - 7
docs/subsystems/credentials.zh.md

@@ -47,7 +47,7 @@ interface CredentialInfo {
 
 ## 已提交的变更
 
-`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
+`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后发出——`set`、`unset` 或在存储中观察到的外部编辑。进程环境自身的变化不可观测,永不发出事件。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。
 
 <!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
 
@@ -57,11 +57,76 @@ interface CredentialInfo {
 
 Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
 
+<a id="ctxauthorization--authorizationservice"></a>
+
+### `ctx.authorization` — `AuthorizationService`
+
+`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.
+
+```ts cordis-catalog
+/**
+ * Offer a way to obtain one credential. One flow per key: two plugins
+ * claiming the same key would each write a record in their own format, and
+ * whichever ran last would leave the other reading a payload it cannot parse.
+ *
+ * @param flow - the key it writes, its label, its methods, and its runner.
+ * @returns Disposer that withdraws this flow.
+ * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+ */
+registerFlow(flow: AuthorizationFlow): () => void
+
+/**
+ * Every registered flow, for a surface listing what can be authorized.
+ * @returns one entry per flow, in registration order.
+ */
+list(): readonly AuthorizationEntry[]
+
+/**
+ * One registered flow.
+ * @param key - the credential record to ask about.
+ * @returns the entry, or undefined when no flow claims that key.
+ */
+describe(key: CredentialKey): AuthorizationEntry | undefined
+
+/**
+ * Withdraw the attempt running for a key, if any. Separate from the
+ * request's own signal because a request/response transport answers a Cancel
+ * button on a second call, with no handle on the first one's signal.
+ * @param key - the credential record whose attempt should stop.
+ */
+cancel(key: CredentialKey): void
+
+/**
+ * Run one attempt to authorize a key, and report how it ended.
+ *
+ * One attempt per key at a time. A second caller is refused rather than
+ * joined: the two would be prompting different humans through the same flow,
+ * and the second would answer questions the first was asked.
+ *
+ * @param request - the key, the method, the surface, and the cancel signal.
+ * @returns `authorized` once the flow's record is committed during this
+ *   attempt and observed, or `cancelled` when the human declined or the
+ *   caller withdrew.
+ * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+ *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+ *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+ *   `NOT_COMMITTED` when the flow resolved without committing a record
+ *   during the attempt.
+ */
+async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>
+```
+
+Source: [`packages/credentials/authorization/src/index.ts:182`](../../packages/credentials/authorization/src/index.ts)
+
 <a id="ctxcredentials--credentialprovider-abstract-seam"></a>
 
 ### `ctx.credentials` — `CredentialProvider` (abstract seam)
 
-Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
+Abstract credential service over two key spaces that answer two questions.
+
+A CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.
+
+A CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock).
 
 ```ts cordis-catalog
 /**
@@ -99,17 +164,107 @@ abstract set(ref: CredentialRef, value: string): Promise<void>
  * @param ref - the reference to remove.
  */
 abstract unset(ref: CredentialRef): Promise<void>
+
+/**
+ * Read one stored record. The value is returned as its owner wrote it; a
+ * {@link GrantRecord} payload is not interpreted on the way out.
+ * @param key - the record to read.
+ * @returns the record, or `undefined` while none is stored.
+ */
+abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>
+
+/**
+ * Describe one record for configuration surfaces without exposing its value.
+ * @param key - the record to describe.
+ * @returns presence, discriminant, and writability.
+ */
+abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>
+
+/**
+ * Enumerate every stored record's address and tag. Unlike the reference
+ * half, which has no enumeration because configuration surfaces learn which
+ * references exist from settings schemas, records have no such discovery
+ * path: a surface that cannot list them cannot show what a user is
+ * authorized for, nor find an orphan left by an uninstalled plugin.
+ * @returns every stored record, values excluded.
+ */
+abstract listRecords(): Promise<readonly CredentialRecordEntry[]>
+
+/**
+ * Serialized read-modify-write over one record — the only write path.
+ * `mutate` sees the record as it stands at the moment the write is
+ * exclusive, and returning `undefined` leaves the entry untouched. Exclusion
+ * holds across processes where the backing store supports it, which is what
+ * makes a token refresh safe: two processes rotating one refresh token
+ * concurrently would otherwise lose whichever wrote first.
+ * @param key - the record to modify.
+ * @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
+ * @returns the record after the write, or the current one when `mutate` declined.
+ */
+abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>, ): Promise<CredentialRecord | undefined>
+
+/**
+ * Remove one record; removing an absent record is a no-op.
+ * @param key - the record to remove.
+ */
+abstract deleteRecord(key: CredentialKey): Promise<void>
+```
+
+Source: [`packages/credentials/credentials/src/index.ts:177`](../../packages/credentials/credentials/src/index.ts)
+
+<a id="authorization-events"></a>
+
+### `authorization/*` events
+
+<a id="authorizationsettled--emit"></a>
+
+#### `authorization/settled` — emit
+
+One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.
+
+```ts cordis-catalog
+/**
+ * One authorization attempt has finished and released its key. Fires for
+ * every terminal outcome, failures included, so a surface watching a key it
+ * did not start (a second browser tab) learns the attempt is over.
+ * @mode emit
+ * @param key - the credential record the finished attempt was authorizing.
+ * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+ */
+'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
 ```
 
-Source: [`packages/credentials/credentials/src/index.ts:60`](../../packages/credentials/credentials/src/index.ts)
+Source: [`packages/credentials/authorization/src/index.ts:57`](../../packages/credentials/authorization/src/index.ts)
 
 <a id="credentials-events"></a>
 
 ### `credentials/*` events
 
-<a id="credentialsupdated--emit"></a>
+<a id="credentialsrecord-updated--emit"></a>
+
+#### `credentials/record-updated` — emit
+
+Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.
+
+```ts cordis-catalog
+/**
+ * Committed change to a stored credential record: a `modifyRecord` that
+ * wrote, a `deleteRecord` that removed, or an external edit observed in
+ * storage. Separate from `credentials/reference-updated` because the two key
+ * grammars are disjoint — a listener that received both on one event could
+ * not tell which space a subject belongs to. Listener failures are
+ * contained on the same terms as `credentials/reference-updated`.
+ * @param key - the record whose stored value changed.
+ * @mode emit
+ */
+'credentials/record-updated'(key: CredentialKey): void
+```
+
+Source: [`packages/credentials/credentials/src/types.ts:87`](../../packages/credentials/credentials/src/types.ts)
+
+<a id="credentialsreference-updated--emit"></a>
 
-#### `credentials/updated` — emit
+#### `credentials/reference-updated` — emit
 
 Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation's outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.
 
@@ -126,8 +281,8 @@ Committed change to a provider-managed credential source: a `set`, an `unset`, o
  * @param ref - the reference whose stored value changed.
  * @mode emit
  */
-'credentials/updated'(ref: CredentialRef): void
+'credentials/reference-updated'(ref: CredentialRef): void
 ```
 
-Source: [`packages/credentials/credentials/src/types.ts:29`](../../packages/credentials/credentials/src/types.ts)
+Source: [`packages/credentials/credentials/src/types.ts:75`](../../packages/credentials/credentials/src/types.ts)
 <!-- END GENERATED cordis-surface -->

+ 2 - 2
packages/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/README.md
-README.md: a410d7148d14503a61edb9c4848b521552050ca4
-README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460
+README.md: c455feb38489400b6e96101661103dfdeb0d2083
+README.zh.md: 14ab37d08e34728a6aa905e8acbe64447b936894

+ 1 - 1
packages/README.md

@@ -46,7 +46,7 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
 | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
 | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
 | [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
-| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
+| [`credentials/`](credentials/README.md) | Credential reference/record seam + env-over-`.env` provider + authorization flows | Product — stable API |
 | [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
 | [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
 | [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API |

+ 1 - 1
packages/README.zh.md

@@ -46,7 +46,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通
 | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API |
 | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API |
 | [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API |
-| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定 API |
+| [`credentials/`](credentials/README.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 授权 flow | 产品:稳定 API |
 | [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API |
 | [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API |
 | [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API |

+ 1 - 1
packages/api/remotes/src/remote-events.ts

@@ -17,7 +17,7 @@
 export const API_REMOTE_FORWARDED_EVENTS = [
   'agent-preset/selected',
   'commands/change',
-  'credentials/updated',
+  'credentials/reference-updated',
   'cordis/request-run',
   'cordis/request-run-resolved',
   'cordis/dynamic-package',

+ 3 - 3
packages/client/runtime/tests/wire-events.client.spec.ts

@@ -28,7 +28,7 @@ function forwardedEventContracts(ctx: Context): void {
     const bare: typeof namespace = 'plain-string'
     void bare; void namespace; void source
   })
-  ctx.remote.$on('credentials/updated', () => {})
+  ctx.remote.$on('credentials/reference-updated', () => {})
   ctx.remote.$on('commands/change', () => {})
   ctx.remote.$on('llm/adapters-updated', () => {})
   ctx.remote.$on('agent-preset/selected', (sessionId, agentPreset) => {
@@ -106,7 +106,7 @@ describe('wire event bridge', () => {
     })
     bench.sinks?.onHostEnvelope?.({
       rpcId: 'r4' as never,
-      payload: { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
+      payload: { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
     })
     // The carrier does not second-guess the name: selecting what a consumer can
     // receive is the allowlist's job, and dropping an unsubscribed name is the
@@ -118,7 +118,7 @@ describe('wire event bridge', () => {
 
     expect(seen).toEqual([
       ['settings/document-updated', 'llm-pi-ai', 7],
-      ['credentials/updated', 'OPENAI_API_KEY'],
+      ['credentials/reference-updated', 'OPENAI_API_KEY'],
       ['nobody/listening', 'ignored'],
     ])
   })

+ 2 - 2
packages/client/ui-settings-models/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 0d76d5f31585721ad8f5cd76dd223d72353fdd0c
-README.zh.md: 8ac336245c9d82420c10132b3b935cf00f5ea889
+README.md: 781d7a8134c8118ddf4a78f3ee153a8043bae0a6
+README.zh.md: a652e9a250d855c0e91f842ec2332f4a49f17856

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-settings-models/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-settings-models/README.zh.md


+ 1 - 1
packages/client/ui-settings-models/src/client/index.ts

@@ -108,7 +108,7 @@ export function apply(ctx: ClientContext): void {
     const refreshModels = (): void => { refreshIfLoaded(controller) }
     const disposers = [
       ctx.remote.$on('settings/document-updated', () => { refreshModels() }),
-      ctx.remote.$on('credentials/updated', refreshModels),
+      ctx.remote.$on('credentials/reference-updated', refreshModels),
       ctx.remote.$on('llm/adapters-updated', refreshModels),
       ctx.on('connection/reset', refreshModels),
     ]

+ 2 - 2
packages/client/ui-settings-models/tests/apply.client.spec.ts

@@ -177,7 +177,7 @@ describe('pushed invalidations', () => {
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     // The fake wire face has no methods: a fetch attempt would throw.
     b.ctx.remote.$dispatch('settings/document-updated', ['llm-pi-ai', 1])
-    b.ctx.remote.$dispatch('credentials/updated', ['OPENAI_API_KEY'])
+    b.ctx.remote.$dispatch('credentials/reference-updated', ['OPENAI_API_KEY'])
     b.ctx.remote.$dispatch('llm/adapters-updated', [])
     b.ctx.emit('connection/reset')
   })
@@ -210,7 +210,7 @@ describe('pushed invalidations', () => {
     )()
     injected.controller.store.update((state) => { state.status = 'ready' })
     const load = vi.spyOn(injected.controller, 'load').mockResolvedValue()
-    b.ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
+    b.ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
     expect(load).toHaveBeenCalledTimes(1)
   })
 

+ 2 - 2
packages/client/ui-settings-plugins/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-plugins/README.md
-README.md: 92ba426b6bee3ae73e184dfcfd418e889bd47086
-README.zh.md: dc319ea9f128016b6df821575cb4d75740f04579
+README.md: 0ee0edbebadad8e8e895002cd9eb60a20f45809d
+README.zh.md: 75cc63ccf5280bba1b94bcf8930b9ee9432440e5

+ 1 - 1
packages/client/ui-settings-plugins/README.md

@@ -20,7 +20,7 @@ A card stages what the user types and writes it only when they save. Each contro
 
 Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. The Host is the only authority on whether a value was accepted — its validators own the constraints no schema can express — so the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct.
 
-A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/updated` event for the reference it watches.
+A key can also be written from another surface — the Models page addresses the same reference — which changes no settings section, so the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches.
 
 A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response, so a key control starts blank, reports only whether one is configured, and writes through the credentials domain rather than the settings section; a blank draft writes nothing and keeps the stored key.
 

+ 1 - 1
packages/client/ui-settings-plugins/README.zh.md

@@ -20,7 +20,7 @@
 
 保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。某个值是否被接受只有 Host 说了算——schema 表达不了的约束归它的校验器所有——因此卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。
 
-密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/updated` 事件报告它所关注的引用时重读。
+密钥也可能从别的表层写入——模型页寻址的是同一个引用——而那不改变任何 settings 分节,因此卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。
 
 字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应,因此密钥控件初始为空、只报告是否已配置,并经由 credentials 领域而非 settings 分节写入;空草稿不写入任何东西,保留已存密钥。
 

+ 1 - 1
packages/client/ui-settings-plugins/src/client/index.ts

@@ -68,7 +68,7 @@ export function apply(ctx: ClientContext): void {
   // scope publishes nothing when one is written. This is the only signal that
   // a key written on another surface reached the Host.
   ctx.effect(
-    () => ctx.remote.$on('credentials/updated', (ref) => { webSearch.refreshCredential(ref) }),
+    () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }),
     'ui-settings-plugins: credential invalidations',
   )
 

+ 2 - 2
packages/client/ui-settings-plugins/tests/apply.client.spec.ts

@@ -180,7 +180,7 @@ describe('ui-settings-plugins apply', () => {
 
     // A key written on another surface changes no settings section, so this
     // event is the only thing that reaches the card.
-    ctx.remote.$dispatch('credentials/updated', ['DEEPSEEK_API_KEY'])
+    ctx.remote.$dispatch('credentials/reference-updated', ['DEEPSEEK_API_KEY'])
 
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) })
   })
@@ -192,7 +192,7 @@ describe('ui-settings-plugins apply', () => {
     await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalled() })
     describeCredentials.mockClear()
 
-    ctx.remote.$dispatch('credentials/updated', ['SOME_OTHER_KEY'])
+    ctx.remote.$dispatch('credentials/reference-updated', ['SOME_OTHER_KEY'])
     await Promise.resolve()
 
     expect(describeCredentials).not.toHaveBeenCalled()

+ 2 - 2
packages/credentials/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/credentials/README.md
-README.md: 75e3941ff6421ca7e41a71ab2bfe8b30209ce598
-README.zh.md: d7c958689fdffc82830a82b04e2a06802bc81634
+README.md: 032f0f38319e276b613241ba89a956a341eaa09c
+README.zh.md: 21e66ef6c8375ef1e4834b05f30901b0e38aebad

+ 5 - 4
packages/credentials/README.md

@@ -1,14 +1,15 @@
-# credentials/ — credential references
+# credentials/ — credentials and authorization
 
 English | [中文](README.zh.md)
 
-The credential capability family separates reference resolution from its provider:
+The credential capability family separates reference resolution from its provider, and separates both from obtaining a credential that has to be asked for:
 
 | Package | Role | ctx key |
 |---|---|---|
-| [`credentials/`](credentials/README.md) | Credential-reference seam | `ctx.credentials` |
+| [`credentials/`](credentials/README.md) | Credential-reference and credential-record seam | `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | Environment and local-file provider | registers `ctx.credentials` |
+| [`authorization/`](authorization/README.md) | Plugin-owned flows that obtain a credential by asking a human | `ctx.authorization` |
 
-Configuration carries references, not secret values. Consumers resolve those references at their operation boundary; the child READMEs own mutation, precedence, and storage semantics.
+Configuration carries references, not secret values. Consumers resolve those references at their operation boundary; the child READMEs own mutation, precedence, and storage semantics. An authorization flow writes a credential record and is keyed by it, so the two seams meet at the record and nowhere else.
 
 The subsystem reference — `CredentialRef`, per-operation resolution, UI-safe `CredentialInfo`, provider layers — is [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md).

+ 5 - 4
packages/credentials/README.zh.md

@@ -1,14 +1,15 @@
-# credentials/:凭据引用
+# credentials/:凭据与授权
 
 [English](README.md) | 中文
 
-凭据能力家族将引用解析与提供方分离:
+凭据能力家族将引用解析与提供方分离,并把二者与"必须开口去要才能拿到的凭据"再分开:
 
 | 包 | 角色 | ctx 键 |
 |---|---|---|
-| [`credentials/`](credentials/README.md) | 凭据引用 seam | `ctx.credentials` |
+| [`credentials/`](credentials/README.md) | 凭据引用与凭据记录 seam | `ctx.credentials` |
 | [`credentials-local/`](credentials-local/README.md) | 环境与本地文件提供方 | 注册 `ctx.credentials` |
+| [`authorization/`](authorization/README.md) | 由插件拥有、通过询问人来取得凭据的 flow | `ctx.authorization` |
 
-配置携带引用而非机密值。消费方在其操作边界解析这些引用;变更、优先级与存储语义由子级 README 负责。
+配置携带引用而非机密值。消费方在其操作边界解析这些引用;变更、优先级与存储语义由子级 README 负责。授权 flow 写入一条凭据记录并以它为键,因此两个 seam 只在记录处相交,别无其他接触面。
 
 子系统参考——`CredentialRef`、按操作解析、对 UI 安全的 `CredentialInfo`、提供方层——见 [docs/subsystems/credentials.md](../../docs/subsystems/credentials.md)。

+ 6 - 0
packages/credentials/authorization/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/credentials/authorization/README.md
+README.md: 373a070c1a5cbb9a60e7ae14734a752515e0a0e4
+README.zh.md: 8dcdc081a87979ec712f18189ff7bbe937328236

+ 76 - 0
packages/credentials/authorization/README.md

@@ -0,0 +1,76 @@
+# dsh-authorization
+
+English | [中文](README.zh.md)
+
+Authorization Service Definition (`ctx.authorization`). Some credentials cannot be configured, only obtained: getting one means a conversation with a human — open this page, paste that code, pick an account. This seam owns that conversation and the lifecycle around it, and never the protocol.
+
+**A flow is a plugin's knowledge of how to get its own credential.** It is registered under the [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) it writes, so a flow says which record it produces and, through that key's scope, which plugin answers for the format inside it. A second authorization protocol arrives as another flow, not as another seam.
+
+**The flow owns the write.** `run()` resolving means the record is already committed through `ctx.credentials`; the seam confirms a commit it observed during the attempt — presence alone would let a re-authorization pass a stale record off as fresh — and refuses a flow that resolved without one. Committing inside the flow is what lets a library that persists through its own store adapter stay the single writer instead of being copied back out and written twice.
+
+**The interaction travels with the request, not a registry.** Whoever starts an authorization is the one who can talk to the human about it, so prompts reach exactly the surface that asked and a headless caller supplies an interaction that declines. There is no ambient provider to be absent, and no question about which of two open pages a prompt belongs to.
+
+## Surface
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import { AuthorizationDeclinedError, type AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+
+declare const ctx: Context
+declare const exchange: (signal: AbortSignal) => Promise<void>
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')
+
+const dispose = ctx.authorization.registerFlow({
+  key,
+  label: 'ChatGPT (Codex)',
+  methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+  async run(session: AuthorizationSession) {
+    session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+    const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
+    // Commits the record through ctx.credentials before resolving.
+    await exchange(session.signal)
+    void code
+  },
+})
+
+ctx.authorization.list()                    // [{ key, label, methods, inFlight }]
+ctx.authorization.describe(key)             // the same entry, or undefined
+await ctx.authorization.begin({             // { status: 'authorized' | 'cancelled' }
+  key,
+  interaction: { notify: () => {}, prompt: () => Promise.reject(new AuthorizationDeclinedError()) },
+})
+ctx.authorization.cancel(key)               // withdraw whatever is running for the key
+dispose()
+```
+
+One attempt per key at a time. A second caller is refused with `ALREADY_IN_FLIGHT` rather than joined, because the two would be prompting different humans through one flow and the second would be answering questions the first was asked. `inFlight` is on the entry so a surface renders the button disabled instead of discovering this by error.
+
+`cancel(key)` exists beside the request's own signal because a request/response transport answers a Cancel button on a second call, holding no handle on the first one's signal. A flow whose registration is disposed mid-attempt is withdrawn the same way: its runner belongs to a plugin that is going away.
+
+An attempt whose caller has already withdrawn never claims the key and never starts the flow — relying on each flow to check its signal before the first await would let one that does not hang holding the key. Validation still runs first, so a caller naming a key or method that does not exist hears about it whether or not it also gave up.
+
+A human's "no" is an outcome, not a breakage. An interaction that declines rejects its prompt with `AuthorizationDeclinedError`, and an attempt that fails after a declined prompt settles as `cancelled`, exactly as a withdrawn signal does; any other prompt rejection stays a flow failure that reaches the caller. A notice is fire-and-forget on the same principle, held at the seam: a surface that cannot render one loses the notice, never the attempt.
+
+`authorization/settled (key, settlement)` fires after the key is released, for every terminal outcome. `settlement` adds `failed` to the two statuses `begin()` can return: a failure reaches its own caller as a thrown error, so the event stream is the only place a watcher that did not start the attempt can tell a refusal from a breakage. Listener failures are contained: every listener runs, a throw or rejection is logged without changing the finished attempt's outcome, and only an `INVARIANT`-coded failure rethrows after the rest ran.
+
+## The interaction vocabulary
+
+A notice is one-way and never carries a secret: a message, optionally the page the human must open and the code they must enter there. A prompt is a question the flow cannot answer — `text`, `secret`, or `select` — and `secret` differs from `text` only in presentation. A prompt carries its own `signal` so a flow that races a typed code against a browser callback can withdraw the losing question while the attempt continues; the request's signal withdraws the whole attempt instead.
+
+The vocabulary is deliberately smaller than any one provider's: it describes what a surface must render, so a surface that renders one flow renders all of them.
+
+## Model Experience
+
+None, as authorization is a configuration-time conversation with a human and no flow, notice, or prompt reaches a model request.
+
+#### KV Cache effect
+
+No invalidation; no authorization state enters a request prefix.
+
+## Known Limitations and Deferred Work
+
+- **No flow is resumable** — an attempt lives in the process that started it, so a browser reload during a login abandons it and the human starts over. Durable attempts need a store this seam does not have.
+- **Nothing revokes** — signing out is `ctx.credentials.deleteRecord(key)`, which forgets the local record without telling the issuer. A provider that needs a server-side revoke has no place to declare it yet.
+- **A key with no flow is inert** — the seam reports what is registered, so a record left by an uninstalled plugin can be deleted but not re-authorized. Recognizing that orphan is the caller's join, as it is for [`listRecords()`](../credentials/README.md#surface).

+ 76 - 0
packages/credentials/authorization/README.zh.md

@@ -0,0 +1,76 @@
+# dsh-authorization
+
+[English](README.md) | 中文
+
+授权 Service Definition(`ctx.authorization`)。有些凭据无法配置,只能获取:拿到它意味着与人对话——打开这个页面、粘贴那个码、选一个账号。本 seam 拥有这段对话及其生命周期,但从不拥有协议本身。
+
+**flow 是某个插件"如何取得自己那份凭据"的知识。** 它以自己写入的 [`CredentialKey`](../credentials/README.md#two-key-spaces-two-questions) 注册,因此 flow 声明了自己产出哪条记录,并通过该键的 scope 声明由哪个插件为记录内部的格式负责。第二种授权协议以另一个 flow 的形式到来,而不是另一个 seam。
+
+**写入由 flow 拥有。** `run()` 返回即表示记录已经通过 `ctx.credentials` 提交;seam 核实的是它在本次尝试期间观察到的提交——只看记录存在与否,会让重新授权把陈旧记录冒充成新鲜的——并拒绝那些返回时没提交记录的 flow。让提交发生在 flow 内部,才能使一个通过自有 store 适配器持久化的库保持为唯一写入方,而不是把凭据复制出来再写第二遍。
+
+**交互随请求传入,而非注册表。** 发起授权的一方才是能与人对话的一方,因此提示恰好抵达发问的那个界面,无头调用方则传入一个直接拒绝的交互实现。这样既不存在"环境提供方缺席"的问题,也不会出现某个提示该归两个已打开页面中哪一个的疑问。
+
+## 接口
+
+```ts
+import type { Context } from '@deepseek-ai/cordis'
+import { AuthorizationDeclinedError, type AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+
+declare const ctx: Context
+declare const exchange: (signal: AbortSignal) => Promise<void>
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')
+
+const dispose = ctx.authorization.registerFlow({
+  key,
+  label: 'ChatGPT (Codex)',
+  methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+  async run(session: AuthorizationSession) {
+    session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+    const code = await session.prompt({ kind: 'text', message: 'Paste the code' })
+    // Commits the record through ctx.credentials before resolving.
+    await exchange(session.signal)
+    void code
+  },
+})
+
+ctx.authorization.list()                    // [{ key, label, methods, inFlight }]
+ctx.authorization.describe(key)             // the same entry, or undefined
+await ctx.authorization.begin({             // { status: 'authorized' | 'cancelled' }
+  key,
+  interaction: { notify: () => {}, prompt: () => Promise.reject(new AuthorizationDeclinedError()) },
+})
+ctx.authorization.cancel(key)               // withdraw whatever is running for the key
+dispose()
+```
+
+同一个键同时只允许一次尝试。第二个调用方会收到 `ALREADY_IN_FLIGHT` 拒绝而不是被并入:否则两者会通过同一个 flow 向不同的人发问,而第二个人回答的是问给第一个人的问题。`inFlight` 放在 entry 上,界面据此把按钮渲染为禁用,而不是靠报错才发现。
+
+`cancel(key)` 与请求自带的 signal 并存,是因为请求/响应式传输要用第二次调用来响应"取消"按钮,而它拿不到第一次调用的 signal。注册在尝试进行中被 dispose 的 flow 也以同样方式撤销:它的执行体属于一个正在离开的插件。
+
+调用方在发起前就已撤销的尝试,既不占用该键也不启动 flow——若指望每个 flow 都在首个 await 之前检查自己的 signal,那么没有检查的那个就会占着键一直挂起。校验仍然先执行,因此调用方给出的键或方法不存在时,无论它是否已经放弃都会收到报错。
+
+人的"不"是一种结果,不是故障。选择拒绝的交互实现让 prompt 以 `AuthorizationDeclinedError` 拒绝,在提示被拒之后才失败的尝试以 `cancelled` 结算,与 signal 撤销完全一致;其余任何 prompt 拒绝仍是抵达调用方的 flow 故障。notice 依同一原则即发即忘,并由 seam 兜底:渲染不了 notice 的界面丢掉的是那条 notice,而不是整次尝试。
+
+`authorization/settled (key, settlement)` 在键释放之后触发,覆盖每一种终态。`settlement` 在 `begin()` 能返回的两种状态之外增加了 `failed`:失败以抛出的错误抵达其调用方,因此事件流是未发起该尝试的旁观者唯一能区分"被拒绝"与"出故障"的地方。监听器故障被就地遏制:每个监听器都会执行,抛错或拒绝只记录日志、不改变已结束尝试的结果,仅 `INVARIANT` 编码的故障在其余监听器执行完后重抛。
+
+## 交互词汇
+
+notice 是单向的,且从不携带机密:一条消息,以及可选的"人需要打开的页面"和"需要在该页面输入的码"。prompt 是 flow 无法自答的问题——`text`、`secret` 或 `select`——其中 `secret` 与 `text` 的差别仅在呈现方式。prompt 自带 `signal`,使得一个让手输码与浏览器回调赛跑的 flow 可以在尝试继续的同时撤下落败的那个问题;撤销整次尝试则用请求的 signal。
+
+这套词汇刻意小于任何单个 provider 的词汇:它描述的是界面必须渲染什么,因此能渲染一个 flow 的界面就能渲染全部 flow。
+
+## Model Experience
+
+无,因为授权是配置期与人的对话,flow、notice 与 prompt 都不会抵达模型请求。
+
+#### KV Cache effect
+
+不失效;任何授权状态都不会进入请求前缀。
+
+## Known Limitations and Deferred Work
+
+- **flow 不可恢复** —— 一次尝试只存活于发起它的进程中,因此登录途中刷新浏览器会丢弃它,人需要重来。可持久的尝试需要一个本 seam 并不具备的存储。
+- **没有吊销** —— 登出即 `ctx.credentials.deleteRecord(key)`,它只遗忘本地记录而不通知签发方。需要服务端吊销的 provider 目前无处声明这一点。
+- **没有 flow 的键是惰性的** —— seam 只报告已注册的内容,因此被卸载插件遗留的记录可以删除但无法重新授权。识别这种孤儿记录由调用方自行 join,与 [`listRecords()`](../credentials/README.md#surface) 的情况相同。

+ 51 - 0
packages/credentials/authorization/package.json

@@ -0,0 +1,51 @@
+{
+  "name": "@deepseek-ai/dsh-authorization",
+  "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human",
+  "version": "0.1.0-rc.8",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/credentials/authorization"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts"
+  ],
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/cordis": "workspace:^"
+  }
+}

+ 437 - 0
packages/credentials/authorization/src/index.ts

@@ -0,0 +1,437 @@
+/**
+ * Service Definition for the authorization capability seam (`ctx.authorization`):
+ * obtaining a credential nobody can supply from configuration alone, because
+ * getting it requires a conversation with the human — open this page, paste
+ * that code, pick an account.
+ *
+ * The seam owns the conversation and the lifecycle; it never owns the protocol.
+ * A plugin that knows how to obtain its own credential registers a flow keyed
+ * by the `CredentialKey` that flow writes, and the flow talks to whatever
+ * surface started it through one neutral vocabulary of notices and prompts. So
+ * a second authorization protocol arrives as another flow rather than as
+ * another seam, and a surface that renders one flow renders all of them.
+ *
+ * ```ts
+ * const dispose = ctx.authorization.registerFlow({
+ *   key: credentialKey('llm-pi-ai', 'openai-codex'),
+ *   label: 'ChatGPT (Codex)',
+ *   methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }],
+ *   async run(session) {
+ *     session.notify({ message: 'Continue in your browser', url })
+ *     await commitThroughCredentials(await exchange(session.signal))
+ *   },
+ * })
+ * ```
+ *
+ * @module @deepseek-ai/dsh-authorization
+ */
+
+import { Context, Service } from '@deepseek-ai/cordis'
+import type { CredentialKey } from '@deepseek-ai/dsh-credentials'
+import { HarnessError } from '@deepseek-ai/dsh-llm'
+
+import type {
+  AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
+  AuthorizationSettlement,
+} from './types.ts'
+
+export type {
+  AuthorizationEntry, AuthorizationMethod, AuthorizationNotice, AuthorizationOutcome, AuthorizationPrompt,
+  AuthorizationPromptOption, AuthorizationSettlement, AuthorizationStatus,
+} from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    authorization: AuthorizationService
+  }
+
+  interface Events {
+    /**
+     * One authorization attempt has finished and released its key. Fires for
+     * every terminal outcome, failures included, so a surface watching a key it
+     * did not start (a second browser tab) learns the attempt is over.
+     * @mode emit
+     * @param key - the credential record the finished attempt was authorizing.
+     * @param settlement - how it ended, including the `failed` case its caller sees as a thrown error.
+     */
+    'authorization/settled'(key: CredentialKey, settlement: AuthorizationSettlement): void
+  }
+}
+
+/** Stable error taxonomy for authorization failures. */
+export class AuthorizationError extends HarnessError {
+  constructor(message: string, code: string, options?: ErrorOptions) {
+    super(message, code, options)
+    this.name = 'AuthorizationError'
+  }
+}
+
+/**
+ * The rejection an {@link AuthorizationInteraction.prompt} uses to say the
+ * human declined — dismissed the question, chose not to answer — rather than
+ * that the surface broke. An attempt whose flow fails after a prompt was
+ * declined settles as `cancelled`, the same outcome as a withdrawn signal,
+ * because the human saying no is a refusal, not a breakage. Only a human's
+ * "no" may reject with this class: a prompt withdrawn by its own `signal` (a
+ * flow retiring the losing question of a race) must reject with something
+ * else, or a later genuine failure would be misread as a decline.
+ */
+export class AuthorizationDeclinedError extends AuthorizationError {
+  constructor(message = 'the authorization prompt was declined') {
+    super(message, 'DECLINED')
+    this.name = 'AuthorizationDeclinedError'
+  }
+}
+
+/**
+ * What a running flow is given to talk to the human. Every member is scoped to
+ * one attempt: the flow neither knows nor chooses which surface is listening.
+ */
+export interface AuthorizationSession {
+  /** The method id the caller picked, always one this flow declared. */
+  readonly method: string
+  /** Aborted when the caller withdraws or `cancel()` is called for this key. */
+  readonly signal: AbortSignal
+  /**
+   * Report progress, or tell the human what to do next. Fire-and-forget: a
+   * surface that cannot render a notice must not stall the flow.
+   * @param notice - the message, and any page or code it refers to.
+   */
+  notify(notice: AuthorizationNotice): void
+  /**
+   * Ask the human a question the flow cannot answer for itself.
+   * @param prompt - what to ask, and how it should be presented.
+   * @returns what the human typed, or the chosen option's id.
+   * @throws when the human declines, or the prompt's own signal withdraws it.
+   */
+  prompt(prompt: AuthorizationPrompt): Promise<string>
+}
+
+/**
+ * A plugin's knowledge of how to obtain one credential. The flow owns the
+ * write: `run()` resolving means the record for `key` is committed through
+ * `ctx.credentials` during that run, which the seam confirms — a commit
+ * observed within the attempt, still present after it — before reporting
+ * success. Committing inside the flow is what lets a library that persists
+ * through its own store adapter (pi-ai's `Models.login()`) stay the single
+ * writer instead of being copied back out and written twice.
+ */
+export interface AuthorizationFlow {
+  /** The credential record this flow writes. Its scope names the owning plugin. */
+  readonly key: CredentialKey
+  /** User-facing name of what is being authorized. */
+  readonly label: string
+  /**
+   * The methods offered, most preferred first; a caller naming none gets the
+   * first. Typed non-empty because a flow with nothing to run is a flow that
+   * cannot be begun, and the type says so at the one place flows are written.
+   */
+  readonly methods: readonly [AuthorizationMethod, ...AuthorizationMethod[]]
+  /**
+   * Run one attempt to obtain and commit the credential.
+   * @param session - the chosen method, the cancellation signal, and the interaction callbacks.
+   * @returns once the record is committed.
+   * @throws when the attempt fails or the human declines.
+   */
+  run(session: AuthorizationSession): Promise<void>
+}
+
+/**
+ * The surface half of one attempt. Supplied with the request rather than
+ * registered, because the caller that starts an authorization is the one that
+ * can talk to the human about it: prompts reach exactly the page that asked,
+ * and a headless caller supplies an interaction that declines.
+ */
+export interface AuthorizationInteraction {
+  /**
+   * Render a notice from the running flow.
+   * @param notice - the message, and any page or code it refers to.
+   */
+  notify(notice: AuthorizationNotice): void
+  /**
+   * Put a question to the human and wait.
+   * @param prompt - what to ask, and how it should be presented.
+   * @returns the typed text, or the chosen option's id.
+   * @throws {AuthorizationDeclinedError} when the human declines; any other
+   *   rejection reads as the surface failing, not as an answer.
+   */
+  prompt(prompt: AuthorizationPrompt): Promise<string>
+}
+
+/** One request to authorize a key. */
+export interface AuthorizationRequest {
+  /** The credential record to authorize; a flow must be registered for it. */
+  key: CredentialKey
+  /** Which of the flow's methods to run. Defaults to the flow's first. */
+  method?: string
+  /** The surface that will render this attempt's notices and prompts. */
+  interaction: AuthorizationInteraction
+  /** Withdraws the whole attempt. */
+  signal?: AbortSignal
+}
+
+/** One attempt in flight, with the handle that withdraws it. */
+interface InFlight {
+  readonly controller: AbortController
+}
+
+/**
+ * `ctx.authorization`: a registry of credential-obtaining flows, one attempt at
+ * a time per key.
+ */
+export class AuthorizationService extends Service {
+  /** The commit this seam confirms is a credential-record write, so the store is required, not optional. */
+  static inject = ['credentials']
+
+  private readonly flows = new Map<CredentialKey, AuthorizationFlow>()
+  private readonly running = new Map<CredentialKey, InFlight>()
+
+  constructor(ctx: Context) {
+    super(ctx, 'authorization')
+  }
+
+  /**
+   * Offer a way to obtain one credential. One flow per key: two plugins
+   * claiming the same key would each write a record in their own format, and
+   * whichever ran last would leave the other reading a payload it cannot parse.
+   *
+   * @param flow - the key it writes, its label, its methods, and its runner.
+   * @returns Disposer that withdraws this flow.
+   * @throws {AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.
+   */
+  registerFlow(flow: AuthorizationFlow): () => void {
+    const dispose = this.ctx.effect(function* (this: AuthorizationService) {
+      if (this.flows.has(flow.key)) {
+        throw new AuthorizationError(
+          `an authorization flow for "${flow.key}" is already registered`, 'DUPLICATE_FLOW')
+      }
+      this.flows.set(flow.key, flow)
+      yield () => {
+        this.flows.delete(flow.key)
+        // A flow leaving mid-attempt takes its attempt with it: the runner
+        // belongs to a plugin that is going away, so letting it keep prompting
+        // would outlive the fiber that can answer for it.
+        this.running.get(flow.key)?.controller.abort()
+      }
+    }.bind(this), 'authorization.registerFlow()')
+    return () => void dispose()
+  }
+
+  /**
+   * Every registered flow, for a surface listing what can be authorized.
+   * @returns one entry per flow, in registration order.
+   */
+  list(): readonly AuthorizationEntry[] {
+    return [...this.flows.values()].map(flow => this.entry(flow))
+  }
+
+  /**
+   * One registered flow.
+   * @param key - the credential record to ask about.
+   * @returns the entry, or undefined when no flow claims that key.
+   */
+  describe(key: CredentialKey): AuthorizationEntry | undefined {
+    const flow = this.flows.get(key)
+    return flow === undefined ? undefined : this.entry(flow)
+  }
+
+  /** The public view of one registered flow. */
+  private entry(flow: AuthorizationFlow): AuthorizationEntry {
+    return {
+      key: flow.key,
+      label: flow.label,
+      methods: flow.methods,
+      inFlight: this.running.has(flow.key),
+    }
+  }
+
+  /**
+   * Withdraw the attempt running for a key, if any. Separate from the
+   * request's own signal because a request/response transport answers a Cancel
+   * button on a second call, with no handle on the first one's signal.
+   * @param key - the credential record whose attempt should stop.
+   */
+  cancel(key: CredentialKey): void {
+    this.running.get(key)?.controller.abort()
+  }
+
+  /**
+   * Run one attempt to authorize a key, and report how it ended.
+   *
+   * One attempt per key at a time. A second caller is refused rather than
+   * joined: the two would be prompting different humans through the same flow,
+   * and the second would answer questions the first was asked.
+   *
+   * @param request - the key, the method, the surface, and the cancel signal.
+   * @returns `authorized` once the flow's record is committed during this
+   *   attempt and observed, or `cancelled` when the human declined or the
+   *   caller withdrew.
+   * @throws {AuthorizationError} code `NO_FLOW` when nothing claims the key,
+   *   `UNKNOWN_METHOD` when the named method is not one the flow offers,
+   *   `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or
+   *   `NOT_COMMITTED` when the flow resolved without committing a record
+   *   during the attempt.
+   */
+  async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome> {
+    const { key } = request
+    const flow = this.flows.get(key)
+    if (flow === undefined) {
+      throw new AuthorizationError(`no authorization flow is registered for "${key}"`, 'NO_FLOW')
+    }
+    const method = request.method ?? flow.methods[0].id
+    if (!flow.methods.some(candidate => candidate.id === method)) {
+      throw new AuthorizationError(
+        `authorization flow for "${key}" offers no method "${method}"`, 'UNKNOWN_METHOD')
+    }
+    if (this.running.has(key)) {
+      throw new AuthorizationError(
+        `an authorization attempt for "${key}" is already running`, 'ALREADY_IN_FLIGHT')
+    }
+    // Withdrawn before it began: never claim the slot and never run the flow.
+    // Handing an aborted signal to `run()` would rely on every flow checking it
+    // before its first await, and one that does not would hang holding the key.
+    // Validation still runs first, so a caller naming a key or method that does
+    // not exist hears about it whether or not it also gave up.
+    if (request.signal?.aborted === true) return { status: 'cancelled' }
+    const controller = new AbortController()
+    const withdraw = (): void => { controller.abort(request.signal?.reason) }
+    request.signal?.addEventListener('abort', withdraw, { once: true })
+    this.running.set(key, { controller })
+    let settlement: AuthorizationSettlement = 'failed'
+    try {
+      const outcome = await this.attempt(flow, method, controller.signal, request.interaction)
+      settlement = outcome.status
+      return outcome
+    } finally {
+      request.signal?.removeEventListener('abort', withdraw)
+      this.running.delete(key)
+      // After the slot is released, so a listener that reacts by starting the
+      // next attempt is not refused by the one that just finished.
+      this.settle(key, settlement)
+    }
+  }
+
+  /* jscpd:ignore-start -- deliberate symmetry with the credentials seam's
+     commit fan-out (`CredentialProvider`): the contained-dispatch shape is the
+     reviewed listener-lifecycle contract, and extracting it would couple the
+     two seams' event semantics. */
+  /**
+   * Fan `authorization/settled` out with contained listener failures: every
+   * listener runs, and a sync throw or async rejection is logged without
+   * changing the finished attempt's own outcome — except `INVARIANT`-coded
+   * failures, which rethrow after every listener ran. The attempt is already
+   * over and its key released when this fires, so a broken watcher (that
+   * second browser tab) can never turn the caller's settled result into a
+   * failure of its own.
+   */
+  private settle(key: CredentialKey, settlement: AuthorizationSettlement): void {
+    let invariantFailure: unknown
+    const args = ['authorization/settled', key, settlement]
+    for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
+      try {
+        const returned = listener(key, settlement)
+        if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
+          void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
+            this.warnSettledListenerFailure(key, error)
+          })
+        }
+      } catch (error) {
+        if ((error as { code?: unknown } | null)?.code === 'INVARIANT') {
+          invariantFailure ??= error
+          continue
+        }
+        this.warnSettledListenerFailure(key, error)
+      }
+    }
+    if (invariantFailure !== undefined) throw invariantFailure as Error
+  }
+  /* jscpd:ignore-end */
+
+  /** Contained-listener diagnostic shared by the sync and async failure paths. */
+  private warnSettledListenerFailure(key: CredentialKey, error: unknown): void {
+    this.ctx.logger.warn('authorization: an authorization/settled listener for "%s" failed', key)
+    this.ctx.logger.warn(error)
+  }
+
+  /** Run the flow, then hold it to its half of the commit contract. */
+  private async attempt(
+    flow: AuthorizationFlow,
+    method: string,
+    signal: AbortSignal,
+    interaction: AuthorizationInteraction,
+  ): Promise<AuthorizationOutcome> {
+    // Withdrawal settles the attempt whether or not the flow reacts to it. A
+    // flow is supposed to stop when its signal fires, but one that does not
+    // would otherwise hold the key for the life of the process, and a wedged
+    // key is indistinguishable from a busy one from the outside. The orphaned
+    // run is left to finish on its own; nothing waits on it, and a record it
+    // still manages to commit is a record the human did authorize.
+    const withdrawn = new Promise<'withdrawn'>((resolve) => {
+      // `begin()` returns before claiming the key when its caller has already
+      // withdrawn, so this signal cannot already be aborted here.
+      signal.addEventListener('abort', () => { resolve('withdrawn') }, { once: true })
+    })
+    // What the seam itself witnessed during the run, held as properties
+    // because closure writes do not narrow locals across awaits: the prompt
+    // wrapper sees a decline first-hand (a flow that rewraps the rejection on
+    // its way out cannot hide it), and confirming the commit means confirming
+    // it happened *now* — on a re-auth the record already exists, so presence
+    // alone would let a flow that wrote nothing report the stale credential
+    // as freshly authorized.
+    const observed = { declined: false, committed: false }
+    const unwatch = this.ctx.on('credentials/record-updated', (key: CredentialKey) => {
+      if (key === flow.key) observed.committed = true
+    })
+    try {
+      const running = flow.run({
+        method,
+        signal,
+        notify: (notice) => {
+          try {
+            interaction.notify(notice)
+          } catch (error) {
+            // Fire-and-forget is held at the seam: a surface that cannot
+            // render a notice (a page whose connection just closed) loses the
+            // notice, never the attempt.
+            this.ctx.logger.warn('authorization: the interaction surface failed to render a notice')
+            this.ctx.logger.warn(error)
+          }
+        },
+        prompt: prompt => interaction.prompt(prompt).catch((error: unknown) => {
+          if (error instanceof AuthorizationDeclinedError) observed.declined = true
+          throw error
+        }),
+      })
+      try {
+        if (await Promise.race([running.then(() => 'ran' as const), withdrawn]) === 'withdrawn') {
+          // Nothing awaits the orphan any more, so its eventual failure has to be
+          // marked handled or it would take down the process.
+          void running.catch(() => { this.ctx.logger.debug('authorization: withdrawn flow failed after the fact') })
+          return { status: 'cancelled' }
+        }
+      } catch (error) {
+        // A withdrawn attempt and a declined prompt are outcomes, not
+        // failures: the human said no, or closed the page. Anything else is
+        // the flow failing and belongs to the caller, cause chain intact.
+        if (signal.aborted || observed.declined) return { status: 'cancelled' }
+        throw error
+      }
+    } finally {
+      unwatch()
+    }
+    if (!observed.committed) {
+      throw new AuthorizationError(
+        `authorization flow for "${flow.key}" resolved without committing a credential record in this attempt`,
+        'NOT_COMMITTED')
+    }
+    const stored = await this.ctx.credentials.describeRecord(flow.key)
+    if (!stored.configured) {
+      throw new AuthorizationError(
+        `authorization flow for "${flow.key}" deleted its credential record instead of committing one`,
+        'NOT_COMMITTED')
+    }
+    return { status: 'authorized' }
+  }
+}
+
+export default AuthorizationService

+ 45 - 0
packages/credentials/authorization/src/invariant.ts

@@ -0,0 +1,45 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-authorization`.
+ * @module @deepseek-ai/dsh-authorization/invariant
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-authorization'
+
+/** Cordis companion plugin name. */
+export const name = 'authorization-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * Install the single-flight release contract: `authorization/settled` names a
+ * finished attempt, and the seam admits one attempt per key, so the key must
+ * already be free when the event fires. A slot still held at settlement is
+ * unrecoverable — every later `begin()` for that key is refused as
+ * `ALREADY_IN_FLIGHT` until the process restarts — and it is invisible from the
+ * outside, because a wedged key looks exactly like a busy one.
+ */
+const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
+  ctx.on('authorization/settled', (key) => {
+    const authorization = ctx.get('authorization')
+    if (authorization === undefined) {
+      fail(`authorization/settled for "${key}" emitted without a live authorization service`)
+      return
+    }
+    // A flow withdrawn during its own attempt settles with nothing left to
+    // describe, which is the disposer's documented behavior rather than a leak.
+    if (authorization.describe(key)?.inFlight === true) {
+      fail(`authorization/settled for "${key}" left the key in flight, wedging every later attempt`)
+    }
+  })
+}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))

+ 91 - 0
packages/credentials/authorization/src/types.ts

@@ -0,0 +1,91 @@
+/**
+ * Wire-safe authorization types, free of cordis/service imports so browser type
+ * chains (apiproxy api → client) can consume them without loading this
+ * package's Context augmentation.
+ * @module @deepseek-ai/dsh-authorization/types
+ */
+
+import type { CredentialKey } from '@deepseek-ai/dsh-credentials/types'
+
+/** One way a flow can obtain its credential, named by the flow that offers it. */
+export interface AuthorizationMethod {
+  /** Flow-owned identifier, echoed back when a caller picks this method. */
+  id: string
+  /** User-facing label for a picker. */
+  label: string
+}
+
+/** A running flow's report to whoever is watching it. Never carries a secret. */
+export interface AuthorizationNotice {
+  /** What is happening, or what the human must do next. */
+  message: string
+  /** A page the human must open to continue. */
+  url?: string
+  /** A short code the human must enter on that page. */
+  code?: string
+}
+
+/** One choice offered by a `select` prompt. */
+export interface AuthorizationPromptOption {
+  /** Value returned when this option is chosen. */
+  id: string
+  /** User-facing label. */
+  label: string
+  /** Optional extra context rendered by capable surfaces. */
+  description?: string
+}
+
+/**
+ * A question a flow must have answered before it can continue. `secret` differs
+ * from `text` only in presentation — a surface masks it and keeps it out of
+ * logs — and `select` answers with the chosen option's `id`.
+ */
+export type AuthorizationPrompt = {
+  /**
+   * Withdraws this prompt alone, leaving the flow running. A flow that races a
+   * typed code against a browser callback aborts the losing prompt here; the
+   * whole authorization is cancelled through the request's signal instead.
+   */
+  signal?: AbortSignal
+} & ({
+  kind: 'text'
+  message: string
+  placeholder?: string
+} | {
+  kind: 'secret'
+  message: string
+  placeholder?: string
+} | {
+  kind: 'select'
+  message: string
+  options: readonly AuthorizationPromptOption[]
+})
+
+/** How one authorization attempt ended, as its own caller sees it. */
+export type AuthorizationStatus = 'authorized' | 'cancelled'
+
+/**
+ * How one attempt ended, as an onlooker sees it. A failure reaches its caller
+ * as a thrown error rather than an outcome, so `failed` exists only here — on
+ * the event stream, where a watcher that did not start the attempt has no
+ * other way to tell a refusal from a breakage.
+ */
+export type AuthorizationSettlement = AuthorizationStatus | 'failed'
+
+/** The result of one `begin()` attempt. */
+export interface AuthorizationOutcome {
+  /** `authorized` once the record is committed and observed; `cancelled` when the human or caller withdrew. */
+  status: AuthorizationStatus
+}
+
+/** A registered flow as a surface sees it: what it authorizes and whether it is busy. */
+export interface AuthorizationEntry {
+  /** The credential record this flow writes. */
+  key: CredentialKey
+  /** User-facing name of what is being authorized. */
+  label: string
+  /** The methods this flow offers, most preferred first. */
+  methods: readonly AuthorizationMethod[]
+  /** Whether an attempt for this key is running right now. */
+  inFlight: boolean
+}

+ 465 - 0
packages/credentials/authorization/tests/authorization.spec.ts

@@ -0,0 +1,465 @@
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+import AuthorizationService, {
+  AuthorizationDeclinedError,
+  type AuthorizationFlow,
+  type AuthorizationInteraction,
+  type AuthorizationSession,
+} from '@deepseek-ai/dsh-authorization'
+import { MemoryCredentials } from './memory.ts'
+
+const KEY = credentialKey('llm-pi-ai', 'openai-codex')
+const OTHER = credentialKey('llm-pi-ai', 'anthropic')
+
+/** A context with the record store the seam confirms commits against. */
+async function harness(): Promise<Context> {
+  const ctx = new Context()
+  await ctx.plugin(MemoryCredentials)
+  await ctx.plugin(AuthorizationService)
+  return ctx
+}
+
+/** An interaction that answers every prompt with the same string. */
+function surface(answer = 'typed'): AuthorizationInteraction & {
+  notices: unknown[]
+  prompts: unknown[]
+} {
+  const notices: unknown[] = []
+  const prompts: unknown[] = []
+  return {
+    notices,
+    prompts,
+    notify: (notice) => { notices.push(notice) },
+    prompt: (prompt) => {
+      prompts.push(prompt)
+      return Promise.resolve(answer)
+    },
+  }
+}
+
+/** A flow that commits `key` through the record store and then resolves. */
+function committingFlow(
+  ctx: Context,
+  key = KEY,
+  run?: (session: AuthorizationSession) => Promise<void>,
+): AuthorizationFlow {
+  return {
+    key,
+    label: 'ChatGPT (Codex)',
+    methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
+    async run(session) {
+      await run?.(session)
+      await ctx.credentials.modifyRecord(key, () =>
+        Promise.resolve({ kind: 'grant', payload: { token: 'granted' } }))
+    },
+  }
+}
+
+describe('AuthorizationService registry', () => {
+  it('lists a registered flow and drops it when the registration is disposed', async () => {
+    const ctx = await harness()
+
+    const dispose = ctx.authorization.registerFlow(committingFlow(ctx))
+
+    expect(ctx.authorization.list()).toEqual([{
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in with ChatGPT' }, { id: 'api-key', label: 'Paste a key' }],
+      inFlight: false,
+    }])
+    expect(ctx.authorization.describe(KEY)?.label).toBe('ChatGPT (Codex)')
+    expect(ctx.authorization.describe(OTHER)).toBeUndefined()
+
+    dispose()
+
+    expect(ctx.authorization.list()).toEqual([])
+    expect(ctx.authorization.describe(KEY)).toBeUndefined()
+  })
+
+  it('refuses a second flow for the same key', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    expect(() => ctx.authorization.registerFlow(committingFlow(ctx)))
+      .toThrow(/already registered/)
+  })
+
+  it('withdraws an attempt still running when its flow leaves', async () => {
+    const ctx = await harness()
+    let started: (() => void) | undefined
+    const running = new Promise<void>((resolve) => {
+      started = resolve
+    })
+    const dispose = ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        started?.()
+        session.signal.addEventListener('abort', () => { reject(new Error('withdrawn')) }, { once: true })
+      })))
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await running
+    dispose()
+
+    await expect(attempt).resolves.toEqual({ status: 'cancelled' })
+  })
+})
+
+describe('AuthorizationService.begin', () => {
+  it('runs the flow, confirms the committed record, and reports the settlement', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+
+    expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
+    expect(settled).toHaveBeenCalledWith(KEY, 'authorized')
+  })
+
+  it('runs the flow first method when the caller names none, and the named one when it does', async () => {
+    const ctx = await harness()
+    const seen: string[] = []
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
+      seen.push(session.method)
+      return Promise.resolve()
+    }))
+
+    await ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await ctx.authorization.begin({ key: KEY, method: 'api-key', interaction: surface() })
+
+    expect(seen).toEqual(['oauth', 'api-key'])
+  })
+
+  it('carries notices and prompts between the flow and the calling surface', async () => {
+    const ctx = await harness()
+    const answers: string[] = []
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
+      session.notify({ message: 'Continue in your browser', url: 'https://auth.example/start' })
+      answers.push(await session.prompt({ kind: 'text', message: 'Paste the code' }))
+    }))
+    const ui = surface('code-123')
+
+    await ctx.authorization.begin({ key: KEY, interaction: ui })
+
+    expect(ui.notices).toEqual([{ message: 'Continue in your browser', url: 'https://auth.example/start' }])
+    expect(ui.prompts).toEqual([{ kind: 'text', message: 'Paste the code' }])
+    expect(answers).toEqual(['code-123'])
+  })
+
+  it('refuses a key no flow claims', async () => {
+    const ctx = await harness()
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/no authorization flow is registered/)
+  })
+
+  it('refuses a method the flow does not offer', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    await expect(ctx.authorization.begin({ key: KEY, method: 'device', interaction: surface() }))
+      .rejects.toThrow(/offers no method "device"/)
+  })
+
+  it('refuses a second attempt while one is running, and admits one after it settles', async () => {
+    const ctx = await harness()
+    // Only the first attempt blocks; the later ones must be free to complete,
+    // which is what shows the key was released rather than merely idle-looking.
+    const held = Promise.withResolvers<undefined>()
+    const started = Promise.withResolvers<undefined>()
+    let first = true
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
+      if (!first) return Promise.resolve()
+      first = false
+      started.resolve(undefined)
+      return held.promise
+    }))
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await started.promise
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(true)
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/already running/)
+
+    held.resolve(undefined)
+    await expect(attempt).resolves.toEqual({ status: 'authorized' })
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+  })
+
+  it('never starts a flow whose caller withdrew before begin', async () => {
+    const ctx = await harness()
+    const ran = vi.fn()
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
+      ran()
+      return new Promise(() => {})
+    }))
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      interaction: surface(),
+      signal: AbortSignal.abort(),
+    })).resolves.toEqual({ status: 'cancelled' })
+
+    expect(ran).not.toHaveBeenCalled()
+    // Nothing occupied the key, so nothing settled on it either.
+    expect(settled).not.toHaveBeenCalled()
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+  })
+
+  it('still reports an unknown method to a caller that already withdrew', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      method: 'device',
+      interaction: surface(),
+      signal: AbortSignal.abort(),
+    })).rejects.toThrow(/offers no method "device"/)
+  })
+
+  it('reports a caller that withdraws mid-flight as cancelled', async () => {
+    const ctx = await harness()
+    const controller = new AbortController()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        session.signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
+        controller.abort()
+      })))
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface(), signal: controller.signal }))
+      .resolves.toEqual({ status: 'cancelled' })
+  })
+
+  it('withdraws a running attempt through cancel(), and ignores cancel() for an idle key', async () => {
+    const ctx = await harness()
+    const started = Promise.withResolvers<undefined>()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      new Promise((_resolve, reject) => {
+        session.signal.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true })
+        started.resolve(undefined)
+      })))
+    ctx.authorization.cancel(OTHER)
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await started.promise
+    ctx.authorization.cancel(KEY)
+
+    await expect(attempt).resolves.toEqual({ status: 'cancelled' })
+  })
+
+  it('settles a withdrawn attempt even when its flow never reacts to the signal', async () => {
+    const ctx = await harness()
+    const orphan = Promise.withResolvers<undefined>()
+    const started = Promise.withResolvers<undefined>()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () => {
+      started.resolve(undefined)
+      return orphan.promise
+    }))
+
+    const attempt = ctx.authorization.begin({ key: KEY, interaction: surface() })
+    await started.promise
+    ctx.authorization.cancel(KEY)
+
+    await expect(attempt).resolves.toEqual({ status: 'cancelled' })
+    // The key is free again immediately, rather than at the mercy of a flow
+    // that may never settle.
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+    // The orphan's own failure is nobody's to await, and must not surface as an
+    // unhandled rejection.
+    orphan.reject(new Error('gave up long after the human left'))
+    await expect(orphan.promise).rejects.toThrow('gave up long after the human left')
+  })
+
+  it('propagates a flow failure to its caller and settles the key as failed', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, () =>
+      Promise.reject(new Error('the token endpoint said no'))))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow('the token endpoint said no')
+
+    expect(settled).toHaveBeenCalledWith(KEY, 'failed')
+    expect(ctx.authorization.describe(KEY)?.inFlight).toBe(false)
+  })
+
+  it('refuses a flow that resolves without committing its record', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'Forgetful',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => Promise.resolve(),
+    })
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/resolved without committing a credential record/)
+  })
+})
+
+describe('commit confirmation', () => {
+  it('refuses a re-auth that left only the record of an earlier attempt', async () => {
+    const ctx = await harness()
+    await ctx.credentials.modifyRecord(KEY, () =>
+      Promise.resolve({ kind: 'grant', payload: { token: 'stale' } }))
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'Forgetful',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      // A commit for another key is not this flow's commit either.
+      async run() {
+        await ctx.credentials.modifyRecord(OTHER, () =>
+          Promise.resolve({ kind: 'grant', payload: { token: 'other' } }))
+      },
+    })
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/without committing a credential record in this attempt/)
+    // Refused, not cleaned up: the stale record still belongs to its owner.
+    expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'stale' } })
+  })
+
+  it('refuses a flow that deleted its record instead of committing one', async () => {
+    const ctx = await harness()
+    await ctx.credentials.modifyRecord(KEY, () =>
+      Promise.resolve({ kind: 'grant', payload: { token: 'stale' } }))
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'Destructive',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => ctx.credentials.deleteRecord(KEY),
+    })
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/deleted its credential record/)
+  })
+})
+
+describe('declined prompts', () => {
+  it('reports an attempt whose prompt the human declined as cancelled, not failed', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
+      await session.prompt({ kind: 'text', message: 'Paste the code' })
+    }))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+    const declining: AuthorizationInteraction = {
+      notify: () => undefined,
+      prompt: () => Promise.reject(new AuthorizationDeclinedError()),
+    }
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: declining }))
+      .resolves.toEqual({ status: 'cancelled' })
+
+    expect(settled).toHaveBeenCalledWith(KEY, 'cancelled')
+  })
+
+  it('reads a decline through a flow that rewraps the rejection on its way out', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, session =>
+      session.prompt({ kind: 'text', message: 'Paste the code' }).then(
+        () => undefined,
+        () => {
+          throw new Error('sign-in aborted')
+        })))
+    const declining: AuthorizationInteraction = {
+      notify: () => undefined,
+      prompt: () => Promise.reject(new AuthorizationDeclinedError()),
+    }
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: declining }))
+      .resolves.toEqual({ status: 'cancelled' })
+  })
+
+  it('keeps a prompt failure that is not a decline a flow failure', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, async (session) => {
+      await session.prompt({ kind: 'text', message: 'Paste the code' })
+    }))
+    const settled = vi.fn()
+    ctx.on('authorization/settled', settled)
+    const broken: AuthorizationInteraction = {
+      notify: () => undefined,
+      prompt: () => Promise.reject(new Error('the transport dropped')),
+    }
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: broken }))
+      .rejects.toThrow('the transport dropped')
+
+    expect(settled).toHaveBeenCalledWith(KEY, 'failed')
+  })
+})
+
+describe('notice containment', () => {
+  it('loses the notice, never the attempt, when the surface cannot render it', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx, KEY, (session) => {
+      session.notify({ message: 'Continue in your browser' })
+      return Promise.resolve()
+    }))
+    const broken: AuthorizationInteraction = {
+      notify: () => {
+        throw new Error('page connection closed')
+      },
+      prompt: () => Promise.resolve('unused'),
+    }
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: broken }))
+      .resolves.toEqual({ status: 'authorized' })
+  })
+})
+
+describe('the settled fan-out', () => {
+  it('keeps a throwing listener from changing a finished attempt, and later listeners still run', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+    ctx.on('authorization/settled', () => {
+      throw new Error('watcher boom')
+    })
+    const second = vi.fn()
+    ctx.on('authorization/settled', second)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+
+    expect(second).toHaveBeenCalledWith(KEY, 'authorized')
+  })
+
+  it('contains an async listener rejection', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+    // An unknown-returning function keeps the typed surface legal while the
+    // runtime value is still the rejected promise the containment must handle.
+    const boom = (): unknown => Promise.reject(new Error('async watcher boom'))
+    ctx.on('authorization/settled', boom)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .resolves.toEqual({ status: 'authorized' })
+    await new Promise(resolve => setTimeout(resolve, 10))
+  })
+
+  it('rethrows an invariant-coded listener failure after the remaining listeners', async () => {
+    const ctx = await harness()
+    ctx.authorization.registerFlow(committingFlow(ctx))
+    ctx.on('authorization/settled', () => {
+      throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
+    })
+    const second = vi.fn()
+    ctx.on('authorization/settled', second)
+
+    await expect(ctx.authorization.begin({ key: KEY, interaction: surface() }))
+      .rejects.toThrow(/forged relation/)
+    // Harness-fatal by design — but the record itself committed first.
+    expect(second).toHaveBeenCalledWith(KEY, 'authorized')
+    expect(await ctx.credentials.readRecord(KEY)).toEqual({ kind: 'grant', payload: { token: 'granted' } })
+  })
+})

+ 87 - 0
packages/credentials/authorization/tests/invariant.spec.ts

@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { credentialKey } from '@deepseek-ai/dsh-credentials'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import AuthorizationService from '@deepseek-ai/dsh-authorization'
+import * as AuthorizationInvariant from '../src/invariant.ts'
+import { MemoryCredentials } from './memory.ts'
+
+const KEY = credentialKey('llm-pi-ai', 'openai-codex')
+
+describe('authorization invariant companion', () => {
+  it('accepts an attempt that released its key before settling', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => ctx.credentials
+        .modifyRecord(KEY, () => Promise.resolve({ kind: 'grant', payload: {} }))
+        .then(() => undefined),
+    })
+
+    await expect(ctx.authorization.begin({
+      key: KEY,
+      interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
+    })).resolves.toEqual({ status: 'authorized' })
+  })
+
+  it('fails a settlement that left its key in flight', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+    const started = Promise.withResolvers<undefined>()
+    ctx.authorization.registerFlow({
+      key: KEY,
+      label: 'ChatGPT (Codex)',
+      methods: [{ id: 'oauth', label: 'Sign in' }],
+      run: () => {
+        started.resolve(undefined)
+        return new Promise(() => {})
+      },
+    })
+    void ctx.authorization.begin({
+      key: KEY,
+      interaction: { notify: () => {}, prompt: () => Promise.reject(new Error('unused')) },
+    })
+    await started.promise
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'authorized') })
+      .toThrow(/left the key in flight/)
+  })
+
+  it('fails a settlement emitted without a live service', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') })
+      .toThrow(/without a live authorization service/)
+  })
+
+  it('accepts a settlement whose flow left during its own attempt', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+    await ctx.plugin(MemoryCredentials)
+    await ctx.plugin(AuthorizationService)
+
+    expect(() => { ctx.emit('authorization/settled', KEY, 'cancelled') }).not.toThrow()
+  })
+
+  it('reserves the package name against duplicate registration', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(AuthorizationInvariant)
+
+    expect(() => {
+      ctx.invariants.register('@deepseek-ai/dsh-authorization', () => {})
+    }).toThrow(/already registered/)
+  })
+})

+ 70 - 0
packages/credentials/authorization/tests/memory.ts

@@ -0,0 +1,70 @@
+import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
+import type {
+  CredentialInfo,
+  CredentialKey,
+  CredentialRecord,
+  CredentialRecordEntry,
+  CredentialRecordInfo,
+  CredentialRef,
+  ResolvedCredential,
+} from '@deepseek-ai/dsh-credentials'
+
+/**
+ * In-memory credentials provider for the authorization suite. Only the record
+ * half is exercised — the seam's whole interest in this service is whether a
+ * flow left a record behind — so the reference half answers "nothing stored".
+ */
+// TODO: near-duplicate of the record half of
+// packages/credentials/credentials/tests/memory.ts; fold both into a shared
+// test-support double when a third suite needs one.
+export class MemoryCredentials extends CredentialProvider {
+  private readonly records = new Map<CredentialKey, CredentialRecord>()
+
+  override resolve(_ref: CredentialRef): Promise<ResolvedCredential | undefined> {
+    return Promise.resolve(undefined)
+  }
+
+  override describe(_ref: CredentialRef): Promise<CredentialInfo> {
+    return Promise.resolve({ configured: false, writable: true })
+  }
+
+  override set(_ref: CredentialRef, _value: string): Promise<void> {
+    return Promise.resolve()
+  }
+
+  override unset(_ref: CredentialRef): Promise<void> {
+    return Promise.resolve()
+  }
+
+  override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
+    return Promise.resolve(this.records.get(key))
+  }
+
+  override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
+    const stored = this.records.get(key)
+    return Promise.resolve(stored === undefined
+      ? { configured: false, writable: true }
+      : { configured: true, kind: stored.kind, writable: true })
+  }
+
+  override listRecords(): Promise<readonly CredentialRecordEntry[]> {
+    return Promise.resolve([...this.records].map(([key, record]) => ({ key, kind: record.kind })))
+  }
+
+  override async modifyRecord(
+    key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined> {
+    const current = this.records.get(key)
+    const next = await mutate(current)
+    if (next === undefined) return current
+    this.records.set(key, next)
+    this.ctx.emit('credentials/record-updated', key)
+    return next
+  }
+
+  override deleteRecord(key: CredentialKey): Promise<void> {
+    if (this.records.delete(key)) this.ctx.emit('credentials/record-updated', key)
+    return Promise.resolve()
+  }
+}

+ 27 - 0
packages/credentials/authorization/tsconfig.json

@@ -0,0 +1,27 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cosmokit"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../credentials"
+    },
+    {
+      "path": "../../llm/llm"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    }
+  ]
+}

+ 2 - 2
packages/credentials/credentials-local/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/credentials/credentials-local/README.md
-README.md: 63895d34fe8396d55c1d6ef29b3e3d9a9dc17264
-README.zh.md: 3c60fb7bbf06a0ae9ec905f36a5c543c2fb6af1b
+README.md: 7acd89ea24be4da3d2cecc6efe619e73e05bb823
+README.zh.md: 4b62904af23dd7e907f045e0713abba59c2abba9

+ 27 - 5
packages/credentials/credentials-local/README.md

@@ -28,14 +28,36 @@ Under the product CLI, resolution reads the launcher's frozen [environment snaps
 
 ## The document
 
-A YAML mapping of credential reference to value, and nothing else:
+A versioned YAML document with one section per key space, and nothing else:
 
 ```yaml
-DEEPSEEK_API_KEY: sk-…
-OPENAI_API_KEY: sk-…
+version: 1
+
+refs:
+  DEEPSEEK_API_KEY: sk-…
+  OPENAI_API_KEY: sk-…
+
+records:
+  llm-pi-ai/openai-codex:
+    kind: grant
+    payload:                    # written verbatim; this provider does not interpret it
+      type: oauth
+      access: eyJhbGciOi…
+      refresh: rft_9f8e7d…
+      expires: 1786000000000
+  llm-pi-ai/amazon-bedrock:
+    kind: api-key               # environment values, no key: this route uses an AWS profile
+    env:
+      AWS_PROFILE: prod
+  llm-pi-ai/amazon-bedrock-dev:
+    kind: api-key               # neither: the owner confirmed the ambient credential chain
 ```
 
-The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the secret I stored has no effect". A non-mapping root, a key that is not a POSIX identifier, a non-string value, an empty string, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload. There is no `version` field and no wrapper level; the format is the mapping.
+The document holds credentials only, so every deviation is a rejection rather than a skipped entry — a silently ignored key would read as "the credential I stored has no effect". A non-mapping root, an unknown top-level key, a key that is not addressable in its space, a wrong-typed value, an empty string, an unknown record tag or field, a duplicate key, and malformed YAML all fail: loud at boot, and warn-and-keep-the-last-good-snapshot on a live reload.
+
+A `grant` payload must survive a JSON round trip, enforced in both directions. YAML spells values JSON has none for — `.inf`, alias cycles — and an owner may hand over a `Date` or a `bigint`; either way the store refuses rather than saving something it could not read back exactly as written.
+
+The pre-release layout was a flat mapping with no `version`. A boot that recognizes it exactly — addressable names over non-empty string scalars, no directives — upgrades the document in place under the writer lock: the original lines nest verbatim under `refs:`, so values, comments, and spellings survive byte for byte. Any other flat shape is refused by name, with the entry count and the one edit needed (`version: 1`, nest under `refs:`) — never read as an empty store, which would surface as an authentication failure on the first request instead of at load. A live reload never migrates: a flat document restored mid-run keeps the last good snapshot until the next boot.
 
 Writes patch the parsed document rather than rebuilding it, so comments and the formatting of every untouched entry survive. A comment directly above an entry is that entry's annotation and is removed with it. Every write first re-reads the document under the cross-process writer lock of [`dsh-atomic-write`](../../util/atomic-write/README.md) and publishes anything it had not observed, then commits atomically with mode `0600` under an owner-only (`0700`) directory — so a concurrent writer or an external edit inside the watcher's debounce window is folded in rather than overwritten. An on-disk document that no longer parses fails the write instead of overwriting content the provider could not understand.
 
@@ -47,7 +69,7 @@ The provider creates the directory `0700` and creates or atomically replaces the
 
 ## Hot reload
 
-External edits publish `credentials/updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
+External edits publish `credentials/reference-updated` per changed reference after the snapshot is replaced **wholesale** — an entry deleted on disk never lingers in memory. Before Chokidar opens the target, the provider realpaths its deepest existing ancestor and restores any missing suffix; file access and diagnostics retain the configured path, while Windows cannot mix an 8.3 alias with long-form libuv events. The provider's own writes are recognized by content and publish exactly their one commit event. An unreadable or invalid document at runtime keeps the last good snapshot and warns; an absent file is an empty store; an unreadable or invalid file at boot fails loud.
 
 ## Security boundary
 

+ 27 - 5
packages/credentials/credentials-local/README.zh.md

@@ -28,14 +28,36 @@
 
 ## 文档本身
 
-一个从凭据引用到值的 YAML mapping,除此之外别无他物:
+一个带版本的 YAML 文档,每个键空间一个分节,除此之外别无他物:
 
 ```yaml
-DEEPSEEK_API_KEY: sk-…
-OPENAI_API_KEY: sk-…
+version: 1
+
+refs:
+  DEEPSEEK_API_KEY: sk-…
+  OPENAI_API_KEY: sk-…
+
+records:
+  llm-pi-ai/openai-codex:
+    kind: grant
+    payload:                    # written verbatim; this provider does not interpret it
+      type: oauth
+      access: eyJhbGciOi…
+      refresh: rft_9f8e7d…
+      expires: 1786000000000
+  llm-pi-ai/amazon-bedrock:
+    kind: api-key               # environment values, no key: this route uses an AWS profile
+    env:
+      AWS_PROFILE: prod
+  llm-pi-ai/amazon-bedrock-dev:
+    kind: api-key               # neither: the owner confirmed the ambient credential chain
 ```
 
-该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的密钥没有生效」。非 mapping 的根、非 POSIX 标识符的键、非字符串值、空字符串、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。没有 `version` 字段,也没有包装层;格式就是这个 mapping。
+该文档只存放凭据,因此任何偏离都是拒绝,而不是跳过某个条目——被静默忽略的键读起来就是「我存进去的凭据没有生效」。非 mapping 的根、未知的顶层键、在其空间中不可寻址的键、类型不符的值、空字符串、未知的记录标签或字段、重复键以及格式错误的 YAML 全部失败:启动时明确报错,运行期热重载则告警并保留最后可用快照。
+
+`grant` 的 payload 必须能经受 JSON 往返,读写两个方向都强制。YAML 能拼写出 JSON 没有的值——`.inf`、别名环——拥有者也可能递来 `Date` 或 `bigint`;无论哪种,存储都选择拒绝,而不是存下一个自己无法逐字读回的东西。
+
+发布前的旧布局是没有 `version` 的扁平 mapping。启动时若能精确识别它——可寻址名称对非空字符串标量、且没有文档指令——就在写锁下原地升级:原有各行逐字下沉到 `refs:` 之下,值、注释与拼写逐字节保留。其余任何扁平形态都会被指名拒绝,并给出条目数与唯一需要做的编辑(`version: 1`,条目下沉到 `refs:`)——绝不当作空存储读过去,否则它会以第一次请求认证失败的形式出现,而不是在加载时。热重载从不迁移:运行中被恢复出来的扁平文档只会保住上一份完好快照,直到下次启动。
 
 写入是对已解析文档打补丁而不是重建,因此注释与所有未触及条目的排版都会保留。直接位于某条目上方的注释属于该条目的注解,会随它一起删除。每次写入都先在 [`dsh-atomic-write`](../../util/atomic-write/README.md) 的跨进程写锁下重读文档、把此前未观察到的一切发布出去,再在仅属主可访问(`0700`)的目录下以 `0600` 权限原子提交——因此并发写入者、或落在 watcher 防抖窗口内的外部编辑会被并入,而不是被覆盖。磁盘上已经无法解析的文档会让写入失败,而不是覆盖提供方读不懂的内容。
 
@@ -47,7 +69,7 @@ OPENAI_API_KEY: sk-…
 
 ## 热重载
 
-外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
+外部编辑在快照**整体替换**后按变更引用逐个发布 `credentials/reference-updated`——磁盘上删掉的条目绝不在内存滞留。在 Chokidar 打开目标之前,提供方会对层级最深的现有祖先路径执行 realpath 解析,再拼回缺失的后缀;文件访问和诊断仍使用配置路径,从而避免 Windows 混用 8.3 别名与 libuv 的长格式事件路径。提供方自己的写入按内容识别,只发布属于该次提交的一个事件。运行期文档不可读或无效时保留最后可用快照并告警;文件不存在即空存储;启动时不可读或无效则明确报错。
 
 <a id="security-boundary"></a>
 

+ 471 - 32
packages/credentials/credentials-local/src/index.ts

@@ -40,12 +40,21 @@ import z from '@deepseek-ai/schemastery'
 import { watch as chokidarWatch } from 'chokidar'
 import { mkdir, readFile, stat } from 'node:fs/promises'
 import { dirname, join, resolve } from 'node:path'
-import { Document, parseDocument, type YAMLError } from 'yaml'
+import { Document, isMap, isScalar, parseDocument, type YAMLError } from 'yaml'
 import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
 import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-home-paths'
 import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
-import { CredentialProvider, credentialRef } from '@deepseek-ai/dsh-credentials'
-import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
+import { CredentialProvider, credentialRef, parseCredentialKey } from '@deepseek-ai/dsh-credentials'
+import type {
+  ApiKeyRecord,
+  CredentialInfo,
+  CredentialKey,
+  CredentialRecord,
+  CredentialRecordEntry,
+  CredentialRecordInfo,
+  CredentialRef,
+  ResolvedCredential,
+} from '@deepseek-ai/dsh-credentials'
 import type { LaunchEnvironmentEntry } from '@deepseek-ai/dsh-launch-environment'
 
 /** Basename of the credentials document inside the harness home. */
@@ -87,6 +96,21 @@ export function resolveSpec(config: Config): ResolvedSpec {
 /** Permission bits outside the owner; a credentials document must have none of them. */
 const GROUP_OTHER_BITS = 0o077
 
+/**
+ * How long a record write waits for the cross-process writer lock. A record
+ * mutation runs its caller's decision while holding the lock, and for the
+ * operation this half exists to serve — an owner refreshing an expired token —
+ * that decision includes a network round trip. The file-work default would
+ * fail every other writer of this document for its duration. A contender's
+ * wait is sized by the longest holder it can meet, and refs and records share
+ * one file and one lock, so every writer of this document — reference writes
+ * and record deletes included — waits this long, not only the mutation that
+ * holds it. Like the retry cadence in `dsh-atomic-write`, this is a
+ * robustness bound of the write protocol rather than a deployment choice: it
+ * is sized by what a provider request costs, which no deployment varies.
+ */
+const DOCUMENT_LOCK_WAIT_MS = 30_000
+
 /**
  * Reject a credentials document other OS users can read, before its contents
  * are read at all. The provider creates and replaces the file at `0600`, but a
@@ -139,19 +163,29 @@ function describeYamlError(error: YAMLError): string {
   return `${error.code}${where}`
 }
 
+/** The document layout this build reads and writes. */
+export const DOCUMENT_VERSION = 1
+
+/** One parsed credentials document: the two key spaces it stores, keyed as written. */
+export interface CredentialsDocument {
+  /** Reference entries, keyed by {@link CredentialRef}. */
+  refs: Map<string, string>
+  /** Stored records, keyed by {@link CredentialKey}. */
+  records: Map<string, CredentialRecord>
+}
+
 /**
- * Parse one credentials document into its entries. The document is a strict
- * mapping of {@link CredentialRef} to non-empty string: a non-mapping root, a
- * key that is not a POSIX identifier, a non-string value, and an empty string
- * are all rejected rather than skipped, because this file holds nothing but
- * credentials and a silently ignored entry reads as "the key I stored has no
- * effect". Duplicate keys surface as parser errors. An empty document is an
- * empty store.
+ * Parse one credentials document. Everything is rejected rather than skipped —
+ * an unversioned root, an unknown top-level key, a key that is not addressable,
+ * a wrong-typed value, an unknown record tag or field — because this file holds
+ * nothing but credentials and a silently ignored entry reads as "the credential
+ * I stored has no effect". Duplicate keys surface as parser errors. An empty
+ * document is an empty store and needs no version.
  * @param text - the document's text.
  * @param filename - absolute path, quoted in errors.
- * @returns the parsed entries, keyed by reference.
+ * @returns the parsed references and records.
  */
-export function parseCredentialsDocument(text: string, filename: string): Map<string, string> {
+export function parseCredentialsDocument(text: string, filename: string): CredentialsDocument {
   // `prettyErrors` is on only for `linePos`; `error.message` is never used,
   // because the parser quotes the offending source line and in this document
   // that line is a secret. Only the code and position leave this function, and
@@ -164,10 +198,77 @@ export function parseCredentialsDocument(text: string, filename: string): Map<st
   }
   const root: unknown = document.toJS() ?? {}
   if (typeof root !== 'object' || root === null || Array.isArray(root)) {
-    throw new TypeError(`credentials-local: ${filename} must be a mapping of credential reference to value`)
+    throw new TypeError(`credentials-local: ${filename} must be a mapping`)
+  }
+  const fields = root as Record<string, unknown>
+  const keys = Object.keys(fields)
+  // An empty (or comment-only) document is the empty store and needs no
+  // version: there is nothing in it a later layout could have meant.
+  if (keys.length === 0) return { refs: new Map(), records: new Map() }
+  if (!('version' in fields)) {
+    throw new Error(
+      `credentials-local: ${filename} uses the pre-release flat layout. Add \`version: ${DOCUMENT_VERSION}\``
+      + ` and nest the existing ${keys.length} ${keys.length === 1 ? 'entry' : 'entries'} under \`refs:\`.`
+      + ' No values need to change.',
+    )
+  }
+  if (fields['version'] !== DOCUMENT_VERSION) {
+    throw new Error(
+      `credentials-local: ${filename} declares version ${JSON.stringify(fields['version'])};`
+      + ` this build reads version ${DOCUMENT_VERSION}`,
+    )
+  }
+  for (const key of keys) {
+    if (key !== 'version' && key !== 'refs' && key !== 'records') {
+      throw new Error(`credentials-local: unknown top-level key "${key}" in ${filename}`)
+    }
+  }
+  return { refs: parseRefs(fields['refs'], filename), records: parseRecords(fields['records'], filename) }
+}
+
+/**
+ * Render the version-1 layout for a pre-release flat document, or `undefined`
+ * for anything else. The flat layout is recognized exactly — a non-empty
+ * top-level mapping of addressable reference names to non-empty string
+ * scalars, with no `version` key and no document directives — and the rewrite
+ * nests the original lines verbatim under `refs:` at two spaces' indent, so
+ * comments, blank lines, and each value's spelling survive byte for byte.
+ * Anything the recognizer declines keeps {@link parseCredentialsDocument}'s
+ * loud rejection: a document this build cannot prove it understands is never
+ * rewritten. Remove with the pre-release stance at the first tagged release.
+ * @param text - the document's text.
+ * @returns the migrated text, or `undefined` when the text is not the recognized flat layout.
+ */
+export function renderFlatLayoutMigration(text: string): string | undefined {
+  const document = parseDocument(text, { prettyErrors: true, uniqueKeys: true })
+  if (document.errors.length > 0) return undefined
+  const flat = document.contents
+  if (!isMap(flat) || flat.items.length === 0) return undefined
+  for (const line of text.split('\n')) {
+    // A directive or document marker would not survive being indented into
+    // the `refs:` block; no shipped writer ever emitted one here.
+    if (/^(%|---|\.\.\.)/.test(line)) return undefined
+  }
+  for (const pair of flat.items) {
+    if (!isScalar(pair.key) || typeof pair.key.value !== 'string' || pair.key.value === 'version') return undefined
+    try {
+      credentialRef(pair.key.value)
+    } catch {
+      // Only credentialRef's rejection of a non-POSIX name lands here; the
+      // flat reader refused such a key too, so this is not the recognized
+      // layout and the loud rejection stands.
+      return undefined
+    }
+    if (!isScalar(pair.value) || typeof pair.value.value !== 'string' || pair.value.value.length === 0) return undefined
   }
+  const body = text.split('\n').map(line => (line.length === 0 ? line : `  ${line}`)).join('\n')
+  return `version: ${DOCUMENT_VERSION}\nrefs:\n${body}${text.endsWith('\n') ? '' : '\n'}`
+}
+
+/** Admit a `refs` section: POSIX-identifier keys over non-empty string values. */
+function parseRefs(section: unknown, filename: string): Map<string, string> {
   const entries = new Map<string, string>()
-  for (const [key, value] of Object.entries(root as Record<string, unknown>)) {
+  for (const [key, value] of Object.entries(asSection(section, 'refs', filename))) {
     // credentialRef throws on anything that is not a POSIX identifier, which
     // is exactly the constraint a stored reference must satisfy to be
     // addressable through the seam.
@@ -185,24 +286,229 @@ export function parseCredentialsDocument(text: string, filename: string): Map<st
   return entries
 }
 
+/** Admit a `records` section: `<scope>/<id>` keys over tagged record mappings. */
+function parseRecords(section: unknown, filename: string): Map<string, CredentialRecord> {
+  const entries = new Map<string, CredentialRecord>()
+  for (const [key, value] of Object.entries(asSection(section, 'records', filename))) {
+    parseCredentialKey(key)
+    entries.set(key, parseRecord(key, value, filename))
+  }
+  return entries
+}
+
+/**
+ * Refuse an api-key record the read path could not admit, before it is
+ * rendered: an empty key, an env name outside the reference grammar, or an
+ * empty env value would persist a document `parseRecord` rejects at the next
+ * boot — a durable-boundary write is validated where it is written.
+ * @param key - the record's credential key, for the failure message.
+ * @param record - the api-key record a mutation returned.
+ */
+function assertStorableApiKey(key: CredentialKey, record: ApiKeyRecord): void {
+  if (record.key !== undefined && record.key.length === 0) {
+    throw new TypeError(`credentials-local: record "${key}" has an empty key; omit the field instead`)
+  }
+  for (const [name, value] of Object.entries(record.env ?? {})) {
+    credentialRef(name)
+    if (value.length === 0) {
+      throw new TypeError(`credentials-local: record "${key}" env "${name}" must be a non-empty string`)
+    }
+  }
+}
+
+/** One section of the document as a plain mapping; absent and null both mean empty. */
+function asSection(section: unknown, name: string, filename: string): Record<string, unknown> {
+  if (section === undefined || section === null) return {}
+  if (typeof section !== 'object' || Array.isArray(section)) {
+    throw new TypeError(`credentials-local: "${name}" in ${filename} must be a mapping`)
+  }
+  return section as Record<string, unknown>
+}
+
+/** Admit one record entry, rejecting an unknown tag or field rather than dropping it. */
+function parseRecord(key: string, value: unknown, filename: string): CredentialRecord {
+  if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+    throw new TypeError(`credentials-local: record "${key}" in ${filename} must be a mapping`)
+  }
+  const fields = value as Record<string, unknown>
+  const kind = fields['kind']
+  if (kind === 'api-key') {
+    assertFields(key, fields, ['kind', 'key', 'env'], filename)
+    const apiKey = fields['key']
+    if (apiKey !== undefined && (typeof apiKey !== 'string' || apiKey.length === 0)) {
+      throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-string or empty key`)
+    }
+    const env = parseRecordEnv(key, fields['env'], filename)
+    return {
+      kind: 'api-key',
+      ...apiKey === undefined ? {} : { key: apiKey },
+      ...env === undefined ? {} : { env },
+    }
+  }
+  if (kind === 'grant') {
+    assertFields(key, fields, ['kind', 'payload'], filename)
+    if (!('payload' in fields)) {
+      throw new Error(`credentials-local: record "${key}" in ${filename} has no payload`)
+    }
+    assertJsonValue(`record "${key}" payload in ${filename}`, fields['payload'], new Set())
+    return { kind: 'grant', payload: fields['payload'] }
+  }
+  if (kind === undefined) throw new Error(`credentials-local: record "${key}" in ${filename} has no kind`)
+  throw new Error(`credentials-local: record "${key}" in ${filename} has unknown kind ${JSON.stringify(kind)}`)
+}
+
+/** Reject a field the tag does not define, so a typo is not silently dropped. */
+function assertFields(key: string, fields: Record<string, unknown>, allowed: string[], filename: string): void {
+  for (const field of Object.keys(fields)) {
+    if (!allowed.includes(field)) {
+      throw new Error(`credentials-local: record "${key}" in ${filename} has unknown field "${field}"`)
+    }
+  }
+}
+
+/** Admit an api-key record's provider environment: POSIX names over non-empty strings. */
+function parseRecordEnv(key: string, env: unknown, filename: string): Record<string, string> | undefined {
+  if (env === undefined) return undefined
+  if (typeof env !== 'object' || env === null || Array.isArray(env)) {
+    throw new TypeError(`credentials-local: record "${key}" in ${filename} has a non-mapping env`)
+  }
+  const parsed: Record<string, string> = {}
+  for (const [name, value] of Object.entries(env as Record<string, unknown>)) {
+    credentialRef(name)
+    if (typeof value !== 'string' || value.length === 0) {
+      throw new TypeError(
+        `credentials-local: record "${key}" env "${name}" in ${filename} must be a non-empty string`,
+      )
+    }
+    parsed[name] = value
+  }
+  return parsed
+}
+
+/**
+ * Reject a payload that cannot survive a JSON round trip, on the way in and on
+ * the way out. The seam promises owners their payload comes back exactly as
+ * written, and both directions can break that: a document may spell `.inf` or
+ * an alias cycle, and an owner may hand over a `Date`, a class instance, or a
+ * `bigint` that this document has no faithful spelling for. Neither the value
+ * nor any nested value is quoted in a diagnostic.
+ * @param where - the subject named in a diagnostic, already free of any value.
+ * @param value - the payload or nested value to admit.
+ * @param seen - objects on the current path, for cycle detection.
+ * @throws TypeError naming `where` when the value cannot round-trip.
+ */
+function assertJsonValue(where: string, value: unknown, seen: Set<object>): void {
+  if (value === null || typeof value === 'string' || typeof value === 'boolean') return
+  if (typeof value === 'number') {
+    if (Number.isFinite(value)) return
+    throw new TypeError(`credentials-local: ${where} holds a non-finite number`)
+  }
+  if (typeof value === 'object') {
+    if (seen.has(value)) throw new TypeError(`credentials-local: ${where} is cyclic`)
+    if (Object.getPrototypeOf(value) === Object.prototype || Array.isArray(value)) {
+      seen.add(value)
+      for (const nested of Object.values(value)) assertJsonValue(where, nested, seen)
+      seen.delete(value)
+      return
+    }
+  }
+  throw new TypeError(`credentials-local: ${where} holds a value JSON cannot represent`)
+}
+
 /**
- * Render the next document text with one reference set or deleted. Editing
- * the parsed document rather than rebuilding it keeps comments and the
- * formatting of every untouched entry; an absent document starts a fresh one.
+ * The comment-preserving mutable tree one edit renders from. Editing the
+ * parsed document rather than rebuilding it keeps comments and the formatting
+ * of every untouched entry; an absent document starts a fresh one.
  * @param text - the current document text, `undefined` while the file is absent.
- * @param ref - the reference to write.
- * @param value - the new value, or `undefined` to delete the key.
- * @returns the text to persist.
+ * @returns the tree to edit, carrying this build's version stamp.
  */
-function renderDocument(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
+function mutableDocument(text: string | undefined): Document {
   // `text` only ever caches content that parsed successfully, so this re-parse
   // for the mutable comment-preserving tree cannot fail.
   const document = text === undefined ? new Document({}) : parseDocument(text)
-  if (value === undefined) document.deleteIn([ref])
-  else document.setIn([ref], value)
+  // Stamped on every edit so a document this provider creates is readable by
+  // the same parser that admitted the one it edits; an existing stamp is
+  // rewritten to the identical value.
+  document.setIn(['version'], DOCUMENT_VERSION)
+  return document
+}
+
+/**
+ * Render the next document text with one reference set or deleted.
+ * @param text - the current document text, `undefined` while the file is absent.
+ * @param ref - the reference to write.
+ * @param value - the new value, or `undefined` to delete the key.
+ * @returns the text to persist.
+ */
+function renderRef(text: string | undefined, ref: CredentialRef, value: string | undefined): string {
+  const document = mutableDocument(text)
+  if (value === undefined) deleteSectionEntry(document, 'refs', ref)
+  else document.setIn(['refs', ref], value)
+  return document.toString()
+}
+
+/**
+ * Render the next document text with one record written or deleted. The record
+ * node is replaced wholesale rather than edited field by field: records are
+ * machine-written, so there is no hand formatting inside one to preserve.
+ * @param text - the current document text, `undefined` while the file is absent.
+ * @param key - the record to write.
+ * @param record - the new record, or `undefined` to delete it.
+ * @returns the text to persist.
+ */
+function renderRecord(text: string | undefined, key: CredentialKey, record: CredentialRecord | undefined): string {
+  const document = mutableDocument(text)
+  if (record === undefined) deleteSectionEntry(document, 'records', key)
+  else document.setIn(['records', key], record)
   return document.toString()
 }
 
+/**
+ * Remove one entry from a section, taking its annotation with it. A comment
+ * block written above a section's first entry annotates that entry, but the
+ * parser attaches it to the section's map rather than to the pair — leaving it
+ * behind would move it onto whichever entry became first, which reads as an
+ * annotation of a credential nobody wrote it for.
+ * @param document - the mutable tree being edited.
+ * @param section - the section holding the entry.
+ * @param key - the entry to remove.
+ */
+function deleteSectionEntry(document: Document, section: 'refs' | 'records', key: string): void {
+  const map: unknown = document.get(section, true)
+  /* v8 ignore next -- both callers render a delete only for an entry they just
+     found in the parsed snapshot, so the section it lives in is always a map;
+     the guard is what narrows `get`'s `unknown`. */
+  if (isMap(map)) {
+    const first = map.items[0]
+    /* v8 ignore next -- a map that holds the entry has a first item, and the
+       parser admits only scalar keys, so only the identity test can be false. */
+    if (first !== undefined && isScalar(first.key) && first.key.value === key) {
+      map.commentBefore = null
+    }
+  }
+  document.deleteIn([section, key])
+}
+
+/**
+ * Structural equality over two admitted JSON values. Records reach this after
+ * {@link assertJsonValue}, so the walk meets only JSON shapes; key order is
+ * ignored because an external editor may reorder a record's fields without
+ * changing what it stores.
+ * @param left - one value.
+ * @param right - the other value.
+ * @returns whether the two carry the same JSON content.
+ */
+function sameJsonValue(left: unknown, right: unknown): boolean {
+  if (left === right) return true
+  if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) return false
+  if (Array.isArray(left) !== Array.isArray(right)) return false
+  const leftKeys = Object.keys(left)
+  const rightKeys = Object.keys(right)
+  if (leftKeys.length !== rightKeys.length) return false
+  return leftKeys.every(key => key in right
+    && sameJsonValue((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
+}
+
 /** File-backed credentials provider (`$DSH_HOME/.credentials.yaml`). */
 export class LocalCredentialProvider extends CredentialProvider {
   /* jscpd:ignore-start -- deliberate config-surface and lifecycle symmetry with
@@ -222,8 +528,10 @@ export class LocalCredentialProvider extends CredentialProvider {
    * which is also the self-write suppression.
    */
   private text: string | undefined
-  /** Parsed document snapshot; replaced wholesale on every reload. */
+  /** Parsed reference snapshot; replaced wholesale on every reload. */
   private values = new Map<string, string>()
+  /** Parsed record snapshot; replaced wholesale on every reload. */
+  private records = new Map<string, CredentialRecord>()
   /**
    * Single exclusive operation chain: watcher reloads and line edits run one
    * at a time in queue order (settled tail), so an edit can never render from
@@ -341,6 +649,82 @@ export class LocalCredentialProvider extends CredentialProvider {
     await this.write(ref, undefined)
   }
 
+  override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
+    return Promise.resolve(this.records.get(key))
+  }
+
+  override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
+    const stored = this.records.get(key)
+    // Presence is the whole fact here: no layer ranks above this document for
+    // a record, so nothing can shadow one, and an api-key record carrying
+    // neither a key nor environment values is a deliberate statement rather
+    // than a blank.
+    if (stored === undefined) return Promise.resolve({ configured: false, writable: true })
+    return Promise.resolve({ configured: true, kind: stored.kind, writable: true })
+  }
+
+  override listRecords(): Promise<readonly CredentialRecordEntry[]> {
+    return Promise.resolve([...this.records].map(([key, record]) => ({
+      // The parser has already proven every stored key addressable.
+      key: parseCredentialKey(key),
+      kind: record.kind,
+    })))
+  }
+
+  override async modifyRecord(
+    key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined> {
+    if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot modify "${key}"`)
+    return this.enqueue(async () => {
+      if (this.isClosed()) {
+        throw new Error(`credentials-local was disposed before the queued "${key}" modify ran`)
+      }
+      await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
+      return withFileLock(this.spec.filename, async () => {
+        // Read-modify-write: `mutate` must decide against the record as it
+        // stands now, not as this process last saw it — another process may
+        // have rotated it since.
+        await this.reconcileFromDisk()
+        const current = this.records.get(key)
+        const next = await mutate(current)
+        if (next === undefined) return current
+        // Admitted before it is rendered: what the read path would refuse is
+        // refused here first, so a caller can never persist a document the
+        // next boot rejects, and a value refused here has not been stored.
+        if (next.kind === 'grant') assertJsonValue(`record "${key}" payload`, next.payload, new Set())
+        else assertStorableApiKey(key, next)
+        const nextText = renderRecord(this.text, key, next)
+        // 0600: a document holding secrets is never world-readable.
+        await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
+        this.text = nextText
+        this.records.set(key, next)
+        // After the commit, on the same terms as a reference write.
+        this.notifyRecordUpdated(key)
+        return next
+      }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
+    })
+  }
+
+  override async deleteRecord(key: CredentialKey): Promise<void> {
+    if (this.isClosed()) throw new Error(`credentials-local is disposed: cannot delete "${key}"`)
+    await this.enqueue(async () => {
+      if (this.isClosed()) {
+        throw new Error(`credentials-local was disposed before the queued "${key}" delete ran`)
+      }
+      await mkdir(dirname(this.spec.filename), { recursive: true, mode: 0o700 })
+      await withFileLock(this.spec.filename, async () => {
+        await this.reconcileFromDisk()
+        if (!this.records.has(key)) return
+        const nextText = renderRecord(this.text, key, undefined)
+        await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
+        this.text = nextText
+        this.records.delete(key)
+        this.notifyRecordUpdated(key)
+      }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
+    })
+  }
+
   /* jscpd:ignore-start -- the operation-chain and reload lifecycle is the same
      reviewed contract as settings-file, deliberately mirrored (prefer symmetry
      for parallel values); the two providers own different documents and
@@ -389,7 +773,7 @@ export class LocalCredentialProvider extends CredentialProvider {
         await this.reconcileFromDisk()
         const existing = this.values.get(ref)
         if (value === undefined && existing === undefined) return
-        const nextText = renderDocument(this.text, ref, value)
+        const nextText = renderRef(this.text, ref, value)
         // 0600: a document holding secrets is never world-readable.
         await writeFileAtomic(this.spec.filename, nextText, { mode: 0o600, dirMode: 0o700 })
         this.text = nextText
@@ -398,7 +782,7 @@ export class LocalCredentialProvider extends CredentialProvider {
         // After the commit: a broken observer must never make the durable
         // write look failed (an INVARIANT failure still rethrows).
         this.notifyUpdated(ref)
-      })
+      }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
     })
   }
 
@@ -419,7 +803,10 @@ export class LocalCredentialProvider extends CredentialProvider {
   /**
    * Boot read: an absent file is an empty store; an invalid one fails the
    * plugin's activation, because a credentials document that exists but
-   * cannot be trusted must never be treated as "no credentials stored".
+   * cannot be trusted must never be treated as "no credentials stored". The
+   * one exception is the recognized pre-release flat layout, which is
+   * upgraded in place first — a key stored by an earlier build must survive
+   * the layout change without a hand edit.
    */
   private async loadInitial(): Promise<void> {
     await assertOwnerOnly(this.spec.filename)
@@ -430,10 +817,44 @@ export class LocalCredentialProvider extends CredentialProvider {
       if (!isENOENT(error)) throw error
       return
     }
-    this.values = parseCredentialsDocument(text, this.spec.filename)
+    if (renderFlatLayoutMigration(text) !== undefined) text = await this.migrateFlatDocument()
+    const document = parseCredentialsDocument(text, this.spec.filename)
+    this.values = document.refs
+    this.records = document.records
     this.text = text
   }
 
+  /**
+   * One-shot upgrade of the recognized pre-release flat layout, before the
+   * watcher exists. The rewrite runs under the document's writer lock and
+   * re-reads first — a concurrent boot may have migrated already — and
+   * whatever the re-read finds that is not the flat layout is returned
+   * untouched for the ordinary parse. Values are carried verbatim; only the
+   * enclosing layout changes. Remove with the pre-release stance at the
+   * first tagged release.
+   * @returns the document text this boot should parse.
+   */
+  private async migrateFlatDocument(): Promise<string> {
+    return withFileLock(this.spec.filename, async () => {
+      const current = await readFile(this.spec.filename, 'utf8')
+      const migrated = renderFlatLayoutMigration(current)
+      /* v8 ignore next 2 -- the losing side of the cross-process migration race:
+         another boot rewrote the document between the unlocked recognize and
+         this lock. That interleaving cannot be scheduled deterministically
+         through a whole boot (migration.spec drives it best-effort); the
+         decision itself is the recognizer's covered versioned-document decline. */
+      if (migrated === undefined) return current
+      // 0600: a document holding secrets is never world-readable.
+      await writeFileAtomic(this.spec.filename, migrated, { mode: 0o600, dirMode: 0o700 })
+      this.ctx.logger.info(
+        'credentials-local: migrated %s to the version %d layout; values are unchanged',
+        this.spec.filename,
+        DOCUMENT_VERSION,
+      )
+      return migrated
+    }, { waitMs: DOCUMENT_LOCK_WAIT_MS })
+  }
+
   /* jscpd:ignore-start -- same deliberate mirror of settings-file's reload and
      reconcile policy: warn-and-keep on a reload, throw on a write, invariant
      failures propagate. */
@@ -474,11 +895,16 @@ export class LocalCredentialProvider extends CredentialProvider {
       text = undefined
     }
     if (text === this.text || this.isClosed()) return
-    const next = text === undefined ? new Map<string, string>() : parseCredentialsDocument(text, this.spec.filename)
-    const changed = this.changedRefs(this.values, next)
+    const next = text === undefined
+      ? { refs: new Map<string, string>(), records: new Map<string, CredentialRecord>() }
+      : parseCredentialsDocument(text, this.spec.filename)
+    const changedRefs = this.changedRefs(this.values, next.refs)
+    const changedRecords = this.changedRecords(this.records, next.records)
     this.text = text
-    this.values = next
-    for (const ref of changed) this.notifyUpdated(ref)
+    this.values = next.refs
+    this.records = next.records
+    for (const ref of changedRefs) this.notifyUpdated(ref)
+    for (const key of changedRecords) this.notifyRecordUpdated(key)
   }
   /* jscpd:ignore-end */
 
@@ -491,6 +917,19 @@ export class LocalCredentialProvider extends CredentialProvider {
     }
     return changed
   }
+
+  /** Records whose stored value changed; the parser has already proven every key addressable. */
+  private changedRecords(
+    prev: Map<string, CredentialRecord>,
+    next: Map<string, CredentialRecord>,
+  ): CredentialKey[] {
+    const changed: CredentialKey[] = []
+    for (const key of new Set([...prev.keys(), ...next.keys()])) {
+      if (sameJsonValue(prev.get(key), next.get(key))) continue
+      changed.push(parseCredentialKey(key))
+    }
+    return changed
+  }
 }
 
 export default LocalCredentialProvider

+ 1 - 1
packages/credentials/credentials-local/src/invariant.ts

@@ -16,7 +16,7 @@ export const inject = ['invariants']
 
 /**
  * No runtime invariant: the Service Definition companion (`dsh-credentials/invariant`) owns the
- * `credentials/updated` lifecycle contract; this provider's file/environment layering is
+ * `credentials/reference-updated` lifecycle contract; this provider's file/environment layering is
  * asynchronous I/O pinned by its unit suite.
  */
 const install: InvariantInstaller = () => {}

+ 31 - 1
packages/credentials/credentials-local/tests/drain.spec.ts

@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { mkdtemp, rm } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
 import { LocalCredentialProvider } from '../src/index.ts'
 
 // The atomic write is the gated asynchronous hold point inside a queued
@@ -29,6 +29,8 @@ async function setGate(next: Promise<void>): Promise<void> {
 
 const KEY = credentialRef('DSH_CRED_DRAIN_A')
 const OTHER = credentialRef('DSH_CRED_DRAIN_B')
+const RECORD = credentialKey('llm-drain', 'alpha')
+const OTHER_RECORD = credentialKey('llm-drain', 'beta')
 
 const cleanups: Array<() => Promise<void>> = []
 
@@ -68,4 +70,32 @@ describe('write-drain teardown', () => {
     expect(await service.resolve(KEY)).toEqual({ value: 'one', source: 'file' })
     expect(await service.resolve(OTHER)).toBeUndefined()
   })
+
+  it('fails a queued record write after disposal on the same terms', async () => {
+    const dir = await mkdtemp(join(tmpdir(), 'dsh-credentials-drain-record-'))
+    cleanups.push(() => rm(dir, { recursive: true, force: true }))
+    const ctx = new Context()
+    const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+    await fiber
+    const service = ctx.credentials
+
+    let release!: () => void
+    await setGate(new Promise<void>((resolveGate) => {
+      release = resolveGate
+    }))
+    const first = service.modifyRecord(RECORD, () => Promise.resolve({ kind: 'grant', payload: { v: 1 } }))
+    await new Promise(resolvePause => setTimeout(resolvePause, 5))
+    const queuedModify = expect(service.modifyRecord(OTHER_RECORD, () => Promise.resolve({ kind: 'api-key' })))
+      .rejects.toThrow(/disposed before the queued/)
+    const queuedDelete = expect(service.deleteRecord(OTHER_RECORD)).rejects.toThrow(/disposed before the queued/)
+    const disposal = fiber.dispose()
+    await new Promise(resolvePause => setTimeout(resolvePause, 10))
+    release()
+    await disposal
+
+    await expect(first).resolves.toEqual({ kind: 'grant', payload: { v: 1 } })
+    await queuedModify
+    await queuedDelete
+    expect(await service.readRecord(OTHER_RECORD)).toBeUndefined()
+  })
 })

+ 64 - 25
packages/credentials/credentials-local/tests/local.spec.ts

@@ -41,7 +41,7 @@ async function boot(config: ConstructorParameters<typeof LocalCredentialProvider
 
 function updates(ctx: Context): CredentialRef[] {
   const seen: CredentialRef[] = []
-  ctx.on('credentials/updated', (ref) => {
+  ctx.on('credentials/reference-updated', (ref) => {
     seen.push(ref)
   })
   return seen
@@ -70,7 +70,7 @@ describe('layering and reads', () => {
   it('serves file entries alongside comments and quoted values', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, '# notes\nDSH_CRED_TEST: plain\nDSH_CRED_OTHER: "with space"\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  # notes\n  DSH_CRED_TEST: plain\n  DSH_CRED_OTHER: "with space"\n')
     const ctx = await boot({ path, watch: false })
     expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'plain', source: 'file' })
     expect(await ctx.credentials.resolve(OTHER)).toEqual({ value: 'with space', source: 'file' })
@@ -80,7 +80,7 @@ describe('layering and reads', () => {
   it('lets a non-empty process environment win read-only over the file', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: from-file\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: from-file\n')
     const ctx = await boot({ path, watch: false })
     vi.stubEnv('DSH_CRED_TEST', 'from-env')
     expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'from-env', source: 'env' })
@@ -90,7 +90,7 @@ describe('layering and reads', () => {
   it('treats an empty environment value as absent, falling through to the file', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: stored\n')
     const ctx = await boot({ path, watch: false })
     vi.stubEnv('DSH_CRED_TEST', '')
     expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
@@ -124,7 +124,7 @@ describe('layer ladder', () => {
   it('lets the stored value beat the user .env, so a UI write takes effect immediately', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: stored\n')
     const ctx = await bootLayered(path, [
       { source: 'process', values: {} },
       { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'older-user-env' } },
@@ -163,7 +163,7 @@ describe('layer ladder', () => {
     expect(await bare.credentials.resolve(KEY)).toEqual({ value: 'from-project', source: 'project-env' })
     expect(await bare.credentials.describe(KEY)).toEqual({ configured: true, source: 'project-env', writable: true })
 
-    await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: stored\n')
     const stored = await bootLayered(path, layers)
     expect(await stored.credentials.resolve(KEY)).toEqual({ value: 'stored', source: 'file' })
   })
@@ -171,7 +171,7 @@ describe('layer ladder', () => {
   it.skipIf(process.platform === 'win32')('refuses a document other OS users can read', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeFile(path, 'DSH_CRED_TEST: leaked\n', { mode: 0o644 })
+    await writeFile(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: leaked\n', { mode: 0o644 })
     const ctx = new Context()
     // Before the contents are read at all: serving secrets out of a
     // world-readable file would make the 0600 the provider writes meaningless.
@@ -212,7 +212,7 @@ describe('layer ladder', () => {
   it('lets only the inherited environment shadow the store, read-only', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: stored\n')
     const ctx = await bootLayered(path, [
       { source: 'process', values: { DSH_CRED_TEST: 'from-shell' } },
       { source: 'user-env', path: '/home/.dsh/.env', values: { DSH_CRED_TEST: 'from-user-env' } },
@@ -230,10 +230,48 @@ describe('document validation', () => {
   it.each([
     ['a non-mapping root', 'just a string\n', /must be a mapping/],
     ['a sequence root', '- DSH_CRED_TEST\n', /must be a mapping/],
-    ['a key that is not a POSIX identifier', 'not-a-ref: value\n', /credential ref/],
-    ['a non-string value', 'DSH_CRED_TEST: 123\n', /must be a string/],
-    ['an empty value', 'DSH_CRED_TEST: ""\n', /is empty/],
-    ['duplicate keys', 'DSH_CRED_TEST: one\nDSH_CRED_TEST: two\n', /invalid document/],
+    // A flat document the boot migration cannot prove it understands is
+    // refused by name rather than read as an empty store or rewritten: a
+    // silently ignored document would surface as an authentication failure on
+    // the first request instead of at load. (The recognized all-string flat
+    // layout upgrades in place instead — migration.spec owns that path.)
+    ['the flat layout with a non-string value', 'DSH_CRED_TEST: [nope]\n', /nest the existing 1 entry under/],
+    ['the flat layout with several entries and a non-string value', 'DSH_CRED_TEST: a\nDSH_CRED_OTHER: [b]\n',
+      /nest the existing 2 entries under/],
+    ['the flat layout with an empty value', 'DSH_CRED_TEST: ""\n', /pre-release flat layout/],
+    ['the flat layout with an unaddressable key', 'not-a-ref: value\n', /pre-release flat layout/],
+    ['the flat layout with a non-string key', '1: a\n', /pre-release flat layout/],
+    ['the flat layout under document directives', '%YAML 1.2\n---\nDSH_CRED_TEST: a\n',
+      /pre-release flat layout/],
+    ['a future version', 'version: 2\nrefs: {}\n', /this build reads version 1/],
+    ['an unknown top-level key', 'version: 1\nsecrets: {}\n', /unknown top-level key "secrets"/],
+    ['a non-mapping refs section', 'version: 1\nrefs: nope\n', /"refs" .* must be a mapping/],
+    ['a key that is not a POSIX identifier', 'version: 1\nrefs:\n  not-a-ref: value\n', /credential ref/],
+    ['a non-string value', 'version: 1\nrefs:\n  DSH_CRED_TEST: 123\n', /must be a string/],
+    ['an empty value', 'version: 1\nrefs:\n  DSH_CRED_TEST: ""\n', /is empty/],
+    ['a record key that is not scoped', 'version: 1\nrecords:\n  codex:\n    kind: grant\n    payload: 1\n',
+      /must be "<scope>\/<id>"/],
+    ['a record that is not a mapping', 'version: 1\nrecords:\n  llm-pi-ai/codex: token\n',
+      /record "llm-pi-ai\/codex" .* must be a mapping/],
+    ['a record with no kind', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    payload: 1\n', /has no kind/],
+    ['a record with an unknown kind', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    kind: token\n',
+      /unknown kind "token"/],
+    ['a record with an unknown field', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    kind: grant\n'
+      + '    payload: 1\n    extra: 2\n', /unknown field "extra"/],
+    ['a grant with no payload', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    kind: grant\n', /has no payload/],
+    // YAML spells values JSON has none for. The seam promises an owner its
+    // payload comes back exactly as written, which a lossy round trip breaks.
+    ['a non-finite payload number', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    kind: grant\n'
+      + '    payload:\n      ratio: .inf\n', /non-finite number/],
+    ['a cyclic payload', 'version: 1\nrecords:\n  llm-pi-ai/codex:\n    kind: grant\n'
+      + '    payload: &loop\n      self: *loop\n', /is cyclic/],
+    ['an api-key record with an empty key', 'version: 1\nrecords:\n  llm-pi-ai/acme:\n    kind: api-key\n'
+      + '    key: ""\n', /non-string or empty key/],
+    ['an api-key record env that is not a mapping', 'version: 1\nrecords:\n  llm-pi-ai/acme:\n    kind: api-key\n'
+      + '    env: nope\n', /non-mapping env/],
+    ['an api-key record env value that is empty', 'version: 1\nrecords:\n  llm-pi-ai/acme:\n    kind: api-key\n'
+      + '    env:\n      AWS_PROFILE: ""\n', /env "AWS_PROFILE" .* must be a non-empty string/],
+    ['duplicate keys', 'version: 1\nrefs:\n  DSH_CRED_TEST: one\n  DSH_CRED_TEST: two\n', /invalid document/],
     ['malformed yaml', 'DSH_CRED_TEST: "unterminated\n', /invalid document/],
   ])('fails boot on %s', async (_case, text, message) => {
     const dir = await tempDir()
@@ -280,7 +318,7 @@ describe('document writes', () => {
     const ctx = await boot({ path, watch: false })
     const seen = updates(ctx)
     await ctx.credentials.set(KEY, 'sk-fresh')
-    expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: sk-fresh\n')
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n  DSH_CRED_TEST: sk-fresh\n')
     if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
     expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'sk-fresh', source: 'file' })
     expect(seen).toEqual([KEY])
@@ -289,11 +327,12 @@ describe('document writes', () => {
   it('patches one entry, preserving comments and every untouched entry', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: old\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  # deployment notes\n  DSH_CRED_OTHER: keep\n\n  # the one under edit\n  DSH_CRED_TEST: old\n')
     const ctx = await boot({ path, watch: false })
     await ctx.credentials.set(KEY, 'new value!')
     expect(await readFile(path, 'utf8')).toBe(
-      '# deployment notes\nDSH_CRED_OTHER: keep\n\n# the one under edit\nDSH_CRED_TEST: new value!\n',
+      'version: 1\nrefs:\n  # deployment notes\n  DSH_CRED_OTHER: keep\n\n'
+      + '  # the one under edit\n  DSH_CRED_TEST: new value!\n',
     )
   })
 
@@ -317,11 +356,11 @@ describe('document writes', () => {
     // Comments above an entry are that entry's annotation and go with it when
     // it is removed — including anything above the document's first entry.
     // Every other entry keeps its own comments.
-    await writeCredentials(path, '# about the doomed one\nDSH_CRED_TEST: gone\n# about the survivor\nDSH_CRED_OTHER: stays\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  # about the doomed one\n  DSH_CRED_TEST: gone\n  # about the survivor\n  DSH_CRED_OTHER: stays\n')
     const ctx = await boot({ path, watch: false })
     const seen = updates(ctx)
     await ctx.credentials.unset(KEY)
-    expect(await readFile(path, 'utf8')).toBe('# about the survivor\nDSH_CRED_OTHER: stays\n')
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n  # about the survivor\n  DSH_CRED_OTHER: stays\n')
     await ctx.credentials.unset(KEY)
     expect(seen).toEqual([KEY])
   })
@@ -329,7 +368,7 @@ describe('document writes', () => {
   it('rejects empty values and writes the environment would shadow', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: stored\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: stored\n')
     const ctx = await boot({ path, watch: false })
 
     await expect(ctx.credentials.set(KEY, '')).rejects.toThrow(/empty value/)
@@ -342,10 +381,10 @@ describe('document writes', () => {
   it('leaves an empty mapping after unsetting the only entry', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_TEST: only\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: only\n')
     const ctx = await boot({ path, watch: false })
     await ctx.credentials.unset(KEY)
-    expect(await readFile(path, 'utf8')).toBe('{}\n')
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs: {}\n')
     // The emptied document still reloads as an empty store, not a parse error.
     const reread = await boot({ path, watch: false })
     expect(await reread.credentials.resolve(KEY)).toBeUndefined()
@@ -369,7 +408,7 @@ describe('document writes', () => {
     const good = ctx.credentials.set(OTHER, 'lands')
     await bad
     await good
-    expect(await readFile(path, 'utf8')).toBe('DSH_CRED_OTHER: lands\n')
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n  DSH_CRED_OTHER: lands\n')
   })
 
   it('serializes concurrent writes so both land in the one document', async () => {
@@ -380,7 +419,7 @@ describe('document writes', () => {
       ctx.credentials.set(KEY, 'one'),
       ctx.credentials.set(OTHER, 'two'),
     ])
-    expect(await readFile(path, 'utf8')).toBe('DSH_CRED_TEST: one\nDSH_CRED_OTHER: two\n')
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrefs:\n  DSH_CRED_TEST: one\n  DSH_CRED_OTHER: two\n')
   })
 
   it('refuses writes after disposal', async () => {
@@ -401,17 +440,17 @@ describe('real hot reload', () => {
     const path = join(dir, '.credentials.yaml')
     // Watching starts on an existing document: creation racing watcher setup
     // is a chokidar readiness gap, not the reload contract under test.
-    await writeCredentials(path, 'DSH_CRED_TEST: boot\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: boot\n')
     const ctx = await boot({ path, debounceMs: 10 })
     const seen = updates(ctx)
 
-    await writeCredentials(path, 'DSH_CRED_TEST: live\nDSH_CRED_OTHER: extra\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: live\n  DSH_CRED_OTHER: extra\n')
     await vi.waitFor(async () => {
       expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'live', source: 'file' })
     })
 
     // Wholesale replacement: an entry deleted on disk never lingers in memory.
-    await writeCredentials(path, 'DSH_CRED_TEST: live\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_TEST: live\n')
     await vi.waitFor(async () => {
       expect(await ctx.credentials.resolve(OTHER)).toBeUndefined()
     })

+ 140 - 0
packages/credentials/credentials-local/tests/migration.spec.ts

@@ -0,0 +1,140 @@
+// One-shot boot upgrade of the pre-release flat layout: a key stored by an
+// earlier build must survive the versioned-document change without a hand
+// edit, byte for byte, while everything the recognizer cannot prove flat
+// keeps the loud rejection local.spec exercises.
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
+import { LocalCredentialProvider, renderFlatLayoutMigration } from '../src/index.ts'
+
+/** Credential documents are seeded owner-only, exactly as the provider creates them. */
+function writeCredentials(file: string, text: string): Promise<void> {
+  return writeFile(file, text, { mode: 0o600 })
+}
+
+const cleanups: Array<() => Promise<void>> = []
+
+afterEach(async () => {
+  while (cleanups.length > 0) await cleanups.pop()!()
+})
+
+async function tempDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-migration-'))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+async function boot(config: ConstructorParameters<typeof LocalCredentialProvider>[1]): Promise<Context> {
+  const ctx = new Context()
+  const fiber = ctx.plugin(LocalCredentialProvider, config)
+  cleanups.push(async () => { await fiber.dispose() })
+  await fiber
+  return ctx
+}
+
+// Every spelling an earlier build accepted: plain, quoted, block-scalar, a
+// comment header, an interior blank line, and a key that happens to spell a
+// section name of the versioned layout.
+const FLAT = [
+  '# keys stored before the versioned layout',
+  'DSH_CRED_TEST: stored',
+  '',
+  '# annotates the quoted entry',
+  "DSH_CRED_OTHER: 'quoted value'",
+  'DSH_CRED_BLOCK: |',
+  '  first line',
+  '  second line',
+  'records: tricky',
+].join('\n') + '\n'
+
+const MIGRATED = [
+  'version: 1',
+  'refs:',
+  '  # keys stored before the versioned layout',
+  '  DSH_CRED_TEST: stored',
+  '',
+  '  # annotates the quoted entry',
+  "  DSH_CRED_OTHER: 'quoted value'",
+  '  DSH_CRED_BLOCK: |',
+  '    first line',
+  '    second line',
+  '  records: tricky',
+].join('\n') + '\n'
+
+describe('flat-layout boot migration', () => {
+  it('upgrades the flat document in place, byte for byte, and serves its keys', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    await writeCredentials(path, FLAT)
+    const ctx = await boot({ path, watch: false })
+    expect(await readFile(path, 'utf8')).toBe(MIGRATED)
+    if (process.platform !== 'win32') expect((await stat(path)).mode & 0o777).toBe(0o600)
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'stored', source: 'file' })
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_OTHER')))
+      .toEqual({ value: 'quoted value', source: 'file' })
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_BLOCK')))
+      .toEqual({ value: 'first line\nsecond line\n', source: 'file' })
+    expect(await ctx.credentials.resolve(credentialRef('records'))).toEqual({ value: 'tricky', source: 'file' })
+  })
+
+  it('a second boot reads the migrated document without touching it', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    await writeCredentials(path, FLAT)
+    await boot({ path, watch: false })
+    const ctx = await boot({ path, watch: false })
+    expect(await readFile(path, 'utf8')).toBe(MIGRATED)
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'stored', source: 'file' })
+  })
+
+  it('yields to a concurrent migrator under the writer lock', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    await writeCredentials(path, FLAT)
+    const winner = 'version: 1\nrefs:\n  DSH_CRED_TEST: winner\n'
+    let release!: () => void
+    const held = new Promise<void>((resolve) => { release = resolve })
+    let acquired!: () => void
+    const holding = new Promise<void>((resolve) => { acquired = resolve })
+    const holder = withFileLock(path, async () => {
+      acquired()
+      await held
+    })
+    await holding
+    // The boot sees the flat text, then waits for the lock; the "other
+    // process" completes the migration in the meantime.
+    const booting = boot({ path, watch: false })
+    await writeCredentials(path, winner)
+    release()
+    await holder
+    const ctx = await booting
+    expect(await readFile(path, 'utf8')).toBe(winner)
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toEqual({ value: 'winner', source: 'file' })
+  })
+
+  it('leaves an empty flow mapping alone', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    await writeCredentials(path, '{}\n')
+    const ctx = await boot({ path, watch: false })
+    expect(await readFile(path, 'utf8')).toBe('{}\n')
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toBeUndefined()
+  })
+
+  it('leaves a comment-only document alone', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    await writeCredentials(path, '# nothing stored yet\n')
+    const ctx = await boot({ path, watch: false })
+    expect(await readFile(path, 'utf8')).toBe('# nothing stored yet\n')
+    expect(await ctx.credentials.resolve(credentialRef('DSH_CRED_TEST'))).toBeUndefined()
+  })
+
+  it('renders a final newline for a document that lacks one', () => {
+    expect(renderFlatLayoutMigration('DSH_CRED_TEST: bare')).toBe('version: 1\nrefs:\n  DSH_CRED_TEST: bare\n')
+  })
+})

+ 349 - 0
packages/credentials/credentials-local/tests/records.spec.ts

@@ -0,0 +1,349 @@
+// The record half of the seam: the store keeps an owner's payload verbatim,
+// presence rather than content answers "configured", and every write goes
+// through one serialized read-modify-write so a rotating credential cannot be
+// lost between processes.
+import { afterEach, describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { credentialKey, credentialKeyScope, credentialRef, parseCredentialKey } from '@deepseek-ai/dsh-credentials'
+import type { CredentialKey, CredentialRecord } from '@deepseek-ai/dsh-credentials'
+import { LocalCredentialProvider } from '../src/index.ts'
+
+/** Credential documents are seeded owner-only, exactly as the provider creates them. */
+function writeCredentials(file: string, text: string): Promise<void> {
+  return writeFile(file, text, { mode: 0o600 })
+}
+
+const CODEX = credentialKey('llm-pi-ai', 'openai-codex')
+const BEDROCK = credentialKey('llm-pi-ai', 'amazon-bedrock')
+const OTHER_OWNER = credentialKey('llm-kimi', 'openai-codex')
+
+const cleanups: Array<() => Promise<void>> = []
+
+afterEach(async () => {
+  while (cleanups.length > 0) await cleanups.pop()!()
+})
+
+async function tempDir(): Promise<string> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-cred-records-'))
+  cleanups.push(() => rm(dir, { recursive: true, force: true }))
+  return dir
+}
+
+async function boot(config: ConstructorParameters<typeof LocalCredentialProvider>[1]): Promise<Context> {
+  const ctx = new Context()
+  const fiber = ctx.plugin(LocalCredentialProvider, config)
+  cleanups.push(async () => { await fiber.dispose() })
+  await fiber
+  return ctx
+}
+
+/** Store one record outright; the seam offers only the read-modify-write path. */
+function put(ctx: Context, key: CredentialKey, record: CredentialRecord): Promise<CredentialRecord | undefined> {
+  return ctx.credentials.modifyRecord(key, () => Promise.resolve(record))
+}
+
+function recordUpdates(ctx: Context): CredentialKey[] {
+  const seen: CredentialKey[] = []
+  ctx.on('credentials/record-updated', (key) => { seen.push(key) })
+  return seen
+}
+
+describe('credential keys', () => {
+  it('rejects a segment that is not a lowercase hyphenated identifier', () => {
+    expect(() => credentialKey('llm-pi-ai', 'OpenAI')).toThrow(/credential key segment/)
+    expect(() => credentialKey('', 'codex')).toThrow(/credential key segment/)
+  })
+
+  it('stays disjoint from the reference grammar', () => {
+    // The `/` is what makes the two key spaces incapable of colliding, so a
+    // record address can never be mistaken for an environment-variable name.
+    expect(() => credentialRef(CODEX)).toThrow(/credential ref/)
+  })
+
+  it('reads back the owning plugin, which is what makes an orphan recognizable', () => {
+    expect(credentialKeyScope(CODEX)).toBe('llm-pi-ai')
+    expect(credentialKeyScope(OTHER_OWNER)).toBe('llm-kimi')
+  })
+
+  it('admits a stored key and refuses one that is not two segments', () => {
+    expect(parseCredentialKey('llm-pi-ai/openai-codex')).toBe(CODEX)
+    expect(() => parseCredentialKey('openai-codex')).toThrow(/must be "<scope>\/<id>"/)
+    expect(() => parseCredentialKey('a/b/c')).toThrow(/must be "<scope>\/<id>"/)
+  })
+})
+
+describe('record storage', () => {
+  it('returns a grant payload exactly as its owner wrote it', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    // Fields the seam has never heard of ride along: an owner's SDK gains them
+    // between releases, and a whitelist here would silently eat the new ones.
+    const payload = { type: 'oauth', access: 'at', refresh: 'rt', expires: 1786000000000, accountId: 'acct_1' }
+    await put(ctx, CODEX, { kind: 'grant', payload })
+
+    expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload })
+    const reread = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    expect(await reread.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload })
+  })
+
+  it('treats a record carrying no key and no environment as configured', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    // The owner confirmed this route authenticates from its own ambient
+    // discovery. That is a stored decision, not a blank — the opposite reading
+    // of the empty-value rule the reference half follows.
+    await put(ctx, BEDROCK, { kind: 'api-key' })
+
+    expect(await ctx.credentials.describeRecord(BEDROCK)).toEqual({ configured: true, kind: 'api-key', writable: true })
+  })
+
+  it('describes an absent record as unconfigured but writable', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+
+    expect(await ctx.credentials.describeRecord(CODEX)).toEqual({ configured: false, writable: true })
+    expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
+  })
+
+  it('stores provider environment values beside or instead of a key', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    await put(ctx, BEDROCK, { kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
+
+    expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
+  })
+
+  it('keeps references and records in one document without either disturbing the other', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+    await ctx.credentials.set(credentialRef('DSH_RECORDS_KEY'), 'sk-live')
+    await put(ctx, CODEX, { kind: 'grant', payload: { token: 't' } })
+
+    const text = await readFile(path, 'utf8')
+    expect(text).toBe(
+      'version: 1\nrefs:\n  DSH_RECORDS_KEY: sk-live\nrecords:\n'
+      + '  llm-pi-ai/openai-codex:\n    kind: grant\n    payload:\n      token: t\n',
+    )
+    expect(await ctx.credentials.resolve(credentialRef('DSH_RECORDS_KEY'))).toEqual({ value: 'sk-live', source: 'file' })
+  })
+
+  it('reads every record shape back off disk', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    // Written by hand rather than through the API: this is the parse path, and
+    // an api-key record is legal with a key, with environment values, with
+    // both, or with neither.
+    await writeCredentials(path, 'version: 1\nrecords:\n'
+      + '  llm-pi-ai/openai-codex:\n    kind: grant\n    payload:\n      access: at\n'
+      + '  llm-pi-ai/amazon-bedrock:\n    kind: api-key\n    env:\n      AWS_PROFILE: prod\n'
+      + '  llm-pi-ai/azure:\n    kind: api-key\n    key: sk-azure\n'
+      + '  llm-pi-ai/both:\n    kind: api-key\n    key: sk-both\n    env:\n      REGION: eu\n'
+      + '  llm-pi-ai/ambient:\n    kind: api-key\n')
+    const ctx = await boot({ path, watch: false })
+
+    expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { access: 'at' } })
+    expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
+    expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'azure')))
+      .toEqual({ kind: 'api-key', key: 'sk-azure' })
+    expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'both')))
+      .toEqual({ kind: 'api-key', key: 'sk-both', env: { REGION: 'eu' } })
+    expect(await ctx.credentials.readRecord(credentialKey('llm-pi-ai', 'ambient'))).toEqual({ kind: 'api-key' })
+  })
+
+  it('publishes a record an external edit reshaped, whatever shape it took', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { scopes: ['a'] } })
+    const seen = recordUpdates(ctx)
+
+    // A sequence where a mapping stood, then a mapping that gained a field:
+    // neither is caught by an identity check, and reporting them as unchanged
+    // would leave a stale credential on every configuration surface.
+    for (const payload of ['[1]', '{a: 1}', '{a: 1, b: 2}']) {
+      await writeCredentials(path, 'version: 1\nrecords:\n  llm-pi-ai/openai-codex:\n'
+        + `    kind: grant\n    payload: ${payload}\n`)
+      // Any write folds the unobserved document in before committing its own.
+      await put(ctx, BEDROCK, { kind: 'api-key' })
+    }
+
+    expect(seen.filter(key => key === CODEX)).toHaveLength(3)
+    expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { a: 1, b: 2 } })
+  })
+
+  it('keeps two owners of the same provider id apart', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { owner: 'pi-ai' } })
+    await put(ctx, OTHER_OWNER, { kind: 'grant', payload: { owner: 'kimi' } })
+
+    expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { owner: 'pi-ai' } })
+    expect(await ctx.credentials.readRecord(OTHER_OWNER)).toEqual({ kind: 'grant', payload: { owner: 'kimi' } })
+  })
+})
+
+describe('record mutation', () => {
+  it('shows the mutation the record as it stands and commits its replacement', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { expires: 1 } })
+    const seen: Array<CredentialRecord | undefined> = []
+
+    const next = await ctx.credentials.modifyRecord(CODEX, (current) => {
+      seen.push(current)
+      return Promise.resolve({ kind: 'grant', payload: { expires: 2 } })
+    })
+
+    expect(seen).toEqual([{ kind: 'grant', payload: { expires: 1 } }])
+    expect(next).toEqual({ kind: 'grant', payload: { expires: 2 } })
+  })
+
+  it('leaves the entry untouched when the mutation declines', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { expires: 1 } })
+    const before = await readFile(path, 'utf8')
+    const seen = recordUpdates(ctx)
+
+    // The refresh path declines whenever a second reader finds the credential
+    // already rotated; declining must not rewrite the document or announce a
+    // change that did not happen.
+    const result = await ctx.credentials.modifyRecord(CODEX, () => Promise.resolve(undefined))
+
+    expect(result).toEqual({ kind: 'grant', payload: { expires: 1 } })
+    expect(await readFile(path, 'utf8')).toBe(before)
+    expect(seen).toEqual([])
+  })
+
+  it('announces a committed write and a committed delete, and stays silent on an absent delete', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    const seen = recordUpdates(ctx)
+
+    await put(ctx, CODEX, { kind: 'grant', payload: 1 })
+    await ctx.credentials.deleteRecord(CODEX)
+    await ctx.credentials.deleteRecord(CODEX)
+
+    expect(seen).toEqual([CODEX, CODEX])
+    expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
+  })
+
+  it('removes a later record without disturbing the annotation above the first', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    // The comment sits above the section's first entry, where the parser
+    // attaches it to the section rather than to the pair. Removing a *later*
+    // entry must leave it exactly where it is.
+    await writeCredentials(path, 'version: 1\nrecords:\n  # the one to keep\n'
+      + '  llm-pi-ai/openai-codex:\n    kind: grant\n    payload: 1\n'
+      + '  llm-pi-ai/amazon-bedrock:\n    kind: api-key\n')
+    const ctx = await boot({ path, watch: false })
+
+    await ctx.credentials.deleteRecord(BEDROCK)
+
+    expect(await readFile(path, 'utf8')).toBe('version: 1\nrecords:\n  # the one to keep\n'
+      + '  llm-pi-ai/openai-codex:\n    kind: grant\n    payload: 1\n')
+  })
+
+  it('folds an unobserved external record edit into a write instead of overwriting it', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { v: 1 } })
+    // Landed on disk with no watcher to report it — the same blind spot as a
+    // debounce window, a missed event, or another process's write.
+    await writeCredentials(path, 'version: 1\nrecords:\n'
+      + '  llm-pi-ai/openai-codex:\n    kind: grant\n    payload:\n      v: 1\n'
+      + '  llm-pi-ai/amazon-bedrock:\n    kind: api-key\n    env:\n      AWS_PROFILE: prod\n')
+
+    await put(ctx, CODEX, { kind: 'grant', payload: { v: 2 } })
+
+    expect(await ctx.credentials.readRecord(BEDROCK)).toEqual({ kind: 'api-key', env: { AWS_PROFILE: 'prod' } })
+    expect(await ctx.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { v: 2 } })
+  })
+
+  it('keeps both records when two providers write the same document concurrently', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const first = await boot({ path, watch: false })
+    const second = await boot({ path, watch: false })
+
+    await Promise.all([
+      (async () => {
+        for (const v of [1, 2, 3]) await put(first, CODEX, { kind: 'grant', payload: { v } })
+      })(),
+      (async () => {
+        for (const v of [1, 2, 3]) await put(second, BEDROCK, { kind: 'grant', payload: { v } })
+      })(),
+    ])
+
+    const reread = await boot({ path, watch: false })
+    expect(await reread.credentials.readRecord(CODEX)).toEqual({ kind: 'grant', payload: { v: 3 } })
+    expect(await reread.credentials.readRecord(BEDROCK)).toEqual({ kind: 'grant', payload: { v: 3 } })
+  })
+
+  it('enumerates stored records by address and tag, never by value', async () => {
+    const dir = await tempDir()
+    const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
+    await put(ctx, CODEX, { kind: 'grant', payload: { secret: 'do-not-list' } })
+    await put(ctx, BEDROCK, { kind: 'api-key', key: 'sk-listed' })
+
+    const listed = await ctx.credentials.listRecords()
+
+    expect(listed).toEqual([{ key: CODEX, kind: 'grant' }, { key: BEDROCK, kind: 'api-key' }])
+    expect(JSON.stringify(listed)).not.toContain('do-not-list')
+    expect(JSON.stringify(listed)).not.toContain('sk-listed')
+  })
+
+  it('refuses a payload this document could not read back', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+
+    // An owner's SDK value that YAML would either lose or re-read as another
+    // type. Rejecting on the way in is what keeps the round-trip promise
+    // keepable; a rejected value must also leave nothing behind.
+    for (const payload of [{ at: new Date(0) }, { size: 1n }, { run: () => undefined }, { ratio: Number.NaN }]) {
+      await expect(put(ctx, CODEX, { kind: 'grant', payload }))
+        .rejects.toThrow(/record "llm-pi-ai\/openai-codex" payload/)
+    }
+    expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
+    await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
+  })
+
+  it('refuses an api-key record this document could not read back', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const ctx = await boot({ path, watch: false })
+
+    // The same admission rule as the read path: an api-key record parseRecord
+    // would reject at the next boot is refused before it is rendered, so the
+    // current process can never report a success the next one refuses to load.
+    await expect(put(ctx, CODEX, { kind: 'api-key', key: '' }))
+      .rejects.toThrow(/empty key/)
+    await expect(put(ctx, CODEX, { kind: 'api-key', env: { 'not a name': 'value' } }))
+      .rejects.toThrow(/must match/)
+    await expect(put(ctx, CODEX, { kind: 'api-key', env: { AWS_REGION: '' } }))
+      .rejects.toThrow(/non-empty string/)
+    expect(await ctx.credentials.readRecord(CODEX)).toBeUndefined()
+    await expect(readFile(path, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
+  })
+
+  it('refuses record writes once disposed', async () => {
+    const dir = await tempDir()
+    const ctx = new Context()
+    const fiber = ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+    await fiber
+    const credentials = ctx.credentials
+    await fiber.dispose()
+
+    await expect(credentials.modifyRecord(CODEX, () => Promise.resolve({ kind: 'grant', payload: 1 })))
+      .rejects.toThrow(/disposed/)
+    await expect(credentials.deleteRecord(CODEX)).rejects.toThrow(/disposed/)
+  })
+})

+ 43 - 11
packages/credentials/credentials-local/tests/review-fixes.spec.ts

@@ -1,5 +1,5 @@
 // Third-review behaviors: read-modify-write under the writer lock (external
-// edits survive an API write), the contained credentials/updated fan-out (a
+// edits survive an API write), the contained credentials/reference-updated fan-out (a
 // broken observer never fails a committed write), and the YAML document
 // editor's isolation between entries.
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -7,7 +7,7 @@ import { Context } from '@deepseek-ai/cordis'
 import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
 import { LocalCredentialProvider } from '../src/index.ts'
 
 /** Credential documents are seeded owner-only, exactly as the provider creates them. */
@@ -45,11 +45,11 @@ describe('read-modify-write', () => {
     const path = join(dir, '.credentials.yaml')
     const ctx = await boot({ path, watch: false })
     const seen: string[] = []
-    ctx.on('credentials/updated', (ref) => { seen.push(ref) })
+    ctx.on('credentials/reference-updated', (ref) => { seen.push(ref) })
     await ctx.credentials.set(ALPHA, 'one')
     // The external edit has landed on disk but no watcher reported it (watch
     // is off — the same blind spot as a debounce window or a missed event).
-    await writeCredentials(path, `${ALPHA}: one\n${BETA}: external\n`)
+    await writeCredentials(path, `version: 1\nrefs:\n  ${ALPHA}: one\n  ${BETA}: external\n`)
     await ctx.credentials.set(ALPHA, 'two')
     const text = await readFile(path, 'utf8')
     expect(text).toContain(`${BETA}: external`)
@@ -80,17 +80,48 @@ describe('read-modify-write', () => {
     await ctx.credentials.set(ALPHA, 'one')
     if (process.platform !== 'win32') expect((await stat(home)).mode & 0o777).toBe(0o700)
   })
+
+  it('holds every writer of the document to the record-mutation lock wait', async () => {
+    const dir = await tempDir()
+    const path = join(dir, '.credentials.yaml')
+    const holder = await boot({ path, watch: false })
+    const contender = await boot({ path, watch: false })
+    const doomed = credentialKey('llm-pi-ai', 'doomed')
+    const slowKey = credentialKey('llm-pi-ai', 'slow')
+    await holder.credentials.modifyRecord(doomed, () => Promise.resolve({ kind: 'api-key', key: 'x' }))
+    const entered = Promise.withResolvers<undefined>()
+    // The mutation holds the cross-process writer lock across a stand-in for
+    // an OAuth refresh round trip — longer than withFileLock's 2s default.
+    const slow = holder.credentials.modifyRecord(slowKey, async () => {
+      entered.resolve(undefined)
+      await new Promise(resolve => setTimeout(resolve, 2_400))
+      return { kind: 'api-key', key: 'slow' }
+    })
+    await entered.promise
+    // The other two writer paths — a reference write and a record delete —
+    // share that file and that lock, so they must wait the refresh out rather
+    // than fail at the file-work default.
+    await Promise.all([
+      contender.credentials.set(ALPHA, 'waited'),
+      contender.credentials.deleteRecord(doomed),
+    ])
+    await slow
+    const reread = await boot({ path, watch: false })
+    expect(await reread.credentials.resolve(ALPHA)).toEqual({ value: 'waited', source: 'file' })
+    expect(await reread.credentials.readRecord(doomed)).toBeUndefined()
+    expect(await reread.credentials.readRecord(slowKey)).toEqual({ kind: 'api-key', key: 'slow' })
+  })
 })
 
 describe('contained update fan-out', () => {
   it('does not fail a committed set when a listener throws, and later listeners still run', async () => {
     const dir = await tempDir()
     const ctx = await boot({ path: join(dir, '.credentials.yaml'), watch: false })
-    ctx.on('credentials/updated', () => {
+    ctx.on('credentials/reference-updated', () => {
       throw new Error('observer boom')
     })
     const second = vi.fn()
-    ctx.on('credentials/updated', second)
+    ctx.on('credentials/reference-updated', second)
     await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
     expect(second).toHaveBeenCalledWith(ALPHA)
     expect(await ctx.credentials.resolve(ALPHA)).toEqual({ value: 'one', source: 'file' })
@@ -102,7 +133,7 @@ describe('contained update fan-out', () => {
     // An unknown-returning function keeps the typed surface legal while the
     // runtime value is still the rejected promise the containment must handle.
     const boom = (): unknown => Promise.reject(new Error('async observer boom'))
-    ctx.on('credentials/updated', boom)
+    ctx.on('credentials/reference-updated', boom)
     await expect(ctx.credentials.set(ALPHA, 'one')).resolves.toBeUndefined()
     await new Promise(resolve => setTimeout(resolve, 10))
   })
@@ -111,11 +142,11 @@ describe('contained update fan-out', () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
     const ctx = await boot({ path, watch: false })
-    ctx.on('credentials/updated', () => {
+    ctx.on('credentials/reference-updated', () => {
       throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
     })
     const second = vi.fn()
-    ctx.on('credentials/updated', second)
+    ctx.on('credentials/reference-updated', second)
     await expect(ctx.credentials.set(ALPHA, 'one')).rejects.toThrow(/forged relation/)
     // Harness-fatal by design — but the write itself committed first.
     expect(second).toHaveBeenCalledWith(ALPHA)
@@ -128,11 +159,12 @@ describe('document editor', () => {
   it('leaves a sibling multi-line value untouched while patching one entry', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    const wrapped = `DSH_REVIEW_WRAPPED: |-\n  line1\n  line2\n${ALPHA}: a\n`
+    const wrapped = `version: 1\nrefs:\n  DSH_REVIEW_WRAPPED: |-\n    line1\n    line2\n  ${ALPHA}: a\n`
     await writeCredentials(path, wrapped)
     const ctx = await boot({ path, watch: false })
     await ctx.credentials.set(ALPHA, 'b')
-    expect(await readFile(path, 'utf8')).toBe(`DSH_REVIEW_WRAPPED: |-\n  line1\n  line2\n${ALPHA}: b\n`)
+    expect(await readFile(path, 'utf8'))
+      .toBe(`version: 1\nrefs:\n  DSH_REVIEW_WRAPPED: |-\n    line1\n    line2\n  ${ALPHA}: b\n`)
     expect(await ctx.credentials.resolve(credentialRef('DSH_REVIEW_WRAPPED')))
       .toEqual({ value: 'line1\nline2', source: 'file' })
   })

+ 18 - 18
packages/credentials/credentials-local/tests/watcher.spec.ts

@@ -105,7 +105,7 @@ describe('watcher pipeline', () => {
     instance!.watcher.emit('error', new Error('watch backend failure'))
     expect(await ctx.credentials.resolve(KEY)).toBeUndefined()
 
-    await writeCredentials(path, 'DSH_CRED_PIPE: arrived\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: arrived\n')
     instance!.watcher.emit('all', 'change', path)
     await vi.waitFor(async () => {
       expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'arrived', source: 'file' })
@@ -115,7 +115,7 @@ describe('watcher pipeline', () => {
   it('keeps the last good snapshot when the file turns unreadable at runtime', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: good\n')
     const ctx = await boot({ path, debounceMs: 5 })
 
     await chmod(path, 0o000)
@@ -130,9 +130,9 @@ describe('watcher pipeline', () => {
   it('keeps the last good snapshot when the read fails after its permission check', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_PIPE: good\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: good\n')
     const ctx = await boot({ path, debounceMs: 5 })
-    fsHarness.nextReadError = Object.assign(new Error('EACCES: injected read failure'), { code: 'EACCES' })
+    fsHarness.nextReadError = Object.assign(new Error('version: 1\nrefs:\n  EACCES: injected read failure\n'), { code: 'EACCES' })
 
     const [instance] = await fakeInstances()
     instance!.watcher.emit('all', 'change', path)
@@ -147,13 +147,13 @@ describe('watcher pipeline', () => {
     const path = join(dir, '.credentials.yaml')
     const ctx = await boot({ path, debounceMs: 5 })
     let arm = true
-    ctx.on('credentials/updated', () => {
+    ctx.on('credentials/reference-updated', () => {
       if (!arm) return
       throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
     })
     const [instance] = await fakeInstances()
 
-    await writeCredentials(path, 'DSH_CRED_PIPE: first\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: first\n')
     instance!.watcher.emit('all', 'change', path)
     // The snapshot commits before the fan-out, so the value lands even though
     // the listener threw out of the refresh.
@@ -162,7 +162,7 @@ describe('watcher pipeline', () => {
     })
 
     arm = false
-    await writeCredentials(path, 'DSH_CRED_PIPE: second\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: second\n')
     instance!.watcher.emit('all', 'change', path)
     await vi.waitFor(async () => {
       expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'second', source: 'file' })
@@ -172,17 +172,17 @@ describe('watcher pipeline', () => {
   it('quiesces the refresh pipeline before dispose completes', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_PIPE: initial\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: initial\n')
     const ctx = new Context()
     const fiber = ctx.plugin(LocalCredentialProvider, { path, debounceMs: 5 })
     await fiber
     let disposed = false
     let postDisposeCommits = 0
-    ctx.on('credentials/updated', () => {
+    ctx.on('credentials/reference-updated', () => {
       if (disposed) postDisposeCommits += 1
     })
 
-    await writeCredentials(path, 'DSH_CRED_PIPE: changed\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: changed\n')
     const [instance] = await fakeInstances()
     // Two queued refreshes: dispose interrupts one mid-flight and the other
     // before it starts, so both closed guards must hold.
@@ -199,10 +199,10 @@ describe('watcher pipeline', () => {
   it('empties the snapshot when the document is deleted and emits the removals', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_PIPE: doomed\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: doomed\n')
     const ctx = await boot({ path, debounceMs: 5 })
     const seen: string[] = []
-    ctx.on('credentials/updated', (ref) => {
+    ctx.on('credentials/reference-updated', (ref) => {
       seen.push(ref)
     })
 
@@ -218,10 +218,10 @@ describe('watcher pipeline', () => {
   it('keeps the last good snapshot when an external edit makes the document invalid', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, 'DSH_CRED_PIPE: a\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: a\n')
     const ctx = await boot({ path, debounceMs: 5 })
     const seen: string[] = []
-    ctx.on('credentials/updated', (ref) => {
+    ctx.on('credentials/reference-updated', (ref) => {
       seen.push(ref)
     })
 
@@ -229,7 +229,7 @@ describe('watcher pipeline', () => {
     // this document holds nothing but credentials. A live reload must warn
     // and keep serving the last good snapshot rather than take the process
     // down or silently drop the entry it could not validate.
-    await writeCredentials(path, 'BAD-KEY: 2\nDSH_CRED_PIPE: b\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  BAD-KEY: 2\n  DSH_CRED_PIPE: b\n')
     const [instance] = await fakeInstances()
     instance!.watcher.emit('all', 'change', path)
     await new Promise(resolve => setTimeout(resolve, 50))
@@ -237,7 +237,7 @@ describe('watcher pipeline', () => {
     expect(seen).toEqual([])
 
     // Repairing the document resumes publishing.
-    await writeCredentials(path, 'DSH_CRED_PIPE: b\n')
+    await writeCredentials(path, 'version: 1\nrefs:\n  DSH_CRED_PIPE: b\n')
     instance!.watcher.emit('all', 'change', path)
     await vi.waitFor(async () => {
       expect(await ctx.credentials.resolve(KEY)).toEqual({ value: 'b', source: 'file' })
@@ -258,11 +258,11 @@ describe('watcher pipeline', () => {
   it('reconciles at watcher ready so a change during setup is not missed', async () => {
     const dir = await tempDir()
     const path = join(dir, '.credentials.yaml')
-    await writeCredentials(path, `${KEY}: a\n`)
+    await writeCredentials(path, `version: 1\nrefs:\n  ${KEY}: a\n`)
     const ctx = await boot({ path, debounceMs: 5 })
     // Written after the initial load but before the watcher became active:
     // no 'all' event will ever fire for it.
-    await writeCredentials(path, `${KEY}: written-before-ready\n`)
+    await writeCredentials(path, `version: 1\nrefs:\n  ${KEY}: written-before-ready\n`)
     const [instance] = await fakeInstances()
     instance!.watcher.emit('ready')
     await vi.waitFor(async () => {

+ 2 - 2
packages/credentials/credentials/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/credentials/credentials/README.md
-README.md: 62f5c6318ec04e9820a3d18ffa159d1561215760
-README.zh.md: 3c6ca0b454d4ff8a78bfcd29d1031584da27a5f1
+README.md: ff2e9a7002a3a4a2527ec0a36a4472711da5be2a
+README.zh.md: 5a21361c8dec5f98a36e5a1753ad8fc2f670bc0b

+ 26 - 4
packages/credentials/credentials/README.md

@@ -10,11 +10,19 @@ Credential Service Definition (`ctx.credentials`). One doctrine, three consequen
 
 **An empty stored value is absent.** Everywhere: `resolve` skips it, `describe` reports it unconfigured. A blank can never masquerade as a configured secret.
 
+## Two key spaces, two questions
+
+A `CredentialRef` answers *what is behind this environment-variable name*, layered over the process environment, the managed store, and `.env` files. Everything above describes that half.
+
+A `CredentialKey` answers *what credential does this plugin hold for this id*. Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and the empty-value rule does not apply: an `api-key` record carrying neither a key nor environment values states that its owner confirmed ambient authentication, which is configured.
+
+The key is `<scope>/<id>`, where `scope` is the **owning plugin's registered name**. The scope is the owner rather than the domain because a `grant` payload is written in its owner's format: two plugins serving the same provider name would otherwise read each other's payload, and a record left behind by an uninstalled plugin could not be told apart from a live one. The `/` also keeps the two grammars disjoint, so the key spaces can never collide. A consumer whose id arrives from somewhere else — a settings dict key, a library's own provider id — asks `isCredentialKeySegment` before building a key, because an id outside the grammar can never have stored a record and should read as "nothing stored" rather than throw on the address.
+
 ## Surface
 
 ```ts
 import type { Context } from '@deepseek-ai/cordis'
-import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
 
 declare const ctx: Context
 
@@ -23,9 +31,22 @@ const hit = await ctx.credentials.resolve(ref)           // { value, source } |
 const info = await ctx.credentials.describe(ref)         // { configured, source?, writable } — never the value
 await ctx.credentials.set(ref, 'sk-…')                   // rejects while a read-only source shadows the ref
 await ctx.credentials.unset(ref)                         // no-op when absent; same shadowing rule
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')   // <owner>/<id>, branded
+await ctx.credentials.readRecord(key)                    // CredentialRecord | undefined
+await ctx.credentials.describeRecord(key)                // { configured, kind?, writable } — never the value
+await ctx.credentials.listRecords()                      // [{ key, kind }] — never values
+await ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { token: '…' } }))
+await ctx.credentials.deleteRecord(key)                  // no-op when absent
 ```
 
-`credentials/updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it.
+`modifyRecord` is the only write path because a correct write depends on the current value: a token refresh is read-decide-replace, and the mutation must see the record as it stands at the moment the write is exclusive. Exclusion holds across processes, which is what stops two of them rotating one refresh token and losing whichever wrote first. Returning `undefined` from the mutation leaves the entry untouched and announces nothing.
+
+`listRecords` exists even though the reference half has no enumeration by design. References are discovered from settings schemas (`apiKeyEnv` fields); records have no such path, so a surface that cannot list them cannot show what a user is authorized for, nor find an orphan left by an uninstalled plugin.
+
+A `grant` record's `payload` is opaque: the seam never reads, validates, or reshapes it. The one constraint is that it survives a JSON round trip, which the provider enforces on the way in and on the way out — a value the store could not read back exactly as written is refused rather than stored lossily.
+
+`credentials/reference-updated (ref)` fires after a committed change to a provider-managed source — a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Consumers do not need the event (they re-resolve per operation); it exists for configuration UIs refreshing a "configured" badge. Its declaration lives in the client-safe `./types` subpath export together with the `CredentialRef` type it names (the package root re-exports the type), so a consumer outside the Host compilation face reads the very signature the Host emits instead of restating it.
 
 The shadowing rule on `set`/`unset` is deliberate fail-loud: when a read-only source (the live process environment, in the local provider) currently supplies the reference, a write would appear to succeed while resolution keeps returning the shadowing value — the seam rejects instead, and `describe().writable` lets a UI render the reference read-only up front.
 
@@ -43,6 +64,7 @@ No direct invalidation; credentials never enter a request prefix.
 
 ## Known Limitations and Deferred Work
 
-- **No enumeration** — the seam answers questions about references it is given; configuration surfaces learn the references from settings schemas, so a `list()` has no current consumer.
-- **References are environment-variable-shaped** — one flat POSIX-identifier namespace until a provider needs richer addressing.
+- **References have no enumeration** — the seam answers questions about references it is given; configuration surfaces learn them from settings schemas, so a `list()` over that half has no current consumer. Records do enumerate, for the reason above.
+- **References are environment-variable-shaped** — one flat POSIX-identifier namespace, because a reference doubles as the environment name it resolves through. Records carry the richer `<owner>/<id>` addressing.
 - **Process-environment changes are invisible** — no event can fire for them; a UI only re-reads `describe()` on its own navigation.
+- **A record's owner is its scope, and nothing verifies the scope is mounted** — the seam stores what it is given and reports what it stores. Recognizing an orphan is the caller's join between `listRecords()` and whatever registry owns that scope; the seam has no registry of its own to check against.

+ 26 - 4
packages/credentials/credentials/README.zh.md

@@ -10,11 +10,19 @@
 
 **空的存储值等于不存在。** 处处如此:`resolve` 跳过它,`describe` 报告未配置。空白永远不会伪装成已配置的机密。
 
+## 两个键空间,两个问题
+
+`CredentialRef` 回答的是*这个环境变量名背后是什么*,分层覆盖进程环境、托管存储与 `.env` 文件。以上全部描述的都是这一半。
+
+`CredentialKey` 回答的是*某个插件为某个 id 持有什么凭据*。这里没有任何东西可以分层——授权 grant 没有可供读取的环境——因此记录的存在与否就是全部事实,空值规则在此不适用:一条既无 key 也无环境值的 `api-key` 记录,陈述的是其拥有者确认该路由靠 ambient 认证,这属于已配置。
+
+键的形式是 `<scope>/<id>`,其中 `scope` 是**拥有该记录的插件的注册名**。scope 取拥有者而非领域,是因为 `grant` 的 payload 以其拥有者的格式写就:否则服务同一个提供方名称的两个插件会互相读到对方的 payload,而已卸载插件留下的记录也无法与仍在使用的区分开。`/` 同时让两种文法互斥,两个键空间因此不可能相撞。id 来自别处的消费方——settings dict 键、某个库自己的 provider id——应先问 `isCredentialKeySegment` 再构造键:文法之外的 id 不可能存过记录,应读作「没有存储任何东西」,而不是在寻址上抛错。
+
 ## 接口
 
 ```ts
 import type { Context } from '@deepseek-ai/cordis'
-import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
 
 declare const ctx: Context
 
@@ -23,9 +31,22 @@ const hit = await ctx.credentials.resolve(ref)           // { value, source } |
 const info = await ctx.credentials.describe(ref)         // { configured, source?, writable } — never the value
 await ctx.credentials.set(ref, 'sk-…')                   // rejects while a read-only source shadows the ref
 await ctx.credentials.unset(ref)                         // no-op when absent; same shadowing rule
+
+const key = credentialKey('llm-pi-ai', 'openai-codex')   // <owner>/<id>, branded
+await ctx.credentials.readRecord(key)                    // CredentialRecord | undefined
+await ctx.credentials.describeRecord(key)                // { configured, kind?, writable } — never the value
+await ctx.credentials.listRecords()                      // [{ key, kind }] — never values
+await ctx.credentials.modifyRecord(key, async () => ({ kind: 'grant', payload: { token: '…' } }))
+await ctx.credentials.deleteRecord(key)                  // no-op when absent
 ```
 
-`credentials/updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。它的声明住在 client-safe 的 `./types` 子路径出口,与其点名的 `CredentialRef` 类型同处一处(包根继续 re-export 该类型),于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
+`modifyRecord` 是唯一写路径,因为正确的写入依赖当前值:刷新 token 是「读—决定—替换」,变更函数必须看到写入取得独占那一刻的记录。独占跨进程成立,这正是防止两个进程同时轮换同一个 refresh token、丢掉先写那一个的机制。变更函数返回 `undefined` 表示保持原状,不写盘也不发通知。
+
+`listRecords` 存在,尽管引用那一半刻意不提供枚举。引用可以从 settings schema(`apiKeyEnv` 字段)被发现;记录没有这条路径,无法枚举的界面就无法显示用户已授权了什么,也找不到已卸载插件留下的孤儿。
+
+`grant` 记录的 `payload` 是不透明的:seam 从不读取、校验或重塑它。唯一约束是它能经受 JSON 往返,提供方在写入与读出两个方向都强制这一点——存储无法逐字读回的值会被拒绝,而不是有损地存下。
+
+`credentials/reference-updated (ref)` 在提供方管理的来源发生已提交变更后触发——`set`、`unset` 或在存储中观察到的外部编辑。进程环境变量的变化不可观测,永不触发。消费方不需要该事件(它们按操作重新解析);它服务于配置界面刷新「已配置」徽标。它的声明住在 client-safe 的 `./types` 子路径出口,与其点名的 `CredentialRef` 类型同处一处(包根继续 re-export 该类型),于是 Host 编译面之外的消费方读到的正是 Host 发射的那一份签名,而不必再写一遍。
 
 `set`/`unset` 的遮蔽规则有意采用明确报错的方式:当只读来源(本地提供方中即当前进程环境)正在提供该引用时,写入会表面成功而解析仍返回遮蔽值——seam 选择直接拒绝,并通过 `describe().writable` 让界面提前把该引用渲染为只读。
 
@@ -43,6 +64,7 @@ await ctx.credentials.unset(ref)                         // no-op when absent; s
 
 ## 已知限制与暂缓事项
 
-- **不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,`list()` 没有当前消费方。
-- **引用限定为环境变量形状**——在有提供方需要更丰富的寻址方式前,保持单一扁平的 POSIX 标识符命名空间。
+- **引用不提供枚举**——seam 只回答被问到的引用;配置界面从 settings schema 得知引用集合,对这一半做 `list()` 没有当前消费方。记录出于上文的理由则可枚举。
+- **引用限定为环境变量形状**——单一扁平的 POSIX 标识符命名空间,因为引用同时就是它借以解析的环境变量名。记录使用更丰富的 `<owner>/<id>` 寻址。
 - **进程环境变化不可见**——不可能为其发事件;界面只能在自身导航时重新读取 `describe()`。
+- **记录的拥有者就是它的 scope,而没有任何环节核验该 scope 是否已挂载**——seam 存下被交予的内容,并报告它存了什么。识别孤儿是调用方在 `listRecords()` 与拥有该 scope 的注册表之间做的连接;seam 自身没有可供核对的注册表。

+ 195 - 17
packages/credentials/credentials/src/index.ts

@@ -9,24 +9,108 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import type { CredentialRef } from './types.ts'
+import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts'
 
-export type { CredentialRef } from './types.ts'
+export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts'
 
 const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
 
+/** Both halves of a {@link CredentialKey}; the `/` between them is what keeps it out of {@link REF_PATTERN}. */
+const KEY_SEGMENT_PATTERN = /^[a-z][a-z0-9-]*$/
+
 /**
  * Brand a raw string as a {@link CredentialRef}.
  * @param value - candidate reference; a POSIX shell identifier such as `DEEPSEEK_API_KEY`.
  * @returns the branded reference.
  */
 export function credentialRef(value: string): CredentialRef {
-  if (!REF_PATTERN.test(value)) {
+  if (!isCredentialRefName(value)) {
     throw new TypeError(`credential ref "${value}" must match ${String(REF_PATTERN)}`)
   }
   return value as CredentialRef
 }
 
+/**
+ * Whether a raw string could name a reference at all. Consumers that receive
+ * environment-variable names from somewhere else — a provider library's own
+ * ambient discovery, a hook payload — ask this before resolving, because a name
+ * outside the grammar has no reference to miss and should read as "not set"
+ * rather than as a thrown error.
+ * @param value - candidate reference.
+ * @returns true when {@link credentialRef} would accept it.
+ */
+export function isCredentialRefName(value: string): boolean {
+  return REF_PATTERN.test(value)
+}
+
+/**
+ * Whether a raw string could be a {@link credentialKey} segment at all.
+ * Consumers whose addressing units come from somewhere else — a settings dict
+ * key, a library's own provider id — ask this before building a key, because a
+ * unit outside the grammar can never have stored a record and should read as
+ * "nothing stored" rather than as a thrown error.
+ * @param value - candidate segment.
+ * @returns true when {@link credentialKey} would accept it as either segment.
+ */
+export function isCredentialKeySegment(value: string): boolean {
+  return KEY_SEGMENT_PATTERN.test(value)
+}
+
+/**
+ * Brand a scope and an id as a {@link CredentialKey}.
+ * @param scope - the owning plugin's registered name, such as `llm-pi-ai`.
+ * @param id - that plugin's own addressing unit, such as a provider route key.
+ * @returns the branded key.
+ * @throws TypeError when either segment is not a lowercase hyphenated identifier.
+ */
+export function credentialKey(scope: string, id: string): CredentialKey {
+  for (const segment of [scope, id]) {
+    if (!KEY_SEGMENT_PATTERN.test(segment)) {
+      throw new TypeError(`credential key segment "${segment}" must match ${String(KEY_SEGMENT_PATTERN)}`)
+    }
+  }
+  return `${scope}/${id}` as CredentialKey
+}
+
+/**
+ * Brand a stored `<scope>/<id>` string as a {@link CredentialKey}. This is the
+ * read half of {@link credentialKey}, for a provider admitting keys off disk.
+ * @param value - candidate key in its joined form.
+ * @returns the branded key.
+ * @throws TypeError when the value is not exactly two valid segments.
+ */
+export function parseCredentialKey(value: string): CredentialKey {
+  const segments = value.split('/')
+  const [scope, id] = segments
+  if (segments.length !== 2 || scope === undefined || id === undefined) {
+    throw new TypeError(`credential key "${value}" must be "<scope>/<id>"`)
+  }
+  return credentialKey(scope, id)
+}
+
+/**
+ * The owning plugin's name for one key. A record whose scope names no
+ * currently registered owner is an orphan, which a configuration surface must
+ * report as such rather than as a working credential.
+ * @param key - the key to read.
+ * @returns the scope segment.
+ */
+export function credentialKeyScope(key: CredentialKey): string {
+  // The brand's only constructors both validate two segments, so the split
+  // cannot come back short here.
+  return key.slice(0, key.indexOf('/'))
+}
+
+/**
+ * The owning plugin's own addressing unit for one key — the half that plugin
+ * chose, such as a provider route.
+ * @param key - the key to read.
+ * @returns the id segment.
+ */
+export function credentialKeyId(key: CredentialKey): string {
+  return key.slice(key.indexOf('/') + 1)
+}
+
 /** One resolved credential value and the source layer that supplied it. */
 export interface ResolvedCredential {
   /** The non-empty secret value. */
@@ -45,6 +129,29 @@ export interface CredentialInfo {
   writable: boolean
 }
 
+/** Presence and writability facts for one record, safe for configuration UIs — never the value. */
+export interface CredentialRecordInfo {
+  /**
+   * Whether a record is stored. Unlike a reference, presence alone answers
+   * this: an {@link ApiKeyRecord} carrying neither a key nor environment
+   * values states that its owner confirmed ambient authentication, which is
+   * configured, not blank.
+   */
+  configured: boolean
+  /** Discriminant of the stored record; absent while none is stored. */
+  kind?: CredentialRecord['kind']
+  /** Whether {@link CredentialProvider.modifyRecord} would currently succeed. */
+  writable: boolean
+}
+
+/** One stored record's address and tag, for enumeration — never its value. */
+export interface CredentialRecordEntry {
+  /** The record's address. */
+  key: CredentialKey
+  /** Discriminant of the stored record. */
+  kind: CredentialRecord['kind']
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Context {
     credentials: CredentialProvider
@@ -52,10 +159,20 @@ declare module '@deepseek-ai/cordis' {
 }
 
 /**
- * Abstract credential service. Providers implement the four operations over
- * their source layers; one seam-wide rule binds them all: an empty stored
- * value is absent everywhere — `resolve` skips it, `describe` reports it
- * unconfigured — so a blank never masquerades as a configured secret.
+ * Abstract credential service over two key spaces that answer two questions.
+ *
+ * A {@link CredentialRef} answers "what is behind this environment-variable
+ * name", layered over the process environment, the provider-managed store, and
+ * `.env` files. One seam-wide rule binds that half: an empty stored value is
+ * absent everywhere — `resolve` skips it, `describe` reports it unconfigured —
+ * so a blank never masquerades as a configured secret.
+ *
+ * A {@link CredentialKey} answers "what credential does this plugin hold for
+ * this id". Nothing can layer here — an authorization grant has no
+ * environment to be read from — so presence of the record is the whole fact,
+ * and {@link modifyRecord} is the only write path because a correct write
+ * depends on the current value (a token refresh is read-decide-replace under
+ * one lock).
  */
 export abstract class CredentialProvider extends Service {
   constructor(ctx: Context) {
@@ -98,11 +215,55 @@ export abstract class CredentialProvider extends Service {
    */
   abstract unset(ref: CredentialRef): Promise<void>
 
-  /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
-     fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
-     contract, and extracting it would couple the two seams' event semantics. */
   /**
-   * Fan `credentials/updated` out with contained listener failures: every
+   * Read one stored record. The value is returned as its owner wrote it; a
+   * {@link GrantRecord} payload is not interpreted on the way out.
+   * @param key - the record to read.
+   * @returns the record, or `undefined` while none is stored.
+   */
+  abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>
+
+  /**
+   * Describe one record for configuration surfaces without exposing its value.
+   * @param key - the record to describe.
+   * @returns presence, discriminant, and writability.
+   */
+  abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>
+
+  /**
+   * Enumerate every stored record's address and tag. Unlike the reference
+   * half, which has no enumeration because configuration surfaces learn which
+   * references exist from settings schemas, records have no such discovery
+   * path: a surface that cannot list them cannot show what a user is
+   * authorized for, nor find an orphan left by an uninstalled plugin.
+   * @returns every stored record, values excluded.
+   */
+  abstract listRecords(): Promise<readonly CredentialRecordEntry[]>
+
+  /**
+   * Serialized read-modify-write over one record — the only write path.
+   * `mutate` sees the record as it stands at the moment the write is
+   * exclusive, and returning `undefined` leaves the entry untouched. Exclusion
+   * holds across processes where the backing store supports it, which is what
+   * makes a token refresh safe: two processes rotating one refresh token
+   * concurrently would otherwise lose whichever wrote first.
+   * @param key - the record to modify.
+   * @param mutate - receives the current record and returns its replacement, or `undefined` to leave it.
+   * @returns the record after the write, or the current one when `mutate` declined.
+   */
+  abstract modifyRecord(
+    key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined>
+
+  /**
+   * Remove one record; removing an absent record is a no-op.
+   * @param key - the record to remove.
+   */
+  abstract deleteRecord(key: CredentialKey): Promise<void>
+
+  /**
+   * Fan `credentials/reference-updated` out with contained listener failures: every
    * listener runs, and a sync throw or async rejection is logged without
    * changing the committed operation's outcome — except `INVARIANT`-coded
    * failures, which rethrow after every listener ran (the rethrow reaches the
@@ -113,14 +274,31 @@ export abstract class CredentialProvider extends Service {
    * @param ref - the reference whose stored value changed.
    */
   protected notifyUpdated(ref: CredentialRef): void {
+    this.fanOut('credentials/reference-updated', ref)
+  }
+
+  /**
+   * Fan `credentials/record-updated` out on exactly the terms
+   * {@link notifyUpdated} documents, for the record half of the seam.
+   * @param key - the record whose stored value changed.
+   */
+  protected notifyRecordUpdated(key: CredentialKey): void {
+    this.fanOut('credentials/record-updated', key)
+  }
+
+  /* jscpd:ignore-start -- deliberate symmetry with the settings seam's commit
+     fan-out: the contained-dispatch shape is the reviewed listener-lifecycle
+     contract, and extracting it would couple the two seams' event semantics. */
+  /** The contained dispatch both notifications run through; see {@link notifyUpdated}. */
+  private fanOut(event: 'credentials/reference-updated' | 'credentials/record-updated', subject: string): void {
     let invariantFailure: unknown
-    const args = ['credentials/updated', ref]
+    const args = [event, subject]
     for (const listener of this.ctx.events.dispatch('emit', args) as Array<(...listenerArgs: unknown[]) => unknown>) {
       try {
-        const returned = listener(ref)
+        const returned = listener(subject)
         if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
           void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
-            this.warnListenerFailure(ref, error)
+            this.warnListenerFailure(event, subject, error)
           })
         }
       } catch (error) {
@@ -128,7 +306,7 @@ export abstract class CredentialProvider extends Service {
           invariantFailure ??= error
           continue
         }
-        this.warnListenerFailure(ref, error)
+        this.warnListenerFailure(event, subject, error)
       }
     }
     if (invariantFailure !== undefined) throw invariantFailure as Error
@@ -136,8 +314,8 @@ export abstract class CredentialProvider extends Service {
   /* jscpd:ignore-end */
 
   /** Contained-listener diagnostic shared by the sync and async failure paths. */
-  private warnListenerFailure(ref: CredentialRef, error: unknown): void {
-    this.ctx.logger.warn('credentials: a credentials/updated listener for "%s" failed', ref)
+  private warnListenerFailure(event: string, subject: string, error: unknown): void {
+    this.ctx.logger.warn('credentials: a %s listener for "%s" failed', event, subject)
     this.ctx.logger.warn(error)
   }
 }

+ 3 - 3
packages/credentials/credentials/src/invariant.ts

@@ -14,7 +14,7 @@ export const name = 'credentials-invariant'
 export const inject = ['invariants']
 
 /**
- * Install the commit-event lifecycle contract: `credentials/updated` names a
+ * Install the commit-event lifecycle contract: `credentials/reference-updated` names a
  * committed provider-source change, so it can only fire while a credentials
  * service is live — an emission after disposal means a provider leaked work
  * past its teardown quiescence. The value relation itself (`describe`
@@ -22,9 +22,9 @@ export const inject = ['invariants']
  * each provider's own suite.
  */
 const install: InvariantInstaller = (ctx: Context, fail: InvariantFailure) => {
-  ctx.on('credentials/updated', (ref) => {
+  ctx.on('credentials/reference-updated', (ref) => {
     if (ctx.get('credentials') === undefined) {
-      fail(`credentials/updated for "${ref}" emitted without a live credentials service`)
+      fail(`credentials/reference-updated for "${ref}" emitted without a live credentials service`)
     }
   })
 }

+ 63 - 5
packages/credentials/credentials/src/types.ts

@@ -1,8 +1,8 @@
 /**
- * Client-safe type surface of the credential-reference seam: the reference
- * brand and the seam's Cordis event declaration. Types only — no runtime code,
- * and nothing here reaches a Host-only symbol, so a Client compilation face
- * reads exactly the signature the Host emits.
+ * Client-safe type surface of the credential seam: the two key brands, the
+ * stored-record union, and the seam's Cordis event declarations. Types only —
+ * no runtime code, and nothing here reaches a Host-only symbol, so a Client
+ * compilation face reads exactly the signature the Host emits.
  *
  * @module @deepseek-ai/dsh-credentials/types
  */
@@ -12,6 +12,52 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
 /** Nominal reference to one credential: a POSIX-style environment-variable name. */
 export type CredentialRef = Branded<'CredentialRef'>
 
+/**
+ * Nominal address of one stored credential record: `<scope>/<id>`, where
+ * `scope` is the registered name of the plugin that owns the record and `id`
+ * is that plugin's own addressing unit (an LLM adapter uses its provider route
+ * key).
+ *
+ * The scope is the owner rather than the domain because a record's payload is
+ * written in its owner's format: two plugins serving the same provider name
+ * would otherwise read each other's payload, and a record left behind by an
+ * uninstalled plugin could not be told apart from a live one. The `/` also
+ * keeps this grammar disjoint from {@link CredentialRef}, so the two key
+ * spaces can never collide.
+ */
+export type CredentialKey = Branded<'CredentialKey'>
+
+/**
+ * A credential the harness itself understands: an api key, provider
+ * environment values, or both. Either field may be absent — a record carrying
+ * neither states that the owner confirmed this route authenticates from its
+ * own ambient discovery, which is a different fact from having no record.
+ */
+export interface ApiKeyRecord {
+  /** Discriminant. */
+  readonly kind: 'api-key'
+  /** The non-empty secret value, when this credential is a key at all. */
+  readonly key?: string
+  /** Provider environment values such as `AWS_PROFILE`; names are POSIX identifiers. */
+  readonly env?: Readonly<Record<string, string>>
+}
+
+/**
+ * The product of one authorization grant, kept verbatim for its owner. The
+ * seam never reads, validates, or reshapes {@link payload}: it is written in
+ * the owning plugin's format and only that plugin can interpret it. The single
+ * constraint is that it survives a JSON round trip.
+ */
+export interface GrantRecord {
+  /** Discriminant. */
+  readonly kind: 'grant'
+  /** Owner-defined JSON value; opaque to the seam and to every other plugin. */
+  readonly payload: unknown
+}
+
+/** One durable credential record, tagged by what the seam may do with it. */
+export type CredentialRecord = ApiKeyRecord | GrantRecord
+
 declare module '@deepseek-ai/cordis' {
   interface Events {
     /**
@@ -26,6 +72,18 @@ declare module '@deepseek-ai/cordis' {
      * @param ref - the reference whose stored value changed.
      * @mode emit
      */
-    'credentials/updated'(ref: CredentialRef): void
+    'credentials/reference-updated'(ref: CredentialRef): void
+
+    /**
+     * Committed change to a stored credential record: a `modifyRecord` that
+     * wrote, a `deleteRecord` that removed, or an external edit observed in
+     * storage. Separate from `credentials/reference-updated` because the two key
+     * grammars are disjoint — a listener that received both on one event could
+     * not tell which space a subject belongs to. Listener failures are
+     * contained on the same terms as `credentials/reference-updated`.
+     * @param key - the record whose stored value changed.
+     * @mode emit
+     */
+    'credentials/record-updated'(key: CredentialKey): void
   }
 }

+ 16 - 3
packages/credentials/credentials/tests/credentials.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { credentialRef } from '../src/index.ts'
+import { credentialRef, isCredentialKeySegment } from '../src/index.ts'
 import type { CredentialRef } from '../src/index.ts'
 import { MemoryCredentials } from './memory.ts'
 
@@ -26,6 +26,19 @@ describe('credentialRef', () => {
   })
 })
 
+describe('isCredentialKeySegment', () => {
+  it('answers whether credentialKey would accept the segment', () => {
+    for (const valid of ['llm-pi-ai', 'openai-codex', 'a', 'z9']) {
+      expect(isCredentialKeySegment(valid)).toBe(true)
+    }
+    // The shapes an arbitrary settings dict key can take that a record id
+    // cannot: a consumer asks here instead of learning it from a throw.
+    for (const invalid of ['', 'My_Proxy', 'z.ai', 'UPPER', '9leading', 'a/b']) {
+      expect(isCredentialKeySegment(invalid)).toBe(false)
+    }
+  })
+})
+
 describe('the credentials seam through the memory provider', () => {
   it('mounts as ctx.credentials and resolves a seeded reference with its source', async () => {
     const ctx = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' })
@@ -42,7 +55,7 @@ describe('the credentials seam through the memory provider', () => {
   it('stores through set, removes through unset, and emits the committed change', async () => {
     const ctx = await boot()
     const events: CredentialRef[] = []
-    ctx.on('credentials/updated', ref => void events.push(ref))
+    ctx.on('credentials/reference-updated', ref => void events.push(ref))
 
     await ctx.credentials.set(REF, 'sk-live')
     expect(await ctx.credentials.resolve(REF)).toEqual({ value: 'sk-live', source: 'memory' })
@@ -54,7 +67,7 @@ describe('the credentials seam through the memory provider', () => {
   it('rejects an empty set and keeps an absent unset silent', async () => {
     const ctx = await boot()
     const events: CredentialRef[] = []
-    ctx.on('credentials/updated', ref => void events.push(ref))
+    ctx.on('credentials/reference-updated', ref => void events.push(ref))
 
     await expect(ctx.credentials.set(REF, '')).rejects.toThrow(/empty value/)
     await ctx.credentials.unset(REF)

+ 1 - 1
packages/credentials/credentials/tests/invariant.spec.ts

@@ -22,7 +22,7 @@ describe('credentials invariant companion', () => {
     await ctx.plugin(InvariantRegistry)
     await ctx.plugin(CredentialsInvariant)
 
-    expect(() => { ctx.emit('credentials/updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
+    expect(() => { ctx.emit('credentials/reference-updated', REF) }).toThrow(/invariant violated by "@deepseek-ai\/dsh-credentials"/)
   })
 
   it('reserves the package name against duplicate registration', async () => {

+ 46 - 3
packages/credentials/credentials/tests/memory.ts

@@ -1,6 +1,14 @@
 import type { Context } from '@deepseek-ai/cordis'
 import { CredentialProvider } from '../src/index.ts'
-import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts'
+import type {
+  CredentialInfo,
+  CredentialKey,
+  CredentialRecord,
+  CredentialRecordEntry,
+  CredentialRecordInfo,
+  CredentialRef,
+  ResolvedCredential,
+} from '../src/index.ts'
 
 /**
  * In-memory credentials provider for interface and consumer tests: one
@@ -8,6 +16,7 @@ import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/i
  */
 export class MemoryCredentials extends CredentialProvider {
   private readonly store = new Map<string, string>()
+  private readonly records = new Map<CredentialKey, CredentialRecord>()
 
   constructor(ctx: Context, seed: Record<string, string> = {}) {
     super(ctx)
@@ -36,13 +45,47 @@ export class MemoryCredentials extends CredentialProvider {
       return Promise.reject(new Error('memory credentials: an empty value cannot be stored; use unset'))
     }
     this.store.set(ref, value)
-    this.ctx.emit('credentials/updated', ref)
+    this.ctx.emit('credentials/reference-updated', ref)
     return Promise.resolve()
   }
 
   override unset(ref: CredentialRef): Promise<void> {
     if (this.store.delete(ref)) {
-      this.ctx.emit('credentials/updated', ref)
+      this.ctx.emit('credentials/reference-updated', ref)
+    }
+    return Promise.resolve()
+  }
+
+  override readRecord(key: CredentialKey): Promise<CredentialRecord | undefined> {
+    return Promise.resolve(this.records.get(key))
+  }
+
+  override describeRecord(key: CredentialKey): Promise<CredentialRecordInfo> {
+    const stored = this.records.get(key)
+    return Promise.resolve(stored === undefined
+      ? { configured: false, writable: true }
+      : { configured: true, kind: stored.kind, writable: true })
+  }
+
+  override listRecords(): Promise<readonly CredentialRecordEntry[]> {
+    return Promise.resolve([...this.records].map(([key, record]) => ({ key, kind: record.kind })))
+  }
+
+  override async modifyRecord(
+    key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined> {
+    const current = this.records.get(key)
+    const next = await mutate(current)
+    if (next === undefined) return current
+    this.records.set(key, next)
+    this.ctx.emit('credentials/record-updated', key)
+    return next
+  }
+
+  override deleteRecord(key: CredentialKey): Promise<void> {
+    if (this.records.delete(key)) {
+      this.ctx.emit('credentials/record-updated', key)
     }
     return Promise.resolve()
   }

+ 159 - 4
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -457,6 +457,44 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       },
     ],
   },
+  {
+    key: 'authorization',
+    summary: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
+    description: '`ctx.authorization`: a registry of credential-obtaining flows, one attempt at a time per key.',
+    methods: [
+      {
+        signature: 'registerFlow(flow: AuthorizationFlow): () => void',
+        description: 'Offer a way to obtain one credential. One flow per key: two plugins claiming the same key would each write a record in their own format, and whichever ran last would leave the other reading a payload it cannot parse.',
+        parameters: [{ name: 'flow', description: 'the key it writes, its label, its methods, and its runner.' }],
+        returns: 'Disposer that withdraws this flow.',
+        throws: ['{AuthorizationError} code `DUPLICATE_FLOW` when the key is already claimed.'],
+      },
+      {
+        signature: 'list(): readonly AuthorizationEntry[]',
+        description: 'Every registered flow, for a surface listing what can be authorized.',
+        parameters: [],
+        returns: 'one entry per flow, in registration order.',
+      },
+      {
+        signature: 'describe(key: CredentialKey): AuthorizationEntry | undefined',
+        description: 'One registered flow.',
+        parameters: [{ name: 'key', description: 'the credential record to ask about.' }],
+        returns: 'the entry, or undefined when no flow claims that key.',
+      },
+      {
+        signature: 'cancel(key: CredentialKey): void',
+        description: 'Withdraw the attempt running for a key, if any. Separate from the request\'s own signal because a request/response transport answers a Cancel button on a second call, with no handle on the first one\'s signal.',
+        parameters: [{ name: 'key', description: 'the credential record whose attempt should stop.' }],
+      },
+      {
+        signature: 'async begin(request: AuthorizationRequest): Promise<AuthorizationOutcome>',
+        description: 'Run one attempt to authorize a key, and report how it ended.\n\nOne attempt per key at a time. A second caller is refused rather than joined: the two would be prompting different humans through the same flow, and the second would answer questions the first was asked.',
+        parameters: [{ name: 'request', description: 'the key, the method, the surface, and the cancel signal.' }],
+        returns: '`authorized` once the flow\'s record is committed during this attempt and observed, or `cancelled` when the human declined or the caller withdrew.',
+        throws: ['{AuthorizationError} code `NO_FLOW` when nothing claims the key, `UNKNOWN_METHOD` when the named method is not one the flow offers, `ALREADY_IN_FLIGHT` when an attempt is already running for the key, or `NOT_COMMITTED` when the flow resolved without committing a record during the attempt.'],
+      },
+    ],
+  },
   {
     key: 'clientModules',
     summary: 'The web plugin table service: incremental `dsh.client` scan + wire composition + bundle route + index injection rows.',
@@ -577,8 +615,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
   },
   {
     key: 'credentials',
-    summary: 'Abstract credential service.',
-    description: 'Abstract credential service. Providers implement the four operations over their source layers; one seam-wide rule binds them all: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.',
+    summary: 'Abstract credential service over two key spaces that answer two questions.',
+    description: 'Abstract credential service over two key spaces that answer two questions.\n\nA CredentialRef answers "what is behind this environment-variable name", layered over the process environment, the provider-managed store, and `.env` files. One seam-wide rule binds that half: an empty stored value is absent everywhere — `resolve` skips it, `describe` reports it unconfigured — so a blank never masquerades as a configured secret.\n\nA CredentialKey answers "what credential does this plugin hold for this id". Nothing can layer here — an authorization grant has no environment to be read from — so presence of the record is the whole fact, and modifyRecord is the only write path because a correct write depends on the current value (a token refresh is read-decide-replace under one lock).',
     methods: [
       {
         signature: 'abstract resolve(ref: CredentialRef): Promise<ResolvedCredential | undefined>',
@@ -602,6 +640,35 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
         description: 'Remove one reference from the provider-managed writable source; removing an absent reference is a no-op. Rejects while a read-only source shadows the reference, like set.',
         parameters: [{ name: 'ref', description: 'the reference to remove.' }],
       },
+      {
+        signature: 'abstract readRecord(key: CredentialKey): Promise<CredentialRecord | undefined>',
+        description: 'Read one stored record. The value is returned as its owner wrote it; a GrantRecord payload is not interpreted on the way out.',
+        parameters: [{ name: 'key', description: 'the record to read.' }],
+        returns: 'the record, or `undefined` while none is stored.',
+      },
+      {
+        signature: 'abstract describeRecord(key: CredentialKey): Promise<CredentialRecordInfo>',
+        description: 'Describe one record for configuration surfaces without exposing its value.',
+        parameters: [{ name: 'key', description: 'the record to describe.' }],
+        returns: 'presence, discriminant, and writability.',
+      },
+      {
+        signature: 'abstract listRecords(): Promise<readonly CredentialRecordEntry[]>',
+        description: 'Enumerate every stored record\'s address and tag. Unlike the reference half, which has no enumeration because configuration surfaces learn which references exist from settings schemas, records have no such discovery path: a surface that cannot list them cannot show what a user is authorized for, nor find an orphan left by an uninstalled plugin.',
+        parameters: [],
+        returns: 'every stored record, values excluded.',
+      },
+      {
+        signature: 'abstract modifyRecord( key: CredentialKey, mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>, ): Promise<CredentialRecord | undefined>',
+        description: 'Serialized read-modify-write over one record — the only write path. `mutate` sees the record as it stands at the moment the write is exclusive, and returning `undefined` leaves the entry untouched. Exclusion holds across processes where the backing store supports it, which is what makes a token refresh safe: two processes rotating one refresh token concurrently would otherwise lose whichever wrote first.',
+        parameters: [{ name: 'key', description: 'the record to modify.' }, { name: 'mutate', description: 'receives the current record and returns its replacement, or `undefined` to leave it.' }],
+        returns: 'the record after the write, or the current one when `mutate` declined.',
+      },
+      {
+        signature: 'abstract deleteRecord(key: CredentialKey): Promise<void>',
+        description: 'Remove one record; removing an absent record is a no-op.',
+        parameters: [{ name: 'key', description: 'the record to remove.' }],
+      },
     ],
   },
   {
@@ -2417,6 +2484,14 @@ export const EVENT_API: readonly EventApiEntry[] = [
     description: 'Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.',
     parameters: [{ name: 'req', description: 'the pending decision (agent, tool identity, reason, signal).' }],
   },
+  {
+    name: 'authorization/settled',
+    mode: 'emit',
+    signature: '\'authorization/settled\'(key: CredentialKey, settlement: AuthorizationSettlement): void',
+    summary: 'One authorization attempt has finished and released its key.',
+    description: 'One authorization attempt has finished and released its key. Fires for every terminal outcome, failures included, so a surface watching a key it did not start (a second browser tab) learns the attempt is over.',
+    parameters: [{ name: 'key', description: 'the credential record the finished attempt was authorizing.' }, { name: 'settlement', description: 'how it ended, including the `failed` case its caller sees as a thrown error.' }],
+  },
   {
     name: 'commands/change',
     mode: 'emit',
@@ -2474,9 +2549,17 @@ export const EVENT_API: readonly EventApiEntry[] = [
     parameters: [{ name: 'resolved', description: 'request identity and outcome.' }],
   },
   {
-    name: 'credentials/updated',
+    name: 'credentials/record-updated',
+    mode: 'emit',
+    signature: '\'credentials/record-updated\'(key: CredentialKey): void',
+    summary: 'Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage.',
+    description: 'Committed change to a stored credential record: a `modifyRecord` that wrote, a `deleteRecord` that removed, or an external edit observed in storage. Separate from `credentials/reference-updated` because the two key grammars are disjoint — a listener that received both on one event could not tell which space a subject belongs to. Listener failures are contained on the same terms as `credentials/reference-updated`.',
+    parameters: [{ name: 'key', description: 'the record whose stored value changed.' }],
+  },
+  {
+    name: 'credentials/reference-updated',
     mode: 'emit',
-    signature: '\'credentials/updated\'(ref: CredentialRef): void',
+    signature: '\'credentials/reference-updated\'(ref: CredentialRef): void',
     summary: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage.',
     description: 'Committed change to a provider-managed credential source: a `set`, an `unset`, or an external edit observed in storage. Ambient process-environment changes are not observable and never emit. Listener failures are contained and logged — a sync throw and an async rejection alike — without changing the committed operation\'s outcome, except `INVARIANT`-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.',
     parameters: [{ name: 'ref', description: 'the reference whose stored value changed.' }],
@@ -2797,6 +2880,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AgentStatus',
     declaration: 'export type AgentStatus = \'idle\' | \'running\';',
   },
+  {
+    name: 'ApiKeyRecord',
+    declaration: 'export interface ApiKeyRecord {\n    readonly kind: \'api-key\';\n    readonly key?: string;\n    readonly env?: Readonly<Record<string, string>>;\n}',
+  },
   {
     name: 'ApprovalOutcome',
     declaration: 'export type ApprovalOutcome = \'allowed-once\' | \'rejected\' | \'cancelled\' | \'unavailable\';',
@@ -2861,6 +2948,54 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'AttachmentId',
     declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;',
   },
+  {
+    name: 'AuthorizationEntry',
+    declaration: 'export interface AuthorizationEntry {\n    key: CredentialKey;\n    label: string;\n    methods: readonly AuthorizationMethod[];\n    inFlight: boolean;\n}',
+  },
+  {
+    name: 'AuthorizationFlow',
+    declaration: 'export interface AuthorizationFlow {\n    readonly key: CredentialKey;\n    readonly label: string;\n    readonly methods: readonly [\n        AuthorizationMethod,\n        ...AuthorizationMethod[]\n    ];\n    run(session: AuthorizationSession): Promise<void>;\n}',
+  },
+  {
+    name: 'AuthorizationInteraction',
+    declaration: 'export interface AuthorizationInteraction {\n    notify(notice: AuthorizationNotice): void;\n    prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
+  },
+  {
+    name: 'AuthorizationMethod',
+    declaration: 'export interface AuthorizationMethod {\n    id: string;\n    label: string;\n}',
+  },
+  {
+    name: 'AuthorizationNotice',
+    declaration: 'export interface AuthorizationNotice {\n    message: string;\n    url?: string;\n    code?: string;\n}',
+  },
+  {
+    name: 'AuthorizationOutcome',
+    declaration: 'export interface AuthorizationOutcome {\n    status: AuthorizationStatus;\n}',
+  },
+  {
+    name: 'AuthorizationPrompt',
+    declaration: 'export type AuthorizationPrompt = {\n    signal?: AbortSignal;\n} & ({\n    kind: \'text\';\n    message: string;\n    placeholder?: string;\n} | {\n    kind: \'secret\';\n    message: string;\n    placeholder?: string;\n} | {\n    kind: \'select\';\n    message: string;\n    options: readonly AuthorizationPromptOption[];\n});',
+  },
+  {
+    name: 'AuthorizationPromptOption',
+    declaration: 'export interface AuthorizationPromptOption {\n    id: string;\n    label: string;\n    description?: string;\n}',
+  },
+  {
+    name: 'AuthorizationRequest',
+    declaration: 'export interface AuthorizationRequest {\n    key: CredentialKey;\n    method?: string;\n    interaction: AuthorizationInteraction;\n    signal?: AbortSignal;\n}',
+  },
+  {
+    name: 'AuthorizationSession',
+    declaration: 'export interface AuthorizationSession {\n    readonly method: string;\n    readonly signal: AbortSignal;\n    notify(notice: AuthorizationNotice): void;\n    prompt(prompt: AuthorizationPrompt): Promise<string>;\n}',
+  },
+  {
+    name: 'AuthorizationSettlement',
+    declaration: 'export type AuthorizationSettlement = AuthorizationStatus | \'failed\';',
+  },
+  {
+    name: 'AuthorizationStatus',
+    declaration: 'export type AuthorizationStatus = \'authorized\' | \'cancelled\';',
+  },
   {
     name: 'BackendRegistry',
     declaration: 'export class BackendRegistry {\n    register(name: string, backend: StorageBackend): () => void;\n    get(name: string): StorageBackend;\n    names(): string[];\n}',
@@ -3069,6 +3204,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'CredentialInfo',
     declaration: 'export interface CredentialInfo {\n    configured: boolean;\n    source?: string;\n    writable: boolean;\n}',
   },
+  {
+    name: 'CredentialKey',
+    declaration: 'export type CredentialKey = Branded<\'CredentialKey\'>;',
+  },
+  {
+    name: 'CredentialRecord',
+    declaration: 'export type CredentialRecord = ApiKeyRecord | GrantRecord;',
+  },
+  {
+    name: 'CredentialRecordEntry',
+    declaration: 'export interface CredentialRecordEntry {\n    key: CredentialKey;\n    kind: CredentialRecord[\'kind\'];\n}',
+  },
+  {
+    name: 'CredentialRecordInfo',
+    declaration: 'export interface CredentialRecordInfo {\n    configured: boolean;\n    kind?: CredentialRecord[\'kind\'];\n    writable: boolean;\n}',
+  },
   {
     name: 'CredentialRef',
     declaration: 'export type CredentialRef = Branded<\'CredentialRef\'>;',
@@ -3289,6 +3440,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'GoalView',
     declaration: 'export interface GoalView extends GoalSnapshot {\n    readonly roundsStarted: number;\n    readonly createdAt: number;\n    readonly updatedAt: number;\n    readonly activation: GoalActivation;\n}',
   },
+  {
+    name: 'GrantRecord',
+    declaration: 'export interface GrantRecord {\n    readonly kind: \'grant\';\n    readonly payload: unknown;\n}',
+  },
   {
     name: 'ImageAttachmentLimits',
     declaration: 'export interface ImageAttachmentLimits {\n    maxImageBytes: number;\n    maxImagesPerMessage: number;\n    maxMessageImageBytes: number;\n    maxImagePixels: number;\n    maxImageDimension: number;\n    mediaTypes: readonly ImageMediaType[];\n}',

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: a9fb07519c1d8b3b2e2d759bd9138dac60f2306f
-README.zh.md: 9bde3583b0f5e6ce172bfe145f440150aabfa230
+README.md: 4cf11c7ffe55484fd2c27e597931f22f8f861a14
+README.zh.md: df17e296eb5b02420825b736461ed9802adf7a17

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/host/apiproxy/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/host/apiproxy/README.zh.md


+ 38 - 5
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -18,7 +18,15 @@ import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from
 import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings'
 import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
 import { CredentialProvider } from '@deepseek-ai/dsh-credentials'
-import type { CredentialInfo, CredentialRef, ResolvedCredential } from '@deepseek-ai/dsh-credentials'
+import type {
+  CredentialInfo,
+  CredentialKey,
+  CredentialRecord,
+  CredentialRecordEntry,
+  CredentialRecordInfo,
+  CredentialRef,
+  ResolvedCredential,
+} from '@deepseek-ai/dsh-credentials'
 import type { HostFrame } from '../src/api/index.ts'
 import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
 import { RpcId } from '../src/api/rpc.ts'
@@ -115,7 +123,7 @@ class MemoryCredentials extends CredentialProvider {
       return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
     }
     this.values.set(ref, value)
-    this.ctx.emit('credentials/updated', ref)
+    this.ctx.emit('credentials/reference-updated', ref)
     return Promise.resolve()
   }
 
@@ -124,7 +132,32 @@ class MemoryCredentials extends CredentialProvider {
       return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`))
     }
     this.values.delete(ref)
-    this.ctx.emit('credentials/updated', ref)
+    this.ctx.emit('credentials/reference-updated', ref)
+    return Promise.resolve()
+  }
+
+  // The record half has no wire face on this proxy, so the double answers the
+  // empty store rather than modelling storage the tests never exercise.
+  readRecord(): Promise<CredentialRecord | undefined> {
+    return Promise.resolve(undefined)
+  }
+
+  describeRecord(): Promise<CredentialRecordInfo> {
+    return Promise.resolve({ configured: false, writable: true })
+  }
+
+  listRecords(): Promise<readonly CredentialRecordEntry[]> {
+    return Promise.resolve([])
+  }
+
+  modifyRecord(
+    _key: CredentialKey,
+    mutate: (current: CredentialRecord | undefined) => Promise<CredentialRecord | undefined>,
+  ): Promise<CredentialRecord | undefined> {
+    return mutate(undefined)
+  }
+
+  deleteRecord(): Promise<void> {
     return Promise.resolve()
   }
 }
@@ -599,8 +632,8 @@ describe('credentials domain', () => {
       expectOk(await api.credentials.unset(request({ ref: 'OPENAI_API_KEY' })))
     })
     expect(frames).toEqual([
-      { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
-      { type: 'host/remote-event', event: 'credentials/updated', args: ['OPENAI_API_KEY'] },
+      { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
+      { type: 'host/remote-event', event: 'credentials/reference-updated', args: ['OPENAI_API_KEY'] },
     ])
   })
 

+ 1 - 1
packages/llm/llm-deepseek/tests/adapter.e2e.ts

@@ -72,7 +72,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
     try {
       // JSON.stringify quotes the value: YAML is a JSON superset, so a real
       // key survives whatever characters it happens to carry.
-      await writeFile(join(dir, '.credentials.yaml'), `DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
+      await writeFile(join(dir, '.credentials.yaml'), `version: 1\nrefs:\n  DEEPSEEK_API_KEY: ${JSON.stringify(key)}\n`, { mode: 0o600 })
       // Scrub the ambient variable so only the credential seam can supply the
       // key: this request proves the per-request resolution path end to end.
       vi.stubEnv('DEEPSEEK_API_KEY', '')

+ 2 - 2
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts

@@ -100,7 +100,7 @@ describe('request-level dynamic configuration', () => {
   it('routes the next request with the freshly resolved base URL and credential', async () => {
     vi.stubEnv('DEEPSEEK_API_KEY', '')
     const dir = await home()
-    await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
+    await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n  DEEPSEEK_API_KEY: first-key\n', { mode: 0o600 })
     const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
     const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
     const { ctx } = await boot(dir, { baseURL: serverA.url })
@@ -258,7 +258,7 @@ describe('request-level dynamic configuration', () => {
   it('falls back to the composition entry when settings detach', async () => {
     vi.stubEnv('DEEPSEEK_API_KEY', '')
     const dir = await home()
-    await writeFile(join(dir, '.credentials.yaml'), 'DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
+    await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n  DEEPSEEK_API_KEY: steady-key\n', { mode: 0o600 })
     const serverA = await mockServer([{ kind: 'sse', events: textEvents }])
     const serverB = await mockServer([{ kind: 'sse', events: textEvents }])
     const { ctx, settingsFiber } = await boot(dir, { baseURL: serverA.url })

+ 2 - 2
packages/llm/llm-deepseek/tests/loader-composition.spec.ts

@@ -53,7 +53,7 @@ async function loadComposition(
   const credentialsPath = join(root, '.credentials.yaml')
   if (options.withDynamic && fresh) {
     await writeFile(settingsPath, '# personal settings\n')
-    await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
+    await writeFile(credentialsPath, 'version: 1\nrefs:\n  DEEPSEEK_API_KEY: boot-key\n', { mode: 0o600 })
   }
 
   const configPath = join(root, 'cordis.yml')
@@ -124,7 +124,7 @@ describe('llm-deepseek real dynamic composition', () => {
     await vi.waitFor(() => {
       expect((ctx.get('settings')!.get(NS) as { baseURL?: string }).baseURL).toBe(serverB.url)
     }, { timeout: 5000 })
-    await writeFile(credentialsPath, 'DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
+    await writeFile(credentialsPath, 'version: 1\nrefs:\n  DEEPSEEK_API_KEY: rotated-key\n', { mode: 0o600 })
     await vi.waitFor(async () => {
       expect(await ctx.get('credentials')!.resolve(KEY_REF)).toEqual({ value: 'rotated-key', source: 'file' })
     }, { timeout: 5000 })

+ 2 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: 6d62aabf954a80120bacc9049a7498af269dd067
-README.zh.md: e7e832fa356c48bf14d7aebac7351b259b924214
+README.md: 19dbcfa90dbefbe322800c83da6d75c70c849f05
+README.zh.md: 4066564224035561fab4ecd7f64ddb7265855ae6

+ 3 - 3
packages/llm/llm-pi-ai/README.md

@@ -140,7 +140,7 @@ Most listings disclose an id and nothing else; `context_window`/`context_length`
 
 Each resolution produces one **immutable** snapshot — the profiles plus a `createModels()` collection holding the `Provider` each route built — and every operation captures a whole snapshot before its first `await`. A configuration change builds a *new* collection rather than mutating the one in use: `Models.streamSimple()` resolves its provider lazily, when the stream is first consumed, which is after the credential await, so a mutated collection would let a request that started under one configuration finish under another or fail on a provider that no longer exists. This is what makes the seam's per-step call freeze (`llm.prepareCall()`) hold end to end — switching models mid-reply takes effect on the next step, never inside the one in flight. Requests reach their provider through `Models.streamSimple()`. A catalog route that keeps its catalog protocol **reuses** the installed provider with its model list replaced, because that provider owns API implementations this package cannot reconstruct — Bedrock loads its Smithy module through a separate entry point — so rebuilding it from parts would silently narrow which providers work. Every other route is built by `createProvider()` over the protocol table behind `supportedProtocols()`, whose entries are the same factories pi-ai's own provider factories use.
 
-Credentials never enter that collection. The harness resolves a route's key through its own seam before the request reaches pi-ai and passes it as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override; `Models` therefore holds no credential store, and the harness keeps its fail-loud reference semantics. A route naming no credential resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives.
+A route's `apiKeyEnv` key still resolves through the harness seam before the request reaches pi-ai and rides as the request's `apiKey` option, which pi-ai treats as the highest-priority auth override — the fail-loud reference semantics stay the harness's. Beneath that override the collection carries this plugin's credential store and ambient auth context: a stored sign-in (an OAuth grant, or a key typed into pi-ai's own login prompt) authenticates its route through them and refreshes itself under the store's cross-process lock. The store addresses records as `llm-pi-ai/<provider id>`, and a hand-declared route key outside that record grammar (uppercase, dots, underscores) reads as holding nothing stored rather than as an addressing error; such a route cannot be signed into — a record write for it refuses with `LlmError('UNSTORABLE_PROVIDER_ID')` — and authenticates through `apiKeyEnv` or ambient provider settings. A route naming no credential at all resolves as configured-but-keyless and leaves the requirement to the protocol, which is where it actually lives.
 
 The selected model descriptor supplies the protocol implementation. This includes native API differences such as OpenAI models whose descriptor uses the Responses API rather than Chat Completions; the harness adapter does not hardcode endpoint selection by model name.
 
@@ -197,8 +197,8 @@ Recorded response content appends to the next request and does not invalidate it
 ## Known Limitations and Deferred Work
 
 - **`maxRequestImageBytes` counts base64 image payload only** — text, tools, and JSON structure ride outside the bound, so it must sit below the gateway's request-body cap with headroom. Offload is decided at request conversion as a pure function of history and configuration and is not recorded as a session event; per-route capability metadata (image count, per-image size, total request size) driving admission and assembly together is deferred design work.
-- **A provider that authenticates through OAuth alone is not offered** — pi-ai resolves OAuth from a *stored* OAuth credential, and this adapter builds its `Models` collection with no credential store and runs no login flow, so every request on such a route fails `Provider is not configured` before it goes out. The configurable-provider directory withholds them; `openai-codex` is the only one the installed catalog ships. A route a settings document already names keeps its entry so a configuration surface can edit or delete it, and `apiKeyEnv` still authenticates it with that key — which for Codex is a token that expires with nothing here to refresh it.
-- **Provider-native discovery reads the process environment only** — a route naming no credential defers to the catalog provider's own resolution, which interrogates environment variables (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, `AWS_ACCESS_KEY_ID`, and each provider's own set). It reads no local credential directory, so `~/.aws/credentials` without an exported `AWS_PROFILE` resolves as unconfigured, and a value held by the harness credential seam is invisible to it unless the process environment carries it too.
+- **A sign-in lives only in the process that started it** — an authorization attempt is not durable, so reloading the page mid-login abandons it and the human starts over. Signing out is `deleteRecord` on the stored record, which forgets it locally without telling the issuer.
+- **Provider-native discovery answers through this plugin's ambient context** — a route naming no credential defers to the catalog provider's own resolution, which asks for environment values (`AZURE_OPENAI_API_KEY`, `AWS_PROFILE`, and each provider's own set) and for local credential files. Both questions are answered here: the credential seam is consulted before the process environment, and file existence is checked against the host process's filesystem with `~` expanded. What it cannot do is *read* a credential file's contents — a provider that parses `~/.aws/credentials` itself does so directly, outside the seam.
 - **Settings can add or override routes, not remove composition routes** — the user layer merges over the composition `base`, so deleting a `cordis.yml`-provided provider is a composition change; `replace` on the namespace only resets the user layer.
 - **The layered merge has no delete for dict keys** — the settings seam merges the composition `base` and the user layer per key, recursively, so a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares cannot be removed by the user layer, only overridden — and for `reasoningEfforts` absence *is* the meaning ("not offered"), so a base-declared level stays offered. This only triggers when a `cordis.yml` entry config declares per-model reasoning fields for the same model the user layer edits; the supported posture is to leave those to the settings document (the shipped composition mounts the adapter dormant), and a `models` list is an array replacing wholesale, which is the in-band escape.
 - **`headers` can carry a credential the redactor never sees** — the profile's `headers` dict is plain strings, so `Authorization` or `api-key` set there is returned verbatim by a redacted `describe()` and rendered by any configuration UI. Store credentials as `apiKeyEnv` references; making the dict write-only is deferred with the rest of the [wire-boundary work](../llm/README.md#known-limitations-and-deferred-work).

+ 3 - 3
packages/llm/llm-pi-ai/README.zh.md

@@ -141,7 +141,7 @@ pi-ai 依据提供方 id 与 baseURL 决定每个请求的形状:系统提示
 
 每次解析产出一份**不可变**快照——profiles 加上一个持有各路由所建 `Provider` 的 `createModels()` 集合——每个操作都在自己第一个 `await` 之前整体捕获一份快照。配置变化会构造**新**集合,而不是改动正在被使用的那个:`Models.streamSimple()` 是惰性的,它在流首次被消费时才解析 provider,而那已在 credential await 之后,因此改动共享集合会让一个在旧配置下开始的请求在新配置下结束,或者撞上一个已不存在的 provider。这正是 seam 的每步调用冻结(`llm.prepareCall()`)能贯通到底的原因——回复途中切换模型会在下一步生效,绝不会影响在途的那一步。请求经 `Models.streamSimple()` 抵达提供方。保持 catalog 协议不变的 catalog 路由会**复用**已安装提供方,只替换其模型列表,因为该提供方持有本包无法重建的 API 实现——Bedrock 经由独立入口加载其 Smithy 模块——从零件重建会静默收窄可用提供方的范围。其余路由都由 `createProvider()` 基于 `supportedProtocols()` 背后的协议表构造,表中条目正是 pi-ai 自己的提供方工厂所用的同一批 factory。
 
-凭据绝不进入该集合。harness 在请求抵达 pi-ai 之前经自身 seam 解析路由密钥,并作为请求的 `apiKey` 选项传入,而 pi-ai 将其视为优先级最高的 auth 覆盖;因此 `Models` 不持有任何凭据存储,harness 也保住了自己明确失败的引用语义。没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。
+路由的 `apiKeyEnv` 密钥仍在请求抵达 pi-ai 之前经 harness 自身 seam 解析,并作为请求的 `apiKey` 选项传入——pi-ai 将其视为优先级最高的 auth 覆盖,harness 因此保住自己明确失败的引用语义。在该覆盖之下,集合携带本插件的凭据存储与 ambient auth context:已存储的登录(OAuth grant,或在 pi-ai 自己的登录提示里键入的密钥)经由它们为路由完成认证,并在存储的跨进程锁下自行刷新。存储以 `llm-pi-ai/<provider id>` 为记录地址;手写路由键若落在记录文法之外(大写、点、下划线),读取时视为「没有存储任何东西」而不是寻址错误——这样的路由无法登录(对它的记录写入会以 `LlmError('UNSTORABLE_PROVIDER_ID')` 拒绝),只能经 `apiKeyEnv` 或提供方 ambient 设置认证。完全没有点名任何凭据的路由会解析为「已配置但无密钥」,把该要求留给协议——那才是它真正所在的位置。
 
 所选模型 descriptor 提供协议实现。这包括原生 API 差异,例如 descriptor 使用 Responses API 而非 Chat Completions 的 OpenAI 模型;harness 适配器不会按模型名称硬编码端点选择。
 
@@ -198,8 +198,8 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
 ## 已知限制与暂缓事项
 
 - **`maxRequestImageBytes` 只统计 base64 图片载荷**:文本、工具与 JSON 结构不计入上限,因此该值必须低于网关请求体上限并留出余量。offload 在请求转换时决定,是历史与配置的纯函数,不记录为会话事件;由按路由能力元数据(图片数量、单图大小、请求总大小)同时驱动准入与组装的完整设计属于暂缓工作。
-- **仅以 OAuth 认证的提供方不予提供**:pi-ai 的 OAuth 只从*已存储*的 OAuth 凭据解析,而本适配器构造 `Models` 集合时不注入凭据存储、也不运行登录流程,因此这类路由的每个请求都会在发出之前以 `Provider is not configured` 失败。可配置提供方目录因此不列出它们;已安装 catalog 中只有 `openai-codex` 属于此类。settings 文档已经写过的路由仍保留目录条目,配置界面据此可以编辑或删除;`apiKeyEnv` 也仍能用该密钥完成认证——对 Codex 而言那是一个会过期、且这里没有任何环节会去刷新的 token。
-- **提供方自带的凭据发现只读进程环境**:不指定凭据的路由交由 catalog 提供方自行解析,而它探测的是环境变量(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE`、`AWS_ACCESS_KEY_ID` 以及各提供方自己的那一组)。它不读任何本地凭据目录,因此只有 `~/.aws/credentials` 而未导出 `AWS_PROFILE` 会被解析为未配置;由 harness 凭据 seam 保管的值,除非进程环境里也有,否则对它不可见。
+- **一次登录只存活于发起它的进程中**:授权尝试不可持久,登录途中刷新页面会丢弃它,人需要重来。登出即对已存储记录执行 `deleteRecord`,它只在本地遗忘而不通知签发方。
+- **提供方自带的凭据发现经由本插件的 ambient context 作答**:不指定凭据的路由交由 catalog 提供方自行解析,它会询问环境值(`AZURE_OPENAI_API_KEY`、`AWS_PROFILE` 以及各提供方自己的那一组)与本地凭据文件是否存在。两类问题都在这里作答:先查凭据 seam 再查进程环境,文件存在性则按宿主进程的文件系统判断并展开 `~`。它做不到的是*读取*凭据文件的内容——自行解析 `~/.aws/credentials` 的提供方是直接读盘的,不经过 seam。
 - **settings 能新增或覆盖路由,但不能移除组合路由**:用户层合并在组合 `base` 之上,因此删除 `cordis.yml` 提供的提供方属于组合变更;对该 namespace 执行 `replace` 只会重置用户层。
 - **分层合并对字典键没有删除语义**:settings seam 把组合 `base` 与用户层按键递归合并,因此 base 声明的某个 `reasoningEfforts` 档位、`modelOverrides` 条目或 `compat` 字段,用户层只能覆盖、无法移除——而 `reasoningEfforts` 里缺席本身*就是*语义(「不提供」),于是 base 声明过的档位会一直被提供。只有 `cordis.yml` entry config 为用户层正在编辑的同一模型声明了按模型推理字段才会触发;受支持的姿态是把这些字段留给 settings 文档(shipped 组合以 dormant 方式挂载该适配器),且 `models` 列表是数组、整体替换,这是带内的解决办法。
 - **`headers` 可能承载一条脱敏器看不见的凭据**:profile 的 `headers` 是纯字符串字典,因此设在其中的 `Authorization` 或 `api-key` 会被脱敏后的 `describe()` 原样返回,并被任何配置 UI 渲染出来。请把凭据存为 `apiKeyEnv` 引用;把该字典整体改为只写与其余[协议边界工作](../llm/README.md#known-limitations-and-deferred-work)一并暂缓。

+ 2 - 0
packages/llm/llm-pi-ai/package.json

@@ -33,6 +33,7 @@
   "license": "MIT",
   "peerDependencies": {
     "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-authorization": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
@@ -47,6 +48,7 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-attachment": "workspace:^",
+    "@deepseek-ai/dsh-authorization": "workspace:^",
     "@deepseek-ai/dsh-credentials": "workspace:^",
     "@deepseek-ai/dsh-launch-environment": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 29 - 5
packages/llm/llm-pi-ai/src/adapter.ts

@@ -13,10 +13,15 @@
  * way down: switching models mid-reply takes effect on the next step, never
  * inside the one in flight.
  *
- * Credentials stay outside that collection. The harness resolves a route's key
- * through its own seam and passes it as the request's `apiKey` option, which
- * pi-ai treats as the highest-priority auth override — so `Models` never holds
- * a credential store and the harness keeps its fail-loud reference semantics.
+ * A route naming a credential reference still resolves it through the harness
+ * seam and passes it as the request's `apiKey` option, which pi-ai treats as
+ * the highest-priority auth override — that is what keeps the fail-loud
+ * reference semantics. Everything that override does not cover reaches pi-ai
+ * through the collection's own auth: the credential store holds the records a
+ * login wrote and a refresh rotates, and the auth context answers the ambient
+ * questions a provider asks while resolving. Both are stable across snapshots,
+ * so a configuration change rebuilds the collection without forgetting who is
+ * signed in.
  *
  * @module dsh-llm-pi-ai/adapter
  */
@@ -24,6 +29,8 @@
 import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
 import type {
   Api,
+  AuthContext,
+  CredentialStore,
   Model,
   Models,
   ModelThinkingLevel,
@@ -74,6 +81,15 @@ export interface PiAiAdapterOptions {
    * `MISSING_CREDENTIAL` rather than falling back.
    */
   resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
+  /**
+   * How every collection this adapter builds resolves auth the request-level
+   * `apiKey` override does not cover. Required rather than optional: a
+   * collection built without them gets pi-ai's in-memory default store, which
+   * is empty at every boot and discarded on every configuration change, so a
+   * route whose only method is a login would report itself unconfigured on
+   * every request no matter how often the human signed in.
+   */
+  auth: PiAiAuthInjection
   /** Resolve the optional durable attachment service at request time. */
   resolveAttachments?: () => AttachmentStore | undefined
   /**
@@ -83,6 +99,14 @@ export interface PiAiAdapterOptions {
   onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void
 }
 
+/** The two auth injectables a pi-ai collection is built with. */
+export interface PiAiAuthInjection {
+  /** Durable storage for credentials pi-ai itself writes: logins, and the refreshes it runs under its own lock. */
+  credentials: CredentialStore
+  /** Ambient lookups a provider performs while resolving its own auth. */
+  authContext: AuthContext
+}
+
 /** Copy profile stream knobs into pi-ai's common option vocabulary. */
 function profileOptions(
   profile: ResolvedPiAiProviderProfile,
@@ -204,7 +228,7 @@ export class PiAiAdapter extends LlmAdapter {
   private current(): PiAiSnapshot {
     const profiles = this.config.profiles()
     if (this.snapshot?.profiles === profiles) return this.snapshot
-    const models: MutableModels = createModels()
+    const models: MutableModels = createModels(this.config.auth)
     for (const profile of profiles.values()) models.setProvider(profile.piProvider)
     this.snapshot = { profiles, models }
     return this.snapshot

+ 207 - 0
packages/llm/llm-pi-ai/src/auth.ts

@@ -0,0 +1,207 @@
+/**
+ * The three adapters between pi-ai's auth model and the harness credential
+ * plane. Every pi-ai-specific concept stays on this side of them: the harness
+ * seams they consume — `ctx.credentials` records and `ctx.authorization` flows —
+ * name nothing from this library, so another adapter family can arrive with a
+ * different auth model and share the same two seams.
+ *
+ * @module dsh-llm-pi-ai/auth
+ */
+
+import { homedir } from 'node:os'
+import { access } from 'node:fs/promises'
+import { resolve as resolvePath } from 'node:path'
+import type { AuthContext, Credential, CredentialInfo, CredentialStore } from '@earendil-works/pi-ai'
+import type { Context } from '@deepseek-ai/cordis'
+import {
+  credentialKey, credentialKeyId, credentialKeyScope, credentialRef, isCredentialKeySegment, isCredentialRefName,
+} from '@deepseek-ai/dsh-credentials'
+import type { CredentialKey, CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials'
+import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
+import { LlmError } from '@deepseek-ai/dsh-llm'
+
+/**
+ * The record scope every credential this adapter family stores is written
+ * under. It is the plugin's registered name, which is what tells a later
+ * reader — a configuration UI, or a second adapter family serving the same
+ * provider name — that this plugin owns the format inside the record.
+ */
+export const RECORD_SCOPE = 'llm-pi-ai'
+
+/**
+ * The record address for one pi-ai provider id.
+ * @param providerId - pi-ai's own provider id, which is also the harness route key.
+ * @returns the scoped credential key this adapter family reads and writes.
+ */
+export function recordKeyFor(providerId: string): CredentialKey {
+  return credentialKey(RECORD_SCOPE, providerId)
+}
+
+/**
+ * Translate a stored record into the credential pi-ai expects.
+ *
+ * An `api-key` record is structural on both sides, so it is rebuilt field by
+ * field. A `grant` payload is pi-ai's own OAuth credential, stored verbatim:
+ * the seam treats it as opaque JSON precisely so a library that owns a token
+ * format keeps owning it, refresh fields and all.
+ * @param record - the stored record, or undefined when nothing is stored.
+ * @returns the pi-ai credential, or undefined for an absent record.
+ */
+function toPiCredential(record: CredentialRecord | undefined): Credential | undefined {
+  if (record === undefined) return undefined
+  if (record.kind === 'api-key') {
+    return {
+      type: 'api_key',
+      ...record.key === undefined ? {} : { key: record.key },
+      ...record.env === undefined ? {} : { env: { ...record.env } },
+    }
+  }
+  return record.payload as Credential
+}
+
+/**
+ * Translate a pi-ai credential into the record to store.
+ * @param credential - what a login or refresh produced.
+ * @returns the record to commit, in the union the credential seam stores.
+ */
+function toRecord(credential: Credential): CredentialRecord {
+  if (credential.type === 'api_key') {
+    return {
+      kind: 'api-key',
+      ...credential.key === undefined ? {} : { key: credential.key },
+      ...credential.env === undefined ? {} : { env: { ...credential.env } },
+    }
+  }
+  return { kind: 'grant', payload: credential }
+}
+
+/**
+ * The credential service, or the failure that names what is missing. Reads
+ * answer "nothing stored" without a service, because a composition with no
+ * credential plane genuinely holds no credential; writes refuse, because a
+ * login whose grant silently evaporated would report success and then fail
+ * every request.
+ * @param ctx - the plugin context.
+ * @returns the live service.
+ * @throws {LlmError} code `NO_CREDENTIAL_STORE` when none is mounted.
+ */
+function writableStore(ctx: Context): CredentialProvider {
+  const credentials = ctx.get('credentials')
+  if (credentials === undefined) {
+    throw new LlmError(
+      'llm-pi-ai: this composition mounts no credentials service, so there is nowhere to store the'
+      + ' credential a sign-in produces; mount one (dsh-credentials-local) to sign in',
+      'NO_CREDENTIAL_STORE',
+    )
+  }
+  return credentials
+}
+
+/**
+ * A pi-ai `CredentialStore` over the harness credential records.
+ *
+ * pi-ai runs OAuth refresh *inside* `modify()`, so this store's exclusion has
+ * to cover a network round trip rather than a file rename — which is why the
+ * record write path takes a wait limit of its own rather than the short one a
+ * local write would need.
+ *
+ * pi-ai asks this store about every provider in the collection, hand-declared
+ * routes included, and a route key is an arbitrary settings dict key while a
+ * record id is not. An id outside the record grammar can never have stored a
+ * record, so reads answer "nothing stored" and a delete has nothing to remove;
+ * only `modify` refuses it, because a write that cannot land must not report
+ * that it did.
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
+ * @returns the store to hand `createModels()`.
+ */
+export function credentialStoreFrom(ctx: Context): CredentialStore {
+  return {
+    async read(providerId) {
+      const credentials = ctx.get('credentials')
+      if (credentials === undefined) return undefined
+      if (!isCredentialKeySegment(providerId)) return undefined
+      return toPiCredential(await credentials.readRecord(recordKeyFor(providerId)))
+    },
+    async list(): Promise<readonly CredentialInfo[]> {
+      const stored = await ctx.get('credentials')?.listRecords() ?? []
+      const mine: CredentialInfo[] = []
+      for (const entry of stored) {
+        // Records another plugin owns are not this collection's to report:
+        // their payloads are written in a format pi-ai never agreed to.
+        if (credentialKeyScope(entry.key) !== RECORD_SCOPE) continue
+        mine.push({
+          providerId: credentialKeyId(entry.key),
+          type: entry.kind === 'api-key' ? 'api_key' : 'oauth',
+        })
+      }
+      return mine
+    },
+    async modify(providerId, mutate) {
+      if (!isCredentialKeySegment(providerId)) {
+        throw new LlmError(
+          `llm-pi-ai: provider id "${providerId}" cannot address a stored credential record (a record id is a`
+          + ' lowercase hyphenated identifier); authenticate this route through apiKeyEnv instead of a stored'
+          + ' credential',
+          'UNSTORABLE_PROVIDER_ID',
+        )
+      }
+      const stored = await writableStore(ctx).modifyRecord(recordKeyFor(providerId), async (current) => {
+        const next = await mutate(toPiCredential(current))
+        return next === undefined ? undefined : toRecord(next)
+      })
+      return toPiCredential(stored)
+    },
+    // `async` so a missing service reaches the caller as a rejection: pi-ai's
+    // store contract is promise-returning, and a synchronous throw would
+    // escape the `ModelsError` wrapper every other storage failure gets.
+    async delete(providerId) {
+      if (!isCredentialKeySegment(providerId)) return
+      await writableStore(ctx).deleteRecord(recordKeyFor(providerId))
+    },
+  }
+}
+
+/**
+ * A pi-ai `AuthContext` over the harness credential plane and the host
+ * filesystem.
+ *
+ * `env()` answers from the credential seam first, so a value a deployment
+ * stored through the harness is found by a provider's own ambient discovery —
+ * without this, that discovery reads only the process environment and a stored
+ * `AWS_ACCESS_KEY_ID` is invisible to it. `fileExists()` answers about the host
+ * process's own filesystem rather than the workspace `ctx.fs` seam, because the
+ * paths it is asked about (`~/.aws/credentials`, application-default
+ * credentials) are facts about where this process runs, not about the project
+ * under edit.
+ * @param ctx - the plugin context carrying the optional `ctx.credentials`.
+ * @returns the auth context to hand `createModels()`.
+ */
+export function authContextFrom(ctx: Context): AuthContext {
+  return {
+    async env(name) {
+      // pi-ai asks about arbitrary provider-declared names; one that is not a
+      // POSIX identifier can never have been stored as a reference, and asking
+      // the seam would throw instead of answering "not set".
+      if (isCredentialRefName(name)) {
+        const credentials = ctx.get('credentials')
+        const hit = await credentials?.resolve(credentialRef(name))
+        if (hit !== undefined) return hit.value
+      }
+      return launchEnvironmentOf(ctx).get(name)?.value
+    },
+    async fileExists(path) {
+      const expanded = path.startsWith('~/') || path === '~'
+        ? resolvePath(homedir(), path.slice(1).replace(/^\//, ''))
+        : path
+      try {
+        await access(expanded)
+        return true
+      } catch {
+        // Absent, unreadable, or a broken symlink — every one of which means
+        // this ambient credential source cannot be used, which is the only
+        // distinction the caller makes.
+        return false
+      }
+    },
+  }
+}

+ 0 - 20
packages/llm/llm-pi-ai/src/catalog.ts

@@ -176,26 +176,6 @@ export function catalogProviderIds(): readonly string[] {
   return getBuiltinProviders()
 }
 
-/**
- * Whether the installed catalog provider for one route declares an api-key
- * method — the only authentication this adapter obtains on its own.
- *
- * A key is what the harness resolves through its own credential seam and hands
- * pi-ai per request. pi-ai's other method, OAuth, resolves from a *stored*
- * OAuth credential alone: `resolveProviderAuth` has no ambient path for it,
- * this adapter builds its `Models` collection with no credential store, and
- * nothing here runs a login flow. So a provider offering OAuth by itself
- * leaves nothing for this adapter to authenticate with, and the posture such a
- * provider invites — no key configured, credentials discovered by the provider
- * — fails every request with `Provider is not configured`.
- * @param provider - provider route key.
- * @returns whether the catalog provider takes an api key; false for a route
- *   pi-ai does not ship, which the caller answers for separately.
- */
-export function catalogProviderTakesApiKey(provider: string): boolean {
-  return catalogProvider(provider)?.auth.apiKey !== undefined
-}
-
 /**
  * The installed catalog models for one route, indexed by model id.
  * @param provider - provider route key.

+ 20 - 18
packages/llm/llm-pi-ai/src/index.ts

@@ -61,10 +61,12 @@ import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
 import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
 import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
 import { PiAiAdapter } from './adapter.ts'
-import { catalogProviderIds, catalogProviderTakesApiKey } from './catalog.ts'
+import { authContextFrom, credentialStoreFrom } from './auth.ts'
+import { catalogProviderIds } from './catalog.ts'
 import { assertServiceable, Config, resolveProfiles } from './config.ts'
 import type { ResolvedPiAiProviderProfile } from './config.ts'
 import { discoverModels } from './discovery.ts'
+import { registerPiAiFlows } from './login.ts'
 
 export { PiAiAdapter } from './adapter.ts'
 export type { PiAiAdapterOptions } from './adapter.ts'
@@ -79,6 +81,7 @@ export type {
   PiAiThinkingFormat,
   ResolvedPiAiProviderProfile,
 } from './config.ts'
+export { recordKeyFor } from './auth.ts'
 export { supportedProtocols } from './provider.ts'
 
 export const name = 'llm-pi-ai'
@@ -105,15 +108,10 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
 }
 
 /**
- * The configurable-provider directory: every installed catalog route this
- * adapter can authenticate, plus every route the current profiles declare. A
- * hand-declared route has no catalog entry, so without this union it would
- * have no settings address and configuration surfaces could neither show nor
- * edit it.
- *
- * The profile half is unconditional, which is what keeps a route already
- * stored against a withheld provider editable and deletable rather than
- * stranded in the settings document with nothing on the page to remove it.
+ * The configurable-provider directory: every installed catalog route, plus
+ * every route the current profiles declare. A hand-declared route has no
+ * catalog entry, so without this union it would have no settings address and
+ * configuration surfaces could neither show nor edit it.
  * @param profiles - the currently resolved provider profiles.
  * @returns the directory entries in catalog order, declared routes last.
  */
@@ -134,14 +132,7 @@ function directoryEntries(
       declared: !catalog.has(provider),
     })
   }
-  // A provider whose only native method is OAuth leaves this adapter nothing
-  // to authenticate with, so offering it would put a card on the settings page
-  // whose own posture — no key, credentials discovered by the provider — fails
-  // every request. Catalog *membership* is unaffected, so `declare` above still
-  // answers what pi-ai ships.
-  for (const provider of catalog) {
-    if (catalogProviderTakesApiKey(provider)) declare(provider, provider)
-  }
+  for (const provider of catalog) declare(provider, provider)
   for (const [provider, profile] of profiles) declare(provider, profile.displayName)
   return [...entries.values()]
 }
@@ -197,9 +188,14 @@ export function apply(ctx: Context, config: Config): void {
     )
   }
 
+  // One store and one ambient context for the whole plugin instance: both read
+  // through `ctx` per call, so they stay correct across the collection rebuilds
+  // a configuration change causes, and a sign-in survives one.
+  const auth = { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }
   const adapter = new PiAiAdapter({
     profiles,
     resolveApiKey,
+    auth,
     resolveAttachments: () => ctx.get('attachments'),
     onReplayDegrade: ({ provider, model, reason }) => {
       ctx.logger.warn(
@@ -208,6 +204,12 @@ export function apply(ctx: Context, config: Config): void {
       )
     },
   })
+  // Independent of the route set: signing in is what makes a route worth
+  // adding, so the flows are offered before any profile names their provider.
+  // Scoped to the authorization seam rather than injected outright, because a
+  // composition without it (headless, ACP) simply has no surface to sign in
+  // from, while everything else this plugin does still works.
+  ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) })
   // The full installed catalog is configurable from the moment the plugin
   // mounts — dormant or not — so configuration surfaces can offer every
   // pi-ai provider before any route exists. Hand-declared routes join it as

+ 161 - 0
packages/llm/llm-pi-ai/src/login.ts

@@ -0,0 +1,161 @@
+/**
+ * Authorization flows for the pi-ai providers that ship a login. This is the
+ * whole of the translation between the harness's neutral notice/prompt
+ * vocabulary and pi-ai's `AuthInteraction`; nothing above it knows which
+ * library ran the conversation.
+ *
+ * @module dsh-llm-pi-ai/login
+ */
+
+import { createModels } from '@earendil-works/pi-ai'
+import type { AuthEvent, AuthPrompt, AuthType, Provider } from '@earendil-works/pi-ai'
+import type { Context } from '@deepseek-ai/cordis'
+import type { AuthorizationMethod, AuthorizationPrompt, AuthorizationSession } from '@deepseek-ai/dsh-authorization'
+import { isCredentialKeySegment } from '@deepseek-ai/dsh-credentials'
+import { catalogProvider, catalogProviderIds } from './catalog.ts'
+import { recordKeyFor } from './auth.ts'
+import type { PiAiAuthInjection } from './adapter.ts'
+
+/**
+ * The login methods one catalog provider offers.
+ *
+ * A method appears only when pi-ai can actually run it: `oauth` always carries
+ * a `login`, while an api-key method has one only when the provider collects
+ * its key interactively — which every installed one currently does, so a key is
+ * typed into pi-ai's own prompt rather than into the settings form.
+ * @param provider - the installed catalog provider, if pi-ai ships one.
+ * @returns its methods, most preferred first; empty when it offers no login.
+ */
+function loginMethods(provider: Provider | undefined): AuthorizationMethod[] {
+  const methods: AuthorizationMethod[] = []
+  const oauth = provider?.auth.oauth
+  if (oauth !== undefined) methods.push({ id: 'oauth', label: oauth.loginLabel ?? oauth.name })
+  const apiKey = provider?.auth.apiKey
+  if (apiKey?.login !== undefined) methods.push({ id: 'api-key', label: apiKey.name })
+  return methods
+}
+
+/**
+ * Restate one pi-ai login event in the seam's vocabulary.
+ *
+ * A device-code grant is the one event carrying two things the human needs at
+ * once — where to go and what to type there — which is why the neutral notice
+ * has a `code` beside its `url` rather than folding the code into the message.
+ * @param event - what pi-ai reported.
+ * @param session - the attempt to report it to.
+ */
+function relay(event: AuthEvent, session: AuthorizationSession): void {
+  switch (event.type) {
+    case 'info': {
+      const link = event.links?.[0]
+      session.notify({ message: event.message, ...link === undefined ? {} : { url: link.url } })
+      return
+    }
+    case 'auth_url':
+      session.notify({
+        message: event.instructions ?? 'Open this page to continue signing in.',
+        url: event.url,
+      })
+      return
+    case 'device_code':
+      session.notify({
+        message: 'Enter this code on the verification page to finish signing in.',
+        url: event.verificationUri,
+        code: event.userCode,
+      })
+      return
+    case 'progress':
+      session.notify({ message: event.message })
+      return
+    default:
+      // pi-ai's event union is open to new members: a build that meets one it
+      // does not know still shows the human that something is happening rather
+      // than going silent mid-login.
+      session.notify({ message: 'Signing in…' })
+  }
+}
+
+/**
+ * Restate one pi-ai prompt in the seam's vocabulary.
+ *
+ * `manual_code` becomes a plain text question because the difference pi-ai
+ * draws — a code the human copies from a browser rather than a value they know
+ * — changes nothing a surface renders. Its own `signal` is carried through, and
+ * that is the part which matters: it is how a flow racing a typed code against
+ * a browser callback withdraws the losing question.
+ * @param prompt - what pi-ai asked.
+ * @returns the neutral prompt to put to the human.
+ */
+function restate(prompt: AuthPrompt): AuthorizationPrompt {
+  const signal = prompt.signal === undefined ? {} : { signal: prompt.signal }
+  switch (prompt.type) {
+    case 'select':
+      return { ...signal, kind: 'select', message: prompt.message, options: prompt.options }
+    case 'secret':
+      return {
+        ...signal,
+        kind: 'secret',
+        message: prompt.message,
+        ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
+      }
+    default:
+      return {
+        ...signal,
+        kind: 'text',
+        message: prompt.message,
+        ...prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder },
+      }
+  }
+}
+
+/**
+ * Register one authorization flow per installed provider that ships a login.
+ *
+ * Registration is unconditional on configuration: a provider has to be signed
+ * into before a route for it is worth adding, so the flow exists from the
+ * moment the plugin mounts rather than appearing once a profile does.
+ * @param ctx - the plugin context carrying `ctx.authorization`.
+ * @param auth - the injectables every collection here is built with.
+ */
+export function registerPiAiFlows(ctx: Context, auth: PiAiAuthInjection): void {
+  for (const providerId of catalogProviderIds()) {
+    const provider = catalogProvider(providerId)
+    const [first, ...rest] = loginMethods(provider)
+    /* v8 ignore next 3 -- every id here names an installed provider and every
+       installed provider ships a login, so nothing is skipped today; the guard
+       is what keeps that from becoming a crash if either stops being true. */
+    if (provider === undefined || first === undefined) continue
+    /* v8 ignore next 7 -- every installed catalog id today is a lowercase
+       hyphenated identifier; the guard keeps a future upstream id outside the
+       record grammar (dotted or uppercase, as vendor ids elsewhere already
+       are) from throwing in `recordKeyFor` and failing the whole mount. */
+    if (!isCredentialKeySegment(providerId)) {
+      ctx.logger.warn(
+        'llm-pi-ai: catalog provider "%s" cannot address a credential record; its sign-in is not offered',
+        providerId)
+      continue
+    }
+    ctx.authorization.registerFlow({
+      key: recordKeyFor(providerId),
+      label: provider.name,
+      methods: [first, ...rest],
+      async run(session) {
+        // A collection of its own, holding only the provider being signed
+        // into: login is not serving requests, and the credential it produces
+        // lands in the shared store either way.
+        const models = createModels(auth)
+        models.setProvider(provider)
+        // Total over the two ids declared above, and the seam only ever hands
+        // back one a flow declared.
+        const type: AuthType = session.method === 'oauth' ? 'oauth' : 'api_key'
+        // pi-ai persists what the login returns through that same store, which
+        // is what makes it the single writer of this record.
+        await models.login(providerId, type, {
+          signal: session.signal,
+          notify: (event) => { relay(event, session) },
+          prompt: prompt => session.prompt(restate(prompt)),
+        })
+      },
+    })
+  }
+}

+ 2 - 0
packages/llm/llm-pi-ai/tests/adapter.spec.ts

@@ -13,6 +13,7 @@ import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
 import { DEFAULT_MAX_REQUEST_IMAGE_BYTES, resolveProfiles } from '../src/config.ts'
+import { memoryAuth } from './auth-double.ts'
 import { assemble } from './assemble.ts'
 import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 
@@ -47,6 +48,7 @@ function adapterOf(
   return new PiAiAdapter({
     profiles: () => resolveProfiles(providers),
     resolveApiKey: () => Promise.resolve(apiKey),
+    auth: memoryAuth(),
   })
 }
 

+ 39 - 0
packages/llm/llm-pi-ai/tests/auth-double.ts

@@ -0,0 +1,39 @@
+import type { Credential } from '@earendil-works/pi-ai'
+import type { PiAiAuthInjection } from '../src/adapter.ts'
+
+/**
+ * The auth injectables for tests that exercise streaming rather than
+ * authentication: an in-process credential store and an ambient context that
+ * finds nothing. A test needing real records builds the store over
+ * `ctx.credentials` instead, through `credentialStoreFrom`.
+ * @param seed - credentials to start with, by pi-ai provider id.
+ * @returns the injection to hand `PiAiAdapter`, with its store readable.
+ */
+export function memoryAuth(seed: Record<string, Credential> = {}): PiAiAuthInjection & {
+  stored: Map<string, Credential>
+} {
+  const stored = new Map(Object.entries(seed))
+  return {
+    stored,
+    credentials: {
+      read: id => Promise.resolve(stored.get(id)),
+      list: () => Promise.resolve([...stored].map(([providerId, credential]) => ({
+        providerId,
+        type: credential.type,
+      }))),
+      async modify(id, mutate) {
+        const next = await mutate(stored.get(id))
+        if (next !== undefined) stored.set(id, next)
+        return stored.get(id)
+      },
+      delete: (id) => {
+        stored.delete(id)
+        return Promise.resolve()
+      },
+    },
+    authContext: {
+      env: () => Promise.resolve(undefined),
+      fileExists: () => Promise.resolve(false),
+    },
+  }
+}

+ 172 - 0
packages/llm/llm-pi-ai/tests/auth.spec.ts

@@ -0,0 +1,172 @@
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import LocalCredentialProvider from '@deepseek-ai/dsh-credentials-local'
+import { credentialKey, credentialRef } from '@deepseek-ai/dsh-credentials'
+import { authContextFrom, credentialStoreFrom, recordKeyFor } from '../src/auth.ts'
+
+const CODEX = recordKeyFor('openai-codex')
+
+const dirs: string[] = []
+
+/** A context whose credential records live in a throwaway `$DSH_HOME`. */
+async function stored(): Promise<Context> {
+  const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-auth-'))
+  dirs.push(dir)
+  const ctx = new Context()
+  await ctx.plugin(LocalCredentialProvider, { path: join(dir, '.credentials.yaml'), watch: false })
+  return ctx
+}
+
+afterEach(async () => {
+  vi.unstubAllEnvs()
+  await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
+})
+
+describe('pi-ai credential store over harness records', () => {
+  it('reads nothing for a provider with no record', async () => {
+    const store = credentialStoreFrom(await stored())
+
+    await expect(store.read('openai-codex')).resolves.toBeUndefined()
+  })
+
+  it('round-trips an api-key credential field by field', async () => {
+    const ctx = await stored()
+    const store = credentialStoreFrom(ctx)
+
+    await store.modify('cloudflare', () =>
+      Promise.resolve({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } }))
+
+    await expect(store.read('cloudflare'))
+      .resolves.toEqual({ type: 'api_key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })
+    await expect(ctx.credentials.readRecord(recordKeyFor('cloudflare')))
+      .resolves.toEqual({ kind: 'api-key', key: 'sk-live', env: { ACCOUNT_ID: 'acct-1' } })
+  })
+
+  it('stores an api-key credential carrying neither a key nor env', async () => {
+    const store = credentialStoreFrom(await stored())
+
+    await store.modify('bedrock', () => Promise.resolve({ type: 'api_key' }))
+
+    await expect(store.read('bedrock')).resolves.toEqual({ type: 'api_key' })
+  })
+
+  it('keeps an OAuth credential verbatim, refresh fields and all', async () => {
+    const ctx = await stored()
+    const store = credentialStoreFrom(ctx)
+    const granted = { type: 'oauth' as const, access: 'at', refresh: 'rt', expires: 42, accountId: 'acc' }
+
+    await store.modify('openai-codex', () => Promise.resolve(granted))
+
+    await expect(store.read('openai-codex')).resolves.toEqual(granted)
+    await expect(ctx.credentials.readRecord(CODEX)).resolves.toEqual({ kind: 'grant', payload: granted })
+  })
+
+  it('shows the mutation the current credential and leaves it alone when declined', async () => {
+    const store = credentialStoreFrom(await stored())
+    await store.modify('openai-codex', () =>
+      Promise.resolve({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }))
+    const seen: unknown[] = []
+
+    const unchanged = await store.modify('openai-codex', (current) => {
+      seen.push(current)
+      return Promise.resolve(undefined)
+    })
+
+    expect(seen).toEqual([{ type: 'oauth', access: 'first', refresh: 'r', expires: 1 }])
+    expect(unchanged).toEqual({ type: 'oauth', access: 'first', refresh: 'r', expires: 1 })
+  })
+
+  it('lists only the records this adapter family owns', async () => {
+    const ctx = await stored()
+    const store = credentialStoreFrom(ctx)
+    await store.modify('openai-codex', () =>
+      Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }))
+    await store.modify('cloudflare', () => Promise.resolve({ type: 'api_key', key: 'k' }))
+    // Another plugin's record for a provider name this one also serves: its
+    // payload is written in a format pi-ai never agreed to.
+    await ctx.credentials.modifyRecord(credentialKey('llm-kimi', 'openai-codex'), () =>
+      Promise.resolve({ kind: 'grant', payload: { theirs: true } }))
+
+    await expect(store.list()).resolves.toEqual([
+      { providerId: 'openai-codex', type: 'oauth' },
+      { providerId: 'cloudflare', type: 'api_key' },
+    ])
+  })
+
+  it('forgets a credential on delete, and stays quiet when there was none', async () => {
+    const store = credentialStoreFrom(await stored())
+    await store.modify('openai-codex', () =>
+      Promise.resolve({ type: 'oauth', access: 'at', refresh: 'rt', expires: 1 }))
+
+    await store.delete('openai-codex')
+    await store.delete('openai-codex')
+
+    await expect(store.read('openai-codex')).resolves.toBeUndefined()
+  })
+
+  it('reads empty but refuses to write without a credentials service', async () => {
+    const store = credentialStoreFrom(new Context())
+
+    await expect(store.read('openai-codex')).resolves.toBeUndefined()
+    await expect(store.list()).resolves.toEqual([])
+    await expect(store.modify('openai-codex', () => Promise.resolve({ type: 'api_key', key: 'k' })))
+      .rejects.toThrow(/mounts no credentials service/)
+    await expect(store.delete('openai-codex')).rejects.toThrow(/mounts no credentials service/)
+  })
+
+  it('treats a provider id outside the record grammar as holding nothing', async () => {
+    const store = credentialStoreFrom(await stored())
+
+    // A hand-declared route key is an arbitrary settings dict key, and pi-ai
+    // reads it during auth resolution: the answer is "not signed in", never a
+    // thrown address error…
+    await expect(store.read('My_Proxy')).resolves.toBeUndefined()
+    // …nothing can ever be stored under it, so a logout has nothing to remove…
+    await expect(store.delete('My_Proxy')).resolves.toBeUndefined()
+    // …while a write that cannot land must refuse rather than report success.
+    await expect(store.modify('My_Proxy', () => Promise.resolve({ type: 'api_key', key: 'k' })))
+      .rejects.toThrow(/cannot address a stored credential record/)
+  })
+})
+
+describe('pi-ai ambient auth context', () => {
+  beforeEach(() => {
+    vi.stubEnv('PI_AUTH_AMBIENT', 'from-environment')
+  })
+
+  it('answers an environment name from the credential seam first', async () => {
+    const ctx = await stored()
+    await ctx.credentials.set(credentialRef('PI_AUTH_SEAM'), 'from-seam')
+
+    await expect(authContextFrom(ctx).env('PI_AUTH_SEAM')).resolves.toBe('from-seam')
+  })
+
+  it('falls back to the launch environment when nothing is stored', async () => {
+    await expect(authContextFrom(await stored()).env('PI_AUTH_AMBIENT')).resolves.toBe('from-environment')
+  })
+
+  it('answers "not set" for a name no reference could ever address', async () => {
+    // pi-ai asks about provider-declared names; one outside the reference
+    // grammar has no reference to miss, and must not throw.
+    await expect(authContextFrom(await stored()).env('not a var')).resolves.toBeUndefined()
+  })
+
+  it('answers about the host filesystem, expanding a leading ~', async () => {
+    const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-home-'))
+    dirs.push(dir)
+    await writeFile(join(dir, 'creds'), 'x')
+    // Both spellings of "home": os.homedir() reads HOME on POSIX and
+    // USERPROFILE on Windows, and the expansion under test goes through it.
+    vi.stubEnv('HOME', dir)
+    vi.stubEnv('USERPROFILE', dir)
+    const context = authContextFrom(await stored())
+
+    await expect(context.fileExists('~/creds')).resolves.toBe(true)
+    await expect(context.fileExists('~/missing')).resolves.toBe(false)
+    await expect(context.fileExists(join(dir, 'creds'))).resolves.toBe(true)
+    await expect(context.fileExists('~')).resolves.toBe(true)
+  })
+})

Неке датотеке нису приказане због велике количине промена