dsh-env-manager-plugin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -2
  3. package/client.js +2581 -0
  4. package/cordis.patch.yml +4 -0
  5. package/docs/00-project/current-state.md +246 -0
  6. package/docs/00-project/glossary.md +215 -0
  7. package/docs/00-project/project-brief.md +114 -0
  8. package/docs/00-project/roadmap.md +89 -0
  9. package/docs/01-architecture/DSH_Environment_Service_Memory_Skill_Brainstorm.md +493 -0
  10. package/docs/01-architecture/api-verification.md +416 -0
  11. package/docs/01-architecture/architecture-overview.md +222 -0
  12. package/docs/01-architecture/data-model.md +930 -0
  13. package/docs/01-architecture/decisions/ADR-0001-plugin-as-bundle.md +29 -0
  14. package/docs/01-architecture/decisions/ADR-0002-file-based-storage.md +25 -0
  15. package/docs/01-architecture/decisions/ADR-0003-skill-router-over-search.md +25 -0
  16. package/docs/01-architecture/decisions/ADR-0004-typescript-host-js-client.md +31 -0
  17. package/docs/01-architecture/decisions/ADR-0005-layered-rule-resolution.md +36 -0
  18. package/docs/01-architecture/decisions/ADR-0006-credentials-as-references.md +30 -0
  19. package/docs/01-architecture/decisions/ADR-0007-session-scoped-default-environment.md +28 -0
  20. package/docs/01-architecture/decisions/ADR-0008-memory-bridge-not-second-store.md +31 -0
  21. package/docs/01-architecture/decisions/ADR-0009-client-host-http-transport.md +87 -0
  22. package/docs/01-architecture/decisions/ADR-0010-environment-plane-model.md +87 -0
  23. package/docs/01-architecture/decisions/ADR-0011-k8s-node-execution-model.md +92 -0
  24. package/docs/01-architecture/design.md +666 -0
  25. package/docs/01-architecture/detailed-design.md +961 -0
  26. package/docs/01-architecture/implementation-plan.md +323 -0
  27. package/docs/01-architecture/module-boundaries.md +306 -0
  28. package/docs/01-architecture/security-model.md +227 -0
  29. package/docs/01-architecture/storage-contract.md +224 -0
  30. package/docs/01-architecture/tool-contract.md +438 -0
  31. package/docs/01-architecture/ui-design.md +587 -0
  32. package/docs/02-harness/agent-behavior.md +36 -0
  33. package/docs/02-harness/change-snapshot-policy.md +29 -0
  34. package/docs/02-harness/client-ui-integration.md +110 -0
  35. package/docs/02-harness/context-policy.md +43 -0
  36. package/docs/02-harness/harness-standard.md +91 -0
  37. package/docs/02-harness/task-execution-policy.md +26 -0
  38. package/docs/02-harness/verification-policy.md +97 -0
  39. package/docs/03-openspec/archive-rule.md +25 -0
  40. package/docs/03-openspec/bugfix-rule.md +30 -0
  41. package/docs/03-openspec/design-rule.md +33 -0
  42. package/docs/03-openspec/openspec-usage-guide.md +31 -0
  43. package/docs/03-openspec/proposal-rule.md +70 -0
  44. package/docs/03-openspec/spec-rule.md +33 -0
  45. package/docs/03-openspec/tasks-rule.md +31 -0
  46. package/docs/04-development/coding-style.md +67 -0
  47. package/docs/04-development/dependency-policy.md +43 -0
  48. package/docs/04-development/development-guide.md +85 -0
  49. package/docs/04-development/directory-convention.md +105 -0
  50. package/docs/04-development/local-run-guide.md +55 -0
  51. package/docs/04-development/testing-guide.md +102 -0
  52. package/docs/05-domain/deployment-template.md +76 -0
  53. package/docs/05-domain/environment-template.md +165 -0
  54. package/docs/05-domain/operation-binding-template.md +55 -0
  55. package/docs/05-domain/operation-record-template.md +55 -0
  56. package/docs/05-domain/prompt-template.md +90 -0
  57. package/docs/05-domain/rule-template.md +73 -0
  58. package/docs/05-domain/service-template.md +39 -0
  59. package/docs/06-operations/deployment-guide.md +54 -0
  60. package/docs/06-operations/release.md +188 -0
  61. package/docs/06-operations/security-policy.md +53 -0
  62. package/docs/06-operations/troubleshooting.md +91 -0
  63. package/docs/07-templates/adr-template.md +21 -0
  64. package/docs/07-templates/bugfix-template.md +11 -0
  65. package/docs/07-templates/case-template.md +15 -0
  66. package/docs/07-templates/design-template.md +29 -0
  67. package/docs/07-templates/proposal-template.md +19 -0
  68. package/docs/07-templates/spec-template.md +15 -0
  69. package/docs/07-templates/tasks-template.md +12 -0
  70. package/docs/99-archive/README.md +13 -0
  71. package/docs/README.md +142 -0
  72. package/icon.svg +4 -0
  73. package/index.js +8 -0
  74. package/lib/app/app.d.ts +67 -0
  75. package/lib/app/app.js +494 -0
  76. package/lib/app/app.js.map +1 -0
  77. package/lib/config.d.ts +55 -0
  78. package/lib/config.js +42 -0
  79. package/lib/config.js.map +1 -0
  80. package/lib/domain/constants.d.ts +21 -0
  81. package/lib/domain/constants.js +59 -0
  82. package/lib/domain/constants.js.map +1 -0
  83. package/lib/domain/errors.d.ts +20 -0
  84. package/lib/domain/errors.js +22 -0
  85. package/lib/domain/errors.js.map +1 -0
  86. package/lib/domain/schemas.d.ts +28 -0
  87. package/lib/domain/schemas.js +445 -0
  88. package/lib/domain/schemas.js.map +1 -0
  89. package/lib/domain/types.d.ts +412 -0
  90. package/lib/domain/types.js +2 -0
  91. package/lib/domain/types.js.map +1 -0
  92. package/lib/index.d.ts +18 -0
  93. package/lib/index.js +89 -0
  94. package/lib/index.js.map +1 -0
  95. package/lib/logger.d.ts +8 -0
  96. package/lib/logger.js +13 -0
  97. package/lib/logger.js.map +1 -0
  98. package/lib/memory/bridge.d.ts +18 -0
  99. package/lib/memory/bridge.js +13 -0
  100. package/lib/memory/bridge.js.map +1 -0
  101. package/lib/registry/integrity.d.ts +10 -0
  102. package/lib/registry/integrity.js +204 -0
  103. package/lib/registry/integrity.js.map +1 -0
  104. package/lib/registry/registry.d.ts +14 -0
  105. package/lib/registry/registry.js +182 -0
  106. package/lib/registry/registry.js.map +1 -0
  107. package/lib/resolver/merge.d.ts +23 -0
  108. package/lib/resolver/merge.js +90 -0
  109. package/lib/resolver/merge.js.map +1 -0
  110. package/lib/resolver/resolve.d.ts +18 -0
  111. package/lib/resolver/resolve.js +288 -0
  112. package/lib/resolver/resolve.js.map +1 -0
  113. package/lib/resolver/template.d.ts +10 -0
  114. package/lib/resolver/template.js +42 -0
  115. package/lib/resolver/template.js.map +1 -0
  116. package/lib/router/params.d.ts +3 -0
  117. package/lib/router/params.js +83 -0
  118. package/lib/router/params.js.map +1 -0
  119. package/lib/router/router.d.ts +11 -0
  120. package/lib/router/router.js +204 -0
  121. package/lib/router/router.js.map +1 -0
  122. package/lib/session/session.d.ts +16 -0
  123. package/lib/session/session.js +64 -0
  124. package/lib/session/session.js.map +1 -0
  125. package/lib/skill.d.ts +17 -0
  126. package/lib/skill.js +56 -0
  127. package/lib/skill.js.map +1 -0
  128. package/lib/storage/atomic.d.ts +10 -0
  129. package/lib/storage/atomic.js +107 -0
  130. package/lib/storage/atomic.js.map +1 -0
  131. package/lib/storage/paths.d.ts +10 -0
  132. package/lib/storage/paths.js +50 -0
  133. package/lib/storage/paths.js.map +1 -0
  134. package/lib/storage/records.d.ts +16 -0
  135. package/lib/storage/records.js +112 -0
  136. package/lib/storage/records.js.map +1 -0
  137. package/lib/storage/sessions.d.ts +17 -0
  138. package/lib/storage/sessions.js +65 -0
  139. package/lib/storage/sessions.js.map +1 -0
  140. package/lib/tools/tools.d.ts +14 -0
  141. package/lib/tools/tools.js +472 -0
  142. package/lib/tools/tools.js.map +1 -0
  143. package/lib/transport/http.d.ts +3 -0
  144. package/lib/transport/http.js +217 -0
  145. package/lib/transport/http.js.map +1 -0
  146. package/locale/en.json +6 -0
  147. package/locale/zh.json +6 -0
  148. package/package.json +97 -4
@@ -0,0 +1,961 @@
1
+ # Detailed Design (V1)
2
+
3
+ > Status: normative implementation design for V1. It refines
4
+ > [`design.md`](design.md) into module-level contracts and depends on the verified
5
+ > runtime facts in [`api-verification.md`](api-verification.md).
6
+ > Record shapes are defined in [`data-model.md`](data-model.md); do not restate or
7
+ > extend them here without updating that document.
8
+
9
+ ## 1. Scope
10
+
11
+ This document specifies:
12
+
13
+ 1. the plugin configuration and its effective defaults;
14
+ 2. every Host module's public interface and internal algorithm;
15
+ 3. the exact `defineTool` contract for all ten tools;
16
+ 4. the plugin-owned HTTP API used by the Client panel;
17
+ 5. error mapping, limits, concurrency and test obligations.
18
+
19
+ UI screens and copy are in [`ui-design.md`](ui-design.md); build and packaging are in
20
+ [`design.md`](design.md) §15 and [`implementation-plan.md`](implementation-plan.md).
21
+
22
+ ## 2. Conventions
23
+
24
+ - Host: TypeScript, ESM, `strict`.
25
+ - Runtime validation: Zod. Tool parameter validation: DSH's schema DSL (F3).
26
+ - Files: YAML 1.2 via the `yaml` package; no anchors/aliases are emitted.
27
+ - Time: ISO-8601 UTC with milliseconds, produced by `new Date().toISOString()`.
28
+ - Paths: POSIX separators inside resolved values; OS-native paths only at the
29
+ filesystem boundary.
30
+ - All expected failures are `EnvManagerError extends HarnessError` (F5) with a code
31
+ from `data-model.md` §11.
32
+
33
+ ## 3. Configuration
34
+
35
+ ```ts
36
+ // src/config.ts
37
+ import z from '@deepseek-ai/schemastery'
38
+
39
+ export const Config = z.object({
40
+ dataDir: z.string().description('Registry directory; relative paths resolve against the profile directory.'),
41
+ maxRecords: z.natural().default(2000),
42
+ registerSkill: z.boolean().default(true),
43
+ writePolicy: z.object({
44
+ allowUiProductionWrites: z.boolean().default(false),
45
+ requireApprovalForDelete: z.boolean().default(true),
46
+ requireApprovalForProductionSave: z.boolean().default(true),
47
+ }).default({}),
48
+ defaults: z.object({
49
+ environmentId: z.string().default(''),
50
+ }).default({}),
51
+ })
52
+ ```
53
+
54
+ | Field | Default | Meaning |
55
+ |---|---|---|
56
+ | `dataDir` | profile data dir | Registry root; see §4.1 |
57
+ | `maxRecords` | 2000 | hard cap per record kind; exceeding it fails a write |
58
+ | `registerSkill` | true | register the profile-wide `dsh-env-manager` discovery Skill |
59
+ | `writePolicy.allowUiProductionWrites` | false | allow the HTTP API to write production records |
60
+ | `writePolicy.requireApprovalForDelete` | true | `env_delete` asks the human first |
61
+ | `writePolicy.requireApprovalForProductionSave` | true | `env_save` on a production environment asks first |
62
+ | `defaults.environmentId` | empty | fallback when neither an argument nor the session default exists |
63
+
64
+ Rules:
65
+
66
+ - The config schema is schemastery (F1/F18), not Zod. Do not add a second config path.
67
+ - Config is read-only at runtime; there is no write API. The Client shows effective
68
+ values only.
69
+ - A blank `dataDir` means "use the resolution order in §4.1", not "current directory".
70
+
71
+ ## 4. Storage
72
+
73
+ ### 4.1 Data directory resolution
74
+
75
+ ```text
76
+ 1. config.dataDir, if set:
77
+ absolute -> use as-is
78
+ relative -> path.resolve(profileDir, config.dataDir)
79
+ 2. profileDir from ctx.get('profileContext')?.dir
80
+ -> <profileDir>/data/dsh-env-manager
81
+ 3. process.env.DSH_PROFILE_DIR
82
+ -> <DSH_PROFILE_DIR>/data/dsh-env-manager
83
+ 4. process.cwd()/data/dsh-env-manager
84
+ ```
85
+
86
+ `profileDir` may be unavailable in an embedded host; step 3 and 4 keep the plugin
87
+ usable. The resolved absolute path is memoized for the process lifetime, logged once,
88
+ and reported by `env_get_context` and the UI overview. It is never derived from model
89
+ input.
90
+
91
+ ### 4.2 Layout
92
+
93
+ ```text
94
+ <dataDir>/
95
+ ├── environments/<environmentId>.yaml
96
+ ├── services/<serviceId>.yaml
97
+ ├── deployments/<deploymentId>.yaml
98
+ ├── rules/<ruleProfileId>.yaml
99
+ ├── operations/<bindingId>.yaml
100
+ ├── sessions/<sessionId>.json
101
+ └── .backups/<kind>/<id>/<timestamp>.yaml (rolling, max 5)
102
+ ```
103
+
104
+ - The file name is always `<id>.yaml`; the record `id` must equal the file stem.
105
+ - `sessions/` and `.backups/` are never scanned as registry records.
106
+ - Directories are created lazily by the first write; a missing root on read yields an
107
+ empty registry, not an error.
108
+
109
+ ### 4.3 Id and path safety
110
+
111
+ ```ts
112
+ const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}$/
113
+ ```
114
+
115
+ `resolveRecordPath(kind, id)` rejects a non-matching id **before** touching the
116
+ filesystem, then verifies `path.resolve(dir, id + '.yaml')` starts with
117
+ `path.resolve(dir) + path.sep`. Session ids are validated as `[A-Za-z0-9_-]{1,128}`
118
+ and hashed only if they fail that pattern.
119
+
120
+ ### 4.4 Atomic write protocol
121
+
122
+ ```ts
123
+ async function writeRecord(kind, id, record) {
124
+ const target = resolveRecordPath(kind, id)
125
+ await mkdir(dirname(target), { recursive: true })
126
+ const tmp = join(dirname(target), `.${id}.${process.pid}.${randomUUID()}.tmp`)
127
+ const text = stringifyYaml(record)
128
+ const handle = await open(tmp, 'w')
129
+ try {
130
+ await handle.writeFile(text, 'utf8')
131
+ await handle.sync()
132
+ } finally {
133
+ await handle.close()
134
+ }
135
+ await rotateBackup(kind, id, target) // copy previous file, prune to 5
136
+ await rename(tmp, target) // atomic replace on POSIX and Windows
137
+ return { file: target, bytes: Buffer.byteLength(text) }
138
+ }
139
+ ```
140
+
141
+ Guarantees:
142
+
143
+ - A reader never sees a partially written record.
144
+ - A crash before `rename` leaves an ignorable `.tmp` file; startup cleanup removes
145
+ `*.tmp` older than one hour.
146
+ - Backup failure does not block the write; it is reported as a warning.
147
+ - `writeRecord` is serialized per target path by an in-process promise chain (§4.8).
148
+
149
+ ### 4.5 Read protocol
150
+
151
+ ```ts
152
+ async function readRecord(kind, id): Promise<ReadResult> {
153
+ const target = resolveRecordPath(kind, id)
154
+ let text
155
+ try { text = await readFile(target, 'utf8') }
156
+ catch (e) { if (e.code === 'ENOENT') return { found: false }; throw e }
157
+ const parsed = parseYamlDocument(text) // throws RECORD_INVALID
158
+ return { found: true, raw: parsed, text }
159
+ }
160
+ ```
161
+
162
+ Invalid YAML, a non-object document, an id/filename mismatch, or a schema failure
163
+ produces a `Diagnostic` and the record is treated as absent for that request. The file
164
+ is never rewritten or deleted.
165
+
166
+ Unknown keys are preserved on read and reported as an `UNKNOWN_FIELD` warning, but
167
+ `env_save` rejects them (`RECORD_INVALID`) so a typo cannot enter the store. Storage
168
+ therefore parses twice: `.passthrough()` on read, strict on save.
169
+
170
+ ### 4.6 Delete
171
+
172
+ ```text
173
+ 1. Resolve the target and confirm it exists.
174
+ 2. Ask the registry whether any record references it (§6.3).
175
+ 3. If referenced -> RECORD_IN_USE listing referrers.
176
+ 4. Otherwise rotate a backup, then unlink the file.
177
+ ```
178
+
179
+ Deleting a session file is a plain unlink and needs no reference check.
180
+
181
+ ### 4.7 Session files
182
+
183
+ ```json
184
+ {
185
+ "schemaVersion": 1,
186
+ "sessionId": "01J...",
187
+ "defaultEnvironmentId": "test-a",
188
+ "updatedAt": "2026-11-01T03:00:00.000Z"
189
+ }
190
+ ```
191
+
192
+ Written with the same temp+rename protocol. A corrupt or unreadable file is reported
193
+ as `persisted: false` and treated as "no default"; it is not deleted.
194
+
195
+ ### 4.8 Concurrency
196
+
197
+ - One serialized chain per file path (`Map<string, Promise<void>>`), released when the
198
+ chain drains.
199
+ - Registry loads are deduplicated: concurrent callers share one in-flight load.
200
+ - No cross-process lock in V1. Two DSH processes writing the same file can lose one
201
+ update; the backup makes the loss recoverable. Documented as a limitation.
202
+
203
+ ### 4.9 Limits
204
+
205
+ | Limit | Value | Failure |
206
+ |---|---|---|
207
+ | records per kind | `maxRecords` (2000) | `RECORD_LIMIT_EXCEEDED` |
208
+ | record YAML bytes | 1 MiB | `RECORD_TOO_LARGE` |
209
+ | session file bytes | 64 KiB | `SESSION_CONTEXT_UNPERSISTED` |
210
+
211
+ ## 5. Domain
212
+
213
+ ```ts
214
+ // src/domain/types.ts
215
+ export type RecordKind = 'environment' | 'service' | 'deployment' | 'ruleProfile' | 'operationBinding'
216
+ export type AnyRecord = Environment | Service | Deployment | RuleProfile | OperationBinding
217
+ ```
218
+
219
+ `src/domain/schemas.ts` exports one Zod schema per record plus:
220
+
221
+ ```ts
222
+ export function parseRecord(kind: RecordKind, value: unknown, origin: string):
223
+ { ok: true; record: AnyRecord } | { ok: false; diagnostic: Diagnostic }
224
+ ```
225
+
226
+ Validation responsibilities:
227
+
228
+ - `RecordMeta`: id pattern, `schemaVersion === 1`, ISO timestamps, `updatedAt >= createdAt`.
229
+ - Cross-field rules from `data-model.md`: at least one `deployModes`, host role ids
230
+ exist, `location.kind === runtimeMode`, unique host id / ip / cluster id /
231
+ endpoint id, unique account id per host, `loginRecords[].hostRef`/`accountId`
232
+ resolve within the environment, host `clusterRef` resolves and is present for
233
+ `k8s-all-in-one`/`k8s-worker`, `access` values are unique, `paths` keys and variable
234
+ names match `[A-Za-z_][A-Za-z0-9_]*`.
235
+ - `structural` shape only. Referential integrity is the registry's job (§6.3), because
236
+ it needs the whole snapshot.
237
+
238
+ `parseRecord` never throws for bad data; it returns a diagnostic with `path` pointing
239
+ at the failing field.
240
+
241
+ Tool input is parsed by `parseRecordInput`, which is identical except `createdAt` and
242
+ `updatedAt` are optional. The app then calls `finalizeRecord(record, previous?)` to
243
+ stamp them before `parseRecord` and before writing.
244
+
245
+ ## 6. Registry
246
+
247
+ ### 6.1 Snapshot
248
+
249
+ ```ts
250
+ interface RegistrySnapshot {
251
+ environments: Map<string, Environment>
252
+ services: Map<string, Service>
253
+ deployments: Map<string, Deployment>
254
+ ruleProfiles: Map<string, RuleProfile>
255
+ operationBindings: Map<string, OperationBinding>
256
+ byAlias: Map<string, string> // "service:<alias>" -> id
257
+ diagnostics: Diagnostic[]
258
+ loadedAt: string
259
+ }
260
+ ```
261
+
262
+ `byAlias` keys are `${kind}:${alias}` and resolve aliases for `env_get`/`env_find`.
263
+ Duplicate aliases are a warning; the first record wins, sorted by file name for
264
+ determinism.
265
+
266
+ ### 6.2 Load algorithm
267
+
268
+ ```text
269
+ for each kind directory (environments, services, deployments, rules, operations):
270
+ entries = readdir(dir, { withFileTypes: true })
271
+ files = entries where isFile() and name endsWith('.yaml') and not name startsWith('.')
272
+ sort files by name
273
+ for each file:
274
+ read + parse
275
+ parseRecord -> on failure: diagnostic (file, code, path, message); skip
276
+ if map already has record.id: diagnostic DUPLICATE_ID; skip later file
277
+ else insert
278
+ refresh byAlias and run integrity checks
279
+ ```
280
+
281
+ A missing kind directory is normal (empty). An unreadable directory is an error
282
+ diagnostic, and the snapshot keeps the other kinds.
283
+
284
+ ### 6.3 Referential integrity
285
+
286
+ | Reference | Rule | Code |
287
+ |---|---|---|
288
+ | `deployment.environmentId` | environment exists | `ENV_NOT_FOUND` |
289
+ | `deployment.serviceId` | service exists | `SERVICE_NOT_FOUND` |
290
+ | `deployment.location.hostRef` | host exists in that environment | `HOST_NOT_FOUND` |
291
+ | `deployment.location.clusterRef` | cluster exists in that environment | `CLUSTER_NOT_FOUND` |
292
+ | environment `hosts[].clusterRef` | cluster exists; required for K8s node kinds | `RECORD_INVALID` |
293
+ | environment `endpoints[].hostRef` / `clusterRef` | target exists in that environment | `RECORD_INVALID` |
294
+ | `service.ruleProfileRefs[]` | rule profile exists | `RULE_PROFILE_NOT_FOUND` |
295
+ | operation binding `match.environmentId` | environment exists | `ENV_NOT_FOUND` |
296
+ | operation binding `match.serviceId` | service exists | `SERVICE_NOT_FOUND` |
297
+ | operation binding `match.deploymentId` | deployment exists | `DEPLOYMENT_NOT_FOUND` |
298
+ | any `credentialRef` | non-empty string matching the agreed ref pattern | `RECORD_INVALID` |
299
+
300
+ Schema validation rejects an environment with zero logical roles and a host with zero
301
+ roles before integrity checks run; both fail as `RECORD_INVALID`. A host role id that is
302
+ not declared in `Environment.roles` is reported as `ROLE_NOT_FOUND` by `env_validate`;
303
+ the panel prevents selecting an undeclared role in the node form.
304
+
305
+ Integrity failures are **diagnostics**, not load failures: the registry still serves
306
+ the other records, and `env_validate` reports the broken references. A resolve or
307
+ route that needs a missing record fails with the specific code.
308
+
309
+ ### 6.4 Reload policy
310
+
311
+ V1 reloads the registry at the start of every application request. Rationale: users
312
+ edit YAML by hand, and a stale cache is worse than a few milliseconds. Concurrent
313
+ requests share one in-flight load. A snapshot is replaced only after a successful
314
+ load; a load that throws keeps the previous snapshot and reports an error diagnostic.
315
+
316
+ Performance target: 500 records load in under 100 ms on a local SSD. If that is
317
+ missed, add an mtime-keyed cache in a follow-up, not in V1.
318
+
319
+ ## 7. Resolver
320
+
321
+ ### 7.1 Template parsing
322
+
323
+ ```ts
324
+ type TemplatePart =
325
+ | { kind: 'literal'; text: string }
326
+ | { kind: 'ref'; path: string }
327
+
328
+ function parseTemplate(text: string): TemplatePart[]
329
+ ```
330
+
331
+ Scanner rules:
332
+
333
+ 1. Find `${`. Text before it is a literal.
334
+ 2. Find the next `}`. A missing `}` is `RULE_TEMPLATE_INVALID`.
335
+ 3. The inner text must match `^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)*$`;
336
+ otherwise `RULE_TEMPLATE_INVALID`.
337
+ 4. Anything that is not `${…}` is literal text, including a lone `$`.
338
+ 5. No nesting, escaping, calls, operators, or env access.
339
+ 6. Template length is capped at 2048 characters.
340
+
341
+ ### 7.2 Namespaces
342
+
343
+ | Reference | Resolves to |
344
+ |---|---|
345
+ | `env.id`, `env.name`, `env.category` | environment record |
346
+ | `service.id`, `service.name`, `service.category` | service record |
347
+ | `service.artifact.name` | service artifact name, when declared |
348
+ | `deployment.id`, `deployment.name`, `deployment.runtimeMode` | deployment record |
349
+ | `deployment.location.<field>` | deployment location, runtime-specific |
350
+ | `<variable>` | layer variable of that name |
351
+ | `variables.<variable>` | same, explicit form |
352
+ | `paths.<pathName>` | another resolved path |
353
+
354
+ `service.artifact.name` resolves only when `artifact` exists. A reference to a
355
+ namespace that is unavailable for this context is `RULE_UNDEFINED_VARIABLE`, not an
356
+ empty string.
357
+
358
+ ### 7.3 Layers
359
+
360
+ ```ts
361
+ interface Layer {
362
+ source: RuleSource
363
+ variables?: Record<string, string | number | boolean>
364
+ paths?: Record<string, string>
365
+ logSources?: Partial<Record<RuntimeMode, LogSource>>
366
+ classLocations?: Partial<Record<RuntimeMode, ClassLocation>>
367
+ }
368
+ ```
369
+
370
+ Built from `design.md` §6.3:
371
+
372
+ 1. built-in global defaults (empty in V1);
373
+ 2. each `service.ruleProfileRefs` entry, in order;
374
+ 3. `environment.ruleOverrides`;
375
+ 4. `deployment.variables` (variables only), then `deployment.ruleOverrides`.
376
+
377
+ Flatten to a field map:
378
+
379
+ ```text
380
+ variables.<name>
381
+ paths.<name>
382
+ logSources.<runtime>.<property>
383
+ classLocations.<runtime>.<property>
384
+ ```
385
+
386
+ ### 7.4 Merge
387
+
388
+ ```ts
389
+ interface MergedField {
390
+ key: string
391
+ raw: string | number | boolean
392
+ definedBy: RuleSource
393
+ shadowed: RuleSource[]
394
+ }
395
+ ```
396
+
397
+ Iterate layers in ascending order. For each field, replace the winner and append the
398
+ previous winner to `shadowed`. This is the only merge rule; there is no deep merge and
399
+ no array concatenation.
400
+
401
+ ### 7.5 Evaluation
402
+
403
+ ```text
404
+ 1. Build the reference graph: node = merged field key; edge = each ref used by a
405
+ string field. Constants (env/service/deployment) are not nodes.
406
+ 2. DFS with an active stack. A back edge is RULE_CYCLE with the full cycle path.
407
+ 3. A ref with no node and no constant is RULE_UNDEFINED_VARIABLE
408
+ (message names the field and the ref).
409
+ 4. Resolve nodes in post-order; substitute into an ordered list of parts.
410
+ 5. For every resolved `paths.*` value, split on `/` and reject `..`
411
+ (RULE_PATH_TRAVERSAL). Absolute/relative shape is otherwise preserved.
412
+ 6. Convert non-string numbers/booleans to their literal text when interpolated.
413
+ 7. Select `logSources[runtimeMode]` and `classLocations[runtimeMode]` after their
414
+ strings resolve.
415
+ ```
416
+
417
+ Cycle detection is iterative (explicit stack), not recursive, so a pathological graph
418
+ cannot overflow the JS stack.
419
+
420
+ ### 7.6 Provenance
421
+
422
+ For every requested field:
423
+
424
+ ```ts
425
+ interface ProvenanceEntry {
426
+ field: string
427
+ value: string | number | boolean
428
+ definedBy: RuleSource
429
+ inputs: Array<{ field: string; source: RuleSource }>
430
+ shadowed: RuleSource[]
431
+ }
432
+ ```
433
+
434
+ `inputs` records the source of the winning definition of each referenced field, so a
435
+ value that depends on an environment override names the environment layer. `shadowed`
436
+ comes from the merge.
437
+
438
+ `paths` and `variables` produce one entry per name. `logSource` and `classLocation` are
439
+ structured: each resolved property produces its own entry
440
+ (`logSources.kubernetes.namespace`), returned as a list so a mixed-layer object is still
441
+ explainable.
442
+
443
+ ### 7.7 Public API
444
+
445
+ ```ts
446
+ interface ResolveInput {
447
+ environment: Environment
448
+ service: Service
449
+ deployment?: Deployment
450
+ ruleProfiles: Map<string, RuleProfile>
451
+ }
452
+
453
+ function resolveContext(input: ResolveInput): ResolveResult
454
+ ```
455
+
456
+ `ResolveResult` is `{ ok: true; context: ServiceContext } | { ok: false; diagnostics: Diagnostic[] }`.
457
+ The function is pure; it never reads files, time, or config.
458
+
459
+ ## 8. Router
460
+
461
+ ### 8.1 Eligibility
462
+
463
+ A binding is eligible when:
464
+
465
+ - `enabled === true`;
466
+ - `operation` equals the requested id exactly;
467
+ - every declared `match` field matches the context:
468
+
469
+ | Field | Matches |
470
+ |---|---|
471
+ | `environmentId` | context environment id |
472
+ | `serviceId` | context service id |
473
+ | `deploymentId` | context deployment id (requires a deployment) |
474
+ | `deploymentMode` | context runtime mode |
475
+ | `serviceCategory` | service category |
476
+
477
+ An empty `match` matches everything.
478
+
479
+ A binding whose `match` is not one of the five documented shapes is `RECORD_INVALID` at
480
+ load (data-model §7); the router never sees such a binding.
481
+
482
+ ### 8.2 Level and selection
483
+
484
+ Rank: `deployment` 1, `environment-service` 2, `service` 3, `category-mode` 4,
485
+ `global` 5. Level is derived from which match fields are present, with
486
+ `deploymentId` winning over `environmentId`+`serviceId`, which wins over `serviceId`,
487
+ then `serviceCategory`+`deploymentMode`, then empty.
488
+
489
+ ```text
490
+ eligible = filter(bindings, eligible)
491
+ if eligible is empty: BINDING_NOT_FOUND
492
+ top = min(rank)
493
+ winners = eligible where rank == top
494
+ if winners.length > 1: BINDING_CONFLICT (list every candidate)
495
+ else selected = winners[0]
496
+ ```
497
+
498
+ Lower levels are ignored once a winner exists, even if they also match.
499
+
500
+ ### 8.3 Skill availability
501
+
502
+ ```ts
503
+ interface SkillDirectory {
504
+ snapshot(opts): Promise<{ skills: SkillSummary[]; complete: boolean }>
505
+ }
506
+ ```
507
+
508
+ Adapter over `ctx.skills` (F20):
509
+
510
+ | Condition | Result |
511
+ |---|---|
512
+ | `ctx.skills` absent | `available: 'unknown'` + `SKILL_REGISTRY_UNAVAILABLE` warning, `ok: true` |
513
+ | `complete === false` | same |
514
+ | name not found | `ok: false`, `SKILL_UNAVAILABLE` |
515
+ | `expectedSource` set and `source` differs | `ok: false`, `SKILL_UNAVAILABLE` |
516
+ | `expectedProvider` set and `provider` differs | `ok: false`, `SKILL_UNAVAILABLE` |
517
+ | otherwise | `available: true` |
518
+
519
+ Digest pinning is not supported: DSH's catalog exposes no digest. The record field is
520
+ removed in this design revision; do not reintroduce it without a provider that can
521
+ verify it.
522
+
523
+ ### 8.4 Policy
524
+
525
+ ```text
526
+ write = operation in WRITE_OPERATIONS
527
+ requiresApproval = policy.requiresApproval ?? write
528
+ if context.environmentCategory === 'production' and policy.allowProduction !== true:
529
+ POLICY_DENIED
530
+ else: return policy
531
+ ```
532
+
533
+ `requiresApproval` and `allowProduction` are returned to the caller; V1 does not
534
+ execute the Skill. A future executor must enforce them, not just display them.
535
+
536
+ ### 8.5 Parameters
537
+
538
+ - Strings are templates resolved through the same engine, with the context namespaces
539
+ and `paths.*`.
540
+ - Numbers and booleans are stringified.
541
+ - A null, array, or object value is `PARAMETER_RESOLUTION_FAILED`.
542
+ - At most 50 parameters; keys must match `[A-Za-z_][A-Za-z0-9_]*`.
543
+
544
+ ### 8.6 Public API
545
+
546
+ ```ts
547
+ function resolveOperation(input: {
548
+ operation: string
549
+ context: ServiceContext
550
+ bindings: OperationBinding[]
551
+ skills: SkillDirectory | null
552
+ }): OperationResolution
553
+ ```
554
+
555
+ Pure except for `skills.snapshot()`, which is an injected async capability. Tests use
556
+ a fake directory.
557
+
558
+ ## 9. Session store
559
+
560
+ ```ts
561
+ interface SessionContext {
562
+ sessionId: string
563
+ defaultEnvironmentId: string | null
564
+ persisted: boolean
565
+ }
566
+ ```
567
+
568
+ - `get(sessionId)`: read `sessions/<id>.json`; missing → `null` default; corrupt →
569
+ `persisted: false`.
570
+ - `set(sessionId, environmentId)`: validate the environment exists, then write the
571
+ file; a write failure returns `persisted: false` and `SESSION_CONTEXT_UNPERSISTED`.
572
+ - `set(..., null)` writes `defaultEnvironmentId: null` (an explicit clear).
573
+ - Two sessions never share a file.
574
+ - A process-local `Map<sessionId, string | null>` caches the last value. `get` prefers
575
+ the file and falls back to the cache when the file is missing or corrupt; a failed
576
+ write still updates the cache so the current process honors the choice. The cache is
577
+ not durable and is empty after a restart.
578
+
579
+ ## 10. Application service
580
+
581
+ `src/app/app.ts` is the single place where storage, registry, resolver, router and
582
+ session are composed. Tools and HTTP both call it; neither contains business logic.
583
+
584
+ ```ts
585
+ interface App {
586
+ meta(): Promise<MetaResult>
587
+ list(kind: RecordKind): Promise<ListResult>
588
+ get(kind: RecordKind, id: string): Promise<GetResult>
589
+ find(query: string): Promise<FindResult>
590
+ save(kind: RecordKind, record: unknown, options?: SaveOptions): Promise<SaveResult>
591
+ saveYaml(kind: RecordKind, yaml: string, options?: SaveOptions): Promise<SaveResult>
592
+ remove(kind: RecordKind, id: string, actor: Actor, options?: RemoveOptions): Promise<RemoveResult>
593
+ validate(): Promise<ValidationReport>
594
+ resolve(input: ResolveRequest): Promise<ResolveResult>
595
+ route(input: RouteRequest): Promise<OperationResolution>
596
+ setCredential(ref: string, value: string): Promise<{ ref: string; configured: true }>
597
+ unsetCredential(ref: string): Promise<{ ref: string; removed: true }>
598
+ getContext(sessionId: string): Promise<SessionContext>
599
+ setContext(sessionId: string, environmentId: string | null): Promise<SessionContext>
600
+ }
601
+
602
+ interface SaveOptions { dryRun?: boolean }
603
+ interface RemoveOptions { dryRun?: boolean }
604
+ ```
605
+
606
+ Rules:
607
+
608
+ - `save` validates with Zod, sets `createdAt` (create only) and `updatedAt`, checks the
609
+ record cap, writes, then reloads the registry. Timestamps supplied by the caller are
610
+ ignored; the stored record always carries both.
611
+ - A schema failure throws `RECORD_INVALID` with
612
+ `details.issues: [{ path, message }]`, where `path` is a record-relative field path
613
+ (`hosts[1].ip`). The UI renders these as inline diagnostics.
614
+ - `save` returns `created: boolean` by comparing existence before the write.
615
+ - `save` re-runs integrity checks after the write and returns warnings for broken
616
+ references; it does not roll back, because the record is individually valid.
617
+ - `remove` refuses when referenced (`RECORD_IN_USE`) and supports a dry-run flag used
618
+ by the UI's confirmation dialog.
619
+ - `resolve` and `route` take explicit ids; the **tool layer** supplies the session
620
+ default when the model omits an id.
621
+ - `Actor` is `'agent' | 'ui'`; the UI actor cannot write production unless
622
+ `writePolicy.allowUiProductionWrites` is true.
623
+ - `resolve` fills `context.credentials` through an injected
624
+ `CredentialDirectory { describe(ref); set(ref, value); unset(ref) }`. The Host
625
+ adapter calls `ctx.credentials.describe`; tests use a fake. A missing ref yields
626
+ `{ ref, configured: false }` plus `CREDENTIAL_MISSING`; no value is ever requested.
627
+ - `setCredential` accepts only `env-manager/<environmentId>/<accountId>` refs and a
628
+ non-empty value, forwards them to `ctx.credentials.set`, and returns only
629
+ `{ ref, configured: true }`. It never writes the value to a record or log.
630
+ `unsetCredential` uses the same namespace check and calls `ctx.credentials.unset`.
631
+ A missing service yields `CREDENTIAL_UNAVAILABLE`; provider failures yield
632
+ `CREDENTIAL_WRITE_FAILED` without exposing the provider message to the UI.
633
+
634
+ ## 11. Tools
635
+
636
+ ### 11.1 Shared shape
637
+
638
+ ```ts
639
+ function toolOutput<T extends JsonSchemaNode>(schema: T, render: Renderer): ToolOutputDefinition
640
+ ```
641
+
642
+ - Every tool returns a plain object matching its declared schema; DSH validates it.
643
+ - Every failure throws `EnvManagerError`; no tool returns `{ ok: false }`.
644
+ - `render` emits one text block: a one-line summary plus the essential ids. The
645
+ canonical JSON value is what the model receives.
646
+
647
+ `EnvManagerError`:
648
+
649
+ ```ts
650
+ class EnvManagerError extends HarnessError {
651
+ constructor(code: ErrorCode, message: string, details?: JsonValue) {
652
+ super(message, code)
653
+ this.details = details
654
+ }
655
+ }
656
+ ```
657
+
658
+ ### 11.2 Tool table
659
+
660
+ | Tool | Parameters | Returns | Session | Approval |
661
+ |---|---|---|---|---|
662
+ | `env_list` | `kind?`, `query?` | `{ items, warnings }` | no | no |
663
+ | `env_get` | `kind`, `id` | `{ record, yaml }` | no | no |
664
+ | `env_find` | `query` | `{ hits }` | no | no |
665
+ | `env_resolve` | `environmentId?`, `serviceId`, `deploymentId?` | `{ context, warnings }` | default env | no |
666
+ | `env_resolve_operation` | `operation`, `environmentId?`, `serviceId?`, `deploymentId?` | `{ resolution }` | yes | no |
667
+ | `env_get_context` | — | `{ sessionId, defaultEnvironmentId, persisted }` | yes | no |
668
+ | `env_set_context` | `environmentId \| null` | same | yes | no |
669
+ | `env_save` | `kind`, `record`, `dryRun?` | `{ id, kind, created, dryRun, warnings }` | no | production env |
670
+ | `env_delete` | `kind`, `id`, `confirm` | `{ id, kind, deleted, blockers }` | no | yes |
671
+ | `env_validate` | — | `{ ok, counts, diagnostics }` | no | no |
672
+
673
+ ### 11.3 Parameter definitions
674
+
675
+ ```ts
676
+ const kindSchema = {
677
+ type: 'string',
678
+ enum: ['environment', 'service', 'deployment', 'ruleProfile', 'operationBinding'],
679
+ description: 'Record kind.',
680
+ } as const
681
+ ```
682
+
683
+ `env_list`:
684
+
685
+ ```ts
686
+ parameters: {
687
+ kind: { ...kindSchema, required: false },
688
+ query: { type: 'string', required: false, description: 'Case-insensitive match over id, name and alias.' },
689
+ }
690
+ ```
691
+
692
+ `env_get`: `kind` and `id` required, `id` description names the id pattern.
693
+
694
+ `env_find`: `query` required, min length 2.
695
+
696
+ `env_resolve`: `serviceId` required; `environmentId` and `deploymentId` optional.
697
+ Resolving an environment/service pair with more than one deployment and no
698
+ `deploymentId` returns `DEPLOYMENT_AMBIGUOUS` listing candidates; zero deployments
699
+ returns `DEPLOYMENT_NOT_FOUND`.
700
+
701
+ `env_resolve_operation`: `operation` required, `enum` of the 13 standard ids plus a
702
+ free-form custom id — model-visible enum lists only the standard ids; a custom id is
703
+ rejected by the schema unless it is declared in a binding. Because the DSL cannot
704
+ express "either enum or binding-declared", the parameter is a plain string with the
705
+ standard ids in the description and is validated in `execute`
706
+ (`OPERATION_UNKNOWN` when neither standard nor bound).
707
+
708
+ `env_set_context`: `environmentId` is `oneOf` string / `null`.
709
+
710
+ `env_save`: `record` is `{ type: 'json', required: true, description: 'Complete record object.' }`.
711
+ Zod still validates it; the JSON parameter only avoids duplicating every record schema
712
+ in the model-facing tool. `dryRun: true` validates and returns the same shape without
713
+ writing; a schema failure still throws `RECORD_INVALID`.
714
+
715
+ `env_delete`: `confirm` is a required boolean. `false` performs a dry run: it returns
716
+ `deleted: false` plus `blockers` and writes nothing.
717
+
718
+ ### 11.4 Output schemas
719
+
720
+ Small results are precise; open records use `additionalProperties: true`.
721
+
722
+ ```ts
723
+ const diagnosticSchema = {
724
+ type: 'object', additionalProperties: false,
725
+ properties: {
726
+ severity: { type: 'string', enum: ['info', 'warning', 'error'], required: true },
727
+ code: { type: 'string', required: true },
728
+ message: { type: 'string', required: true },
729
+ file: { type: 'string' },
730
+ path: { type: 'string' },
731
+ },
732
+ }
733
+ const recordSchema = { type: 'object', additionalProperties: true }
734
+ ```
735
+
736
+ `env_list` output:
737
+
738
+ ```ts
739
+ { type: 'object', additionalProperties: false, properties: {
740
+ items: { type: 'array', required: true, items: { type: 'object', additionalProperties: false, properties: {
741
+ kind: { type: 'string', required: true },
742
+ id: { type: 'string', required: true },
743
+ name: { type: 'string', required: true },
744
+ summary: { type: 'string' },
745
+ } } },
746
+ warnings: { type: 'array', required: true, items: diagnosticSchema },
747
+ } }
748
+ ```
749
+
750
+ `env_get` output: `{ record: recordSchema, yaml: { type: 'string', required: true } }`.
751
+
752
+ `env_resolve` output: `{ context: recordSchema, warnings: arrayOf(diagnosticSchema) }`.
753
+
754
+ `env_validate` output: counts per kind plus `diagnostics`.
755
+
756
+ ### 11.5 Execution flow
757
+
758
+ ```text
759
+ execute(args, exec):
760
+ 1. assert exec.signal not aborted
761
+ 2. resolve target ids (session default when allowed)
762
+ 3. call app.<operation>
763
+ 4. on EnvManagerError: rethrow
764
+ 5. on unexpected error: wrap as INTERNAL_ERROR with a redacted message
765
+ 6. return the declared value
766
+ ```
767
+
768
+ Approval helper:
769
+
770
+ ```ts
771
+ async function requireApproval(exec, toolName: string, reason: string): Promise<void> {
772
+ if (!exec.agent) throw new EnvManagerError('SESSION_CONTEXT_UNAVAILABLE', '...')
773
+ const outcome = await ctx.approval.request({
774
+ agent: exec.agent,
775
+ toolName,
776
+ callId: exec.callId,
777
+ reason,
778
+ displayReason: { en: reason, zh: reasonZh },
779
+ signal: exec.signal,
780
+ })
781
+ if (outcome !== 'allowed-once') throw new EnvManagerError('POLICY_DENIED', '...')
782
+ }
783
+ ```
784
+
785
+ The `displayReason` is localized; the `reason` is the stable English fallback.
786
+
787
+ ## 12. HTTP transport
788
+
789
+ ### 12.1 Route
790
+
791
+ ```ts
792
+ ctx.webServer.register({ kind: 'prefix', path: '/env-manager/api', handler })
793
+ ```
794
+
795
+ - `POST` only; other methods → 405 with `Allow: POST`.
796
+ - `Content-Type: application/json; charset=utf-8` required.
797
+ - Body limit 2 MiB; exceeding it → 413.
798
+ - Request header `x-env-manager-request: 1` required, otherwise 403.
799
+ - If an `Origin` header is present it must equal `ctx.webServer.getOrigin()`,
800
+ otherwise 403. No CORS headers are ever sent.
801
+ - The handler never reads cookies or credentials. A credential value may appear only in
802
+ the `credential` request body, is forwarded directly to the credential service and is
803
+ never logged or echoed.
804
+
805
+ ### 12.2 Envelope
806
+
807
+ Request:
808
+
809
+ ```json
810
+ { "op": "record.save", "payload": { "kind": "environment", "yaml": "id: test-a\n..." } }
811
+ ```
812
+
813
+ Success `200`:
814
+
815
+ ```json
816
+ { "ok": true, "data": { ... } }
817
+ ```
818
+
819
+ Failure:
820
+
821
+ ```json
822
+ { "ok": false, "error": { "code": "RECORD_INVALID", "message": "...", "details": { ... } } }
823
+ ```
824
+
825
+ Status map: `INVALID_ARGUMENT` 400, `POLICY_DENIED`/`FORBIDDEN` 403, `*_NOT_FOUND` 404,
826
+ `RECORD_IN_USE`/`BINDING_CONFLICT`/`DEPLOYMENT_AMBIGUOUS` 409, `RECORD_INVALID`/
827
+ `RULE_*`/`PARAMETER_*` 422, `CREDENTIAL_UNAVAILABLE` 503, anything else 500 (including
828
+ `CREDENTIAL_WRITE_FAILED`). The body always carries the code; the status is for tooling.
829
+
830
+ ### 12.3 Operations
831
+
832
+ | op | payload | data |
833
+ |---|---|---|
834
+ | `meta` | — | dataDir, version, counts, warnings |
835
+ | `list` | `{ kind }` | summaries |
836
+ | `get` | `{ kind, id }` | `{ record, yaml }` |
837
+ | `find` | `{ query }` | hits |
838
+ | `save` | `{ kind, record?, yaml?, dryRun? }` | save result |
839
+ | `remove` | `{ kind, id, confirm?, dryRun? }` | remove result |
840
+ | `validate` | — | validation report |
841
+ | `resolve` | `{ environmentId, serviceId, deploymentId? }` | context |
842
+ | `route` | `{ operation, environmentId, serviceId, deploymentId? }` | resolution |
843
+ | `credential` | `{ ref, value }` | `{ ref, configured }` |
844
+ | `credentialUnset` | `{ ref }` | `{ ref, removed }` |
845
+
846
+ The UI cannot read or write session context; that is agent-only in V1 (§9).
847
+
848
+ ### 12.4 Actor policy
849
+
850
+ `record.save` and `record.remove` run with actor `'ui'`:
851
+
852
+ - production environment save is denied unless `allowUiProductionWrites`;
853
+ - `record.remove` requires `confirm: true` unless `dryRun: true`;
854
+ - every write is logged with actor, op, kind, id and outcome (no record content).
855
+
856
+ `credential` and `credentialUnset` are UI-only, restricted to the `env-manager/`
857
+ namespace and never write to the registry. They log only the ref and outcome.
858
+
859
+ ## 13. Client module
860
+
861
+ Structural contract:
862
+
863
+ ```js
864
+ return {
865
+ inject: ['slots', 'locale'],
866
+ apply(ctx) {
867
+ ctx.effect(() => ctx.locale.register('envManager', { en, zh }))
868
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register(
869
+ { name: 'sidebar.panellist', id: 'env-manager', order: 40 }, Icon))
870
+ ctx.slots.inject('main', () => ctx.slots.register(
871
+ { name: 'main', key: 'env-manager' }, Panel))
872
+ },
873
+ }
874
+ ```
875
+
876
+ - All data access goes through `createApi()` in `client.js`, which posts to
877
+ `/env-manager/api` with the required header and maps failures to a UI error object.
878
+ - The panel keeps state locally (`useState`/`useEffect`); no global store.
879
+ - Rendering and interactions are specified in [`ui-design.md`](ui-design.md).
880
+
881
+ ## 14. Memory bridge
882
+
883
+ ```ts
884
+ interface MemoryBridge {
885
+ readonly available: boolean
886
+ emit(record: OperationRecord): Promise<void>
887
+ observe(scope: MemoryScope): Promise<readonly Observation[]>
888
+ propose(candidate: CandidateChange): Promise<string> // returns proposal id
889
+ }
890
+ ```
891
+
892
+ V1 ships `NullMemoryBridge`: `available: false`, `emit` resolves without writing,
893
+ `observe` returns `[]`, `propose` throws `MEMORY_BRIDGE_UNAVAILABLE`. It is selected
894
+ when no memory provider is registered; a future adapter plugs in without changing the
895
+ tools because nothing in V1 calls `emit` (no execution).
896
+
897
+ ## 15. Cross-cutting
898
+
899
+ ### 15.1 Logging
900
+
901
+ `src/logger.ts` wraps `ctx.get('logger')` when it is callable, else `console`.
902
+ Log levels: `info` for lifecycle (dataDir, loaded counts), `warn` for skipped records,
903
+ `error` for failed loads. Records and credential refs are logged as ids only; never a
904
+ whole record, never a value.
905
+
906
+ ### 15.2 Redaction
907
+
908
+ `redactValue(text)` removes anything that looks like a URL userinfo, a bearer token,
909
+ or a private key block from messages before they reach the model or the UI. Tool
910
+ results never include `credentialRef` values, only configured state.
911
+
912
+ ### 15.3 Cancellation
913
+
914
+ `execute` receives `exec.signal`; long loops (registry load, resolve) check it between
915
+ files and between fields. A cancelled call surfaces DSH's own abort error unchanged.
916
+
917
+ ### 15.4 Performance budgets
918
+
919
+ | Operation | Budget |
920
+ |---|---|
921
+ | registry load (500 records) | 100 ms |
922
+ | resolve (50 fields) | 5 ms |
923
+ | route with skill snapshot | 50 ms |
924
+ | HTTP round trip (local) | 150 ms |
925
+
926
+ ## 16. Test obligations
927
+
928
+ | Module | Tests | From AGENTS §13 |
929
+ |---|---|---|
930
+ | `storage` | atomic write, missing file, invalid YAML, schema mismatch, backups, traversal rejection | required |
931
+ | `registry` | load/lookup, duplicate id, broken references, alias collision | required |
932
+ | `resolver` | precedence, field merge, provenance, undefined/cycle/traversal, runtime selection | required |
933
+ | `router` | each level, conflict, missing skill, source/provider mismatch, policy, parameters | required |
934
+ | `session` | set/get/clear, isolation, corrupt fallback | required |
935
+ | `tools` | output matches schema, error codes, no credential values, approval denial | required |
936
+ | `app` | save→get round trip, delete in use, validate report | new |
937
+ | `http` | auth header, origin, method, size cap, status map, production denial | new |
938
+ | `client` | manual checklist in `ui-design.md` §9 | manual |
939
+
940
+ Tests live in `tests/<module>/*.test.ts`, fixtures in `tests/fixtures/<scenario>/`.
941
+
942
+ ## 17. Traceability
943
+
944
+ | Design section | Implements | Acceptance |
945
+ |---|---|---|
946
+ | §4 storage | ADR-0002 | AC-01, AC-02 |
947
+ | §7 resolver | ADR-0005 | AC-03, AC-04 |
948
+ | §8 router | ADR-0003 | AC-05…AC-07 |
949
+ | §9 session | ADR-0007 | AC-08 |
950
+ | §12 HTTP | ADR-0009 | UI manual QA (`ui-design.md` §12) |
951
+ | §11 tools | tool-contract.md | AC-02…AC-08 |
952
+
953
+ ## 18. Deliberate omissions
954
+
955
+ - No cache beyond one in-flight load.
956
+ - No cross-process locking.
957
+ - No Skill execution.
958
+ - No session-event projection.
959
+ - No digest pinning.
960
+ - No plugin-side bundler.
961
+ - No database.