haechi 1.6.0 → 1.8.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.
@@ -0,0 +1,230 @@
1
+ # Haechi 1.8.0 Implementation Scope
2
+
3
+ Status: implemented draft
4
+ Theme: usage/accounting contracts for later management and quota modules
5
+
6
+ ## 1. Objective
7
+
8
+ 1.8.0 adds the minimum stable core surface needed for user/accounting features
9
+ without turning Haechi core into a user database, IdP, or dashboard write plane.
10
+
11
+ The release introduces:
12
+
13
+ - `providers.usageRecorder`
14
+ - `providers.quotaProvider`
15
+ - a new public `haechi/usage` subpath
16
+ - a default-off top-level `usage` config section
17
+ - PII-safe `usage_recorded` events for completed proxied requests
18
+
19
+ The design is deliberately contract-first. Usage recording is available now;
20
+ quota denial is reserved for 1.9, when enforcement can happen before upstream
21
+ forwarding with full request context.
22
+
23
+ ## 2. Non-Goals
24
+
25
+ - No password/MFA/account-recovery lifecycle in core.
26
+ - No local user registry in core.
27
+ - No dashboard write/admin routes.
28
+ - No per-user Prometheus labels.
29
+ - No raw email/name/JWT subject/issuer/scopes/labels/model names in audit or
30
+ metrics.
31
+ - No quota denial in 1.8. The provider is validated and exposed, but proxy
32
+ enforcement is a 1.9 scope item.
33
+
34
+ ## 3. Public API
35
+
36
+ New export:
37
+
38
+ ```json
39
+ {
40
+ "exports": {
41
+ "./usage": "./packages/usage/index.mjs"
42
+ }
43
+ }
44
+ ```
45
+
46
+ Exported names:
47
+
48
+ - `USAGE_EVENT_SCHEMA_VERSION`
49
+ - `buildUsageEvent`
50
+ - `recordUsageEvent`
51
+ - `projectUsageIdentity`
52
+ - `createNoopUsageRecorder`
53
+ - `createNoopQuotaProvider`
54
+ - `coerceUsageRecorder`
55
+ - `coerceQuotaProvider`
56
+ - `normalizeQuotaDecision`
57
+
58
+ The API is additive under the 1.x semver contract.
59
+
60
+ ## 4. Config
61
+
62
+ Default:
63
+
64
+ ```json
65
+ {
66
+ "usage": {
67
+ "enabled": false,
68
+ "audit": true,
69
+ "recorder": "none",
70
+ "quota": {
71
+ "provider": "none",
72
+ "failClosed": true
73
+ }
74
+ }
75
+ }
76
+ ```
77
+
78
+ Validation:
79
+
80
+ - `usage.enabled`: boolean
81
+ - `usage.audit`: boolean
82
+ - `usage.recorder`: `none | external`
83
+ - `usage.quota.provider`: `none | external`
84
+ - `usage.quota.failClosed`: boolean
85
+
86
+ `usage.recorder: "external"` requires `createRuntime(config, { usageRecorder })`.
87
+ `usage.quota.provider: "external"` requires `createRuntime(config, { quotaProvider })`.
88
+
89
+ `configVersion` remains `1`: the section is additive and default-off.
90
+
91
+ ## 5. Runtime Contracts
92
+
93
+ ### `usageRecorder`
94
+
95
+ Shape:
96
+
97
+ ```js
98
+ {
99
+ async record(event) {}
100
+ }
101
+ ```
102
+
103
+ A bare function is also accepted and wrapped as `{ record }`.
104
+
105
+ When `usage.enabled` is `true`, the proxy emits one event after a non-observability
106
+ request completes. The event is sent to the recorder and, when `usage.audit` is
107
+ true, to the existing hash-chained audit sink.
108
+
109
+ Recorder errors are caught after the response is completed and increment
110
+ `haechi_usage_record_failed_total`. They do not write raw error messages to
111
+ audit and do not add PII-bearing metric labels.
112
+
113
+ ### `quotaProvider`
114
+
115
+ Shape:
116
+
117
+ ```js
118
+ {
119
+ async check(context) {
120
+ return { allowed: true, reason: "within_budget" };
121
+ }
122
+ }
123
+ ```
124
+
125
+ Allowed return values:
126
+
127
+ - `true`
128
+ - `false`
129
+ - `{ allowed: boolean, reason?: boundedString, retryAfterSeconds?: positiveInt }`
130
+
131
+ `normalizeQuotaDecision()` validates the bounded result shape. 1.8 does not call
132
+ the provider from the proxy enforcement path; this avoids a half-enforced quota
133
+ feature before request-body/model accounting is settled.
134
+
135
+ ## 6. Usage Event Shape
136
+
137
+ The core event contains only bounded or hash-only fields:
138
+
139
+ - `schemaVersion`
140
+ - `eventType: "usage_recorded"`
141
+ - `correlationId`
142
+ - `timestamp`
143
+ - `protocol`
144
+ - `operation`
145
+ - `identity`
146
+ - `profile`
147
+ - `decision: "usage_recorded"`
148
+ - `reason: "request_completed"`
149
+ - `routeId`
150
+ - `method`
151
+ - `pathHash`
152
+ - `outcome`
153
+ - `upstream`
154
+ - `request`
155
+ - `response`
156
+ - `durationMs`
157
+ - `summary`
158
+
159
+ Identity projection is exactly:
160
+
161
+ ```json
162
+ {
163
+ "id": "bounded id",
164
+ "type": "bounded type",
165
+ "subjectHash": "hash",
166
+ "issuerHash": "hash",
167
+ "provider": "bounded provider"
168
+ }
169
+ ```
170
+
171
+ The event does not include:
172
+
173
+ - raw URL path
174
+ - raw request body
175
+ - raw response body
176
+ - raw model name
177
+ - bearer token
178
+ - JWT subject or issuer
179
+ - scopes
180
+ - labels
181
+ - provider credentials
182
+
183
+ Model information is represented as `request.modelPresent` and `request.modelHash`.
184
+
185
+ ## 7. Security Properties
186
+
187
+ 1. **No raw PII in usage audit.** Usage events are generated from projected
188
+ fields before they enter the audit sink. The sink's `FORBIDDEN_KEYS` still
189
+ strips `scopes` and `labels` as defense in depth.
190
+ 2. **No high-cardinality metrics.** Usage recorder failures increment one fixed
191
+ counter with no identity labels.
192
+ 3. **No core IdP drift.** Core owns identity projection and accounting contracts
193
+ only. User labels, groups, quotas, and display names belong in future
194
+ satellites.
195
+ 4. **Quota fail-closed contract reserved.** `usage.quota.failClosed` is validated
196
+ now, so 1.9 can enforce provider-error behavior without another config-shape
197
+ change.
198
+ 5. **Default-off compatibility.** Existing configs emit no usage events unless
199
+ `usage.enabled` is explicitly set.
200
+
201
+ ## 8. Tests
202
+
203
+ Added:
204
+
205
+ - `tests/usage-contract.test.mjs`
206
+
207
+ Coverage:
208
+
209
+ - default config and fail-closed provider validation
210
+ - no-op usage/quota provider contracts
211
+ - quota decision normalization
212
+ - PII-safe identity projection
213
+ - end-to-end proxy usage recording
214
+ - hash-chained audit verification with a `usage_recorded` event
215
+ - absence of raw token, email, and model name from usage event and audit log
216
+
217
+ Existing guard updated:
218
+
219
+ - `tests/api-contract.test.mjs` includes the additive `haechi/usage` export and
220
+ the additive top-level `usage` config key.
221
+
222
+ ## 9. Follow-Up: 1.9.0
223
+
224
+ The natural next release is quota enforcement:
225
+
226
+ - call `quotaProvider.check()` after request parse and before upstream forwarding
227
+ - deny over-quota requests before upstream is reached
228
+ - emit `quota_denied` audit records with bounded reason enums
229
+ - add a usage report CLI over audit/usage records
230
+ - document anonymous/global-only quota behavior
@@ -1,6 +1,6 @@
1
1
  # Haechi Release Process
2
2
 
3
- - 문서 상태: Living document (core 1.5.x 추적)
3
+ - 문서 상태: Living document (core 1.7.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 로컬 릴리즈 검증
@@ -1,6 +1,6 @@
1
1
  # Haechi Release Process
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Local Release Verification
@@ -1,9 +1,9 @@
1
1
  # Haechi 리스크 레지스터 및 릴리스 게이트
2
2
 
3
- - 문서 상태: Living document(core 1.6.x 추적)
4
- - 작성일: 2026-06-18
5
- - 기준 버전: 1.6.x
6
- - 기준 브랜치: `main`
3
+ - 문서 상태: Living document(core 1.8.x 추적)
4
+ - 작성일: 2026-06-29
5
+ - 기준 버전: 1.8.x
6
+ - 기준 브랜치: `release/1.8-usage-contracts`
7
7
 
8
8
  ## 1. 현재 판단
9
9
 
@@ -14,9 +14,9 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
14
14
  | 구분 | 판단 | 이유 |
15
15
  |---|---|---|
16
16
  | GitHub public | 허용 | 보안 한계, threat model, shared responsibility가 문서화됨 |
17
- | GitHub release/tag | `v1.6.0` 컷 준비됨 (태그 → attested publish) | `v1.6.0`은 additive minor AES-GCM nonce-budget fail-closed(G13), `haechi/crypto` `readNonceBudget` export + `haechi status` 노출, 명명된 `gate:security` CI 잡; §5.7 / §5.8 항목은 모두 Resolved 유지, G9–G13는 Pass. `v1.5.0`이 직전 릴리스 |
18
- | npm stable | `haechi@1.6.0` (`v1.6.0` 태그에서 publish) | `1.6.0`은 `1.5.x` 기준 위에 키별 nonce-budget 가드 + 운영자 가시성을 더함; additive export만, config/API 파괴 없음(`configVersion`은 `1` 유지) |
19
- | production use | 운영자 게이트; `1.6.0`로 업그레이드 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; 여러 replica를 운영하는 운영자는 공유 저장소(`haechi-store-redis` 위성)를 주입해 audit 해시 체인과 token vault가 플릿 전체에서 유지되도록 해야 |
17
+ | GitHub release/tag | 다음 목표 `v1.8.0` | `v1.8.0`은 `v1.7.0` 위의 additive minor입니다. usage/accounting provider 계약, PII-safe `usage_recorded` 이벤트, 기본 비활성 `usage` 설정, 그리고 `createdAt`/`expiresAt`를 AAD에 묶어 authenticated(변조 저항) freshness를 제공하는 crypto envelope v3 업그레이드를 추가합니다. quota denial은 1.9로 명시적으로 이월합니다. G15/G16이 릴리스 준비 상태를 추적합니다 |
18
+ | npm stable | `haechi@1.7.0` 배포 완료; 다음 목표 `haechi@1.8.0` | `1.8.0`은 frozen `haechi/usage` export, 기본 비활성 additive config key(`usage`), 그리고 frozen `CRYPTO_AAD_ENCODING_V3` export(authenticated envelope freshness, v2는 advisory 유지)를 추가합니다. `configVersion`은 `1` 유지, core는 zero runtime dependency 유지 |
19
+ | production use | 운영자 게이트; `1.8.0` 업그레이드 준비 | 운영자 네트워크 통제, 인가/인증, key custody가 있을 때만 지원; usage event는 PII-safe이지만 audit 주입된 usage store의 retention/access control은 운영자 책임 |
20
20
 
21
21
  ## 2. 릴리스 게이트
22
22
 
@@ -35,7 +35,10 @@ Haechi는 `1.x` stable 라인을 출시했습니다. developer preview 게이트
35
35
  | G10 | 2026-06-16 코드리뷰 round 2 (CR2) 보완 게이트 | CR2 등록부(`code-review-risk-register-2026-06-16-round2.md`, §5.8)는 **P0/P1을 발견하지 못했습니다**; 세 개의 P2(`CR2-001` 프록시 upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply 경계)와 P3 묶음(`CR2-004..008`)이 모두 **Resolved이며 `haechi@1.3.2`로 발행되었고**(`CR2-009` won't-fix, `CR2-010` accepted) 연결된 등록부가 갱신되었습니다. | Pass (`haechi@1.3.2`, 2026-06-16) |
36
36
  | G11 | 1.4.0 signed-plugin 저작 CLI | 1.0 Ed25519 trust gate를 위한 1차 저작 CLI — `plugin-keygen`(개인키 `0600`, 공개키 = trust anchor), `plugin-sign`(정확한 entry 바이트 바인딩), `plugin-verify`(런타임 동등 검증, fail-closed, `--allow-capability`); 개인키가 stdout/audit로 유출되지 않음; 적대적 검증 완료; `plugin-signing-and-trust.md` 큐레이션 런북이 P1-SEC-025 "운영자가 앵커를 큐레이션해야 함" 잔여를 해소. additive CLI 표면(config/API 파괴 없음, `configVersion`은 `1` 유지); `tests/api-contract.test.mjs` green; 코어는 zero runtime dependency 유지; 코어 1.3.3 → 1.4.0(additive minor)로 bump. | Pass (`haechi@1.4.0`, 2026-06-17) |
37
37
  | G12 | 1.5.0 수평 확장 저장소 시임 | audit sink와 token vault가 주입 가능한 **store**를 갖게 되어, 공유 저장소가 sha256 해시 체인 + token vault를 replica 전반에서 뒷받침할 수 있음(프로세스별 / 단일 writer 플릿 한계를 해소). `createAuditSink({store})` / `createTokenVault({store})` + 기본 `createFileAuditStore`/`createFileTokenStore`; 보안에 결정적인 chaining / `sanitizeAudit` / reveal governance / retention은 코어에 남고, store는 배타적 read-previous+persist(audit) / mutate+read(vault) 프리미티브만 추상화. 적대적 검증 완료: 파일 기본값 바이트 동일, chain 연산은 이전과 diff 동일, 비파일 store에서도 시임 동작, 동시 append/tokenize가 비분기·무손실 유지, CR2-002 audit-no-plaintext 유지. 새 export는 `api-stability.md` + `tests/api-contract.test.mjs`에 frozen; `createJsonlAuditSink`/`createLocalTokenVault`는 하위호환 래퍼; 코어는 zero runtime dependency 유지; 코어 1.4.0 → 1.5.0(additive minor)로 bump. `haechi-store-redis` 위성이 운영 소비자. | Pass (`haechi@1.5.0`, 2026-06-17) |
38
- | G13 | 1.6.0 crypto 봉투 무결성 — nonce budget + 보안 CI 게이트 | 스코핑 결과 gap review의 "AAD canonicalization"(GAP-P0-001)은 이미 닫혀 있었음(`canonicalize`가 정렬; 호출마다 신규 랜덤 IV; 복호화가 AAD/변조에 fail-closed, conformance로 외부 provider에도 강제). nonce 절반(GAP-P0-002)을 닫음: 로컬 AES-256-GCM provider가 `kid`별 암호화 횟수를 세어 미리 예약한 윈도우 단위로 키 파일에 영속화(소비 전 기록 → 재시작은 과대집계, 재사용으로의 과소집계 불가)하고, 50%에서 1회 경고, **2^32에서 fail-closed**(NIST SP 800-38D §8.3)하며 `init --force` 회전을 안내. 읽기 전용 키 파일은 프로세스 단위 한도로 degrade(경고 `HAECHI_NONCE_BUDGET_NOPERSIST`); 다중 프로세스 공유는 범위 밖 잔여(단일 writer 레퍼런스 provider; 운영 custody = KMS). 운영자 가시성은 frozen `haechi/crypto` `readNonceBudget` export + `haechi status`(`keys.nonceBudget` 사용%). 또한 GAP-P0-012 추가: 나열된 보안 테스트가 사라지면 요란하게 실패하는, 브랜치 보호 가능한 명명된 `gate:security` CI 잡(`scripts/security-gate.mjs`)이 횡단 보안 invariant를 검사. 결합 mutation 검증(두 가드 동시 무력화 → 스위트 + 게이트 실패). 테스트: `tests/nonce-budget.test.mjs` + `ops-commands` status 단언; api-contract가 `readNonceBudget` 동결. 문서 EN+KO(threat-model §3, operations-runbook §9). core 1.5.0 → 1.6.0(additive minor); zero runtime dependency; `configVersion`은 `1` 유지. 남은 에픽 잔여 deferred: NFKC-on-AAD(v2 봉투 필요), 스트리밍 시퀀스 AAD/replay, 봉투 freshness. | Pass (컷 준비됨; `v1.6.0` 태그에서 publish) |
38
+ | G13 | 1.6.0 crypto 봉투 무결성 — nonce budget + 보안 CI 게이트 | 스코핑 결과 gap review의 "AAD canonicalization"(GAP-P0-001)은 sorted canonical JSON으로 부분 해소되어 있었고, nonce 절반(GAP-P0-002)을 닫음: 로컬 AES-256-GCM provider가 `kid`별 암호화 횟수를 세어 미리 예약한 윈도우 단위로 키 파일에 영속화(소비 전 기록 → 재시작은 과대집계, 재사용으로의 과소집계 불가)하고, 50%에서 1회 경고, **2^32에서 fail-closed**(NIST SP 800-38D §8.3)하며 `init --force` 회전을 안내. 읽기 전용 키 파일은 프로세스 단위 한도로 degrade(경고 `HAECHI_NONCE_BUDGET_NOPERSIST`); 다중 프로세스 공유는 범위 밖 잔여(단일 writer 레퍼런스 provider; 운영 custody = KMS). 운영자 가시성은 frozen `haechi/crypto` `readNonceBudget` export + `haechi status`(`keys.nonceBudget` 사용%). 또한 GAP-P0-012 명명된 `gate:security` CI 잡이 횡단 보안 invariant를 검사. core 1.5.0 → 1.6.0(additive minor); zero runtime dependency; `configVersion`은 `1` 유지. | Pass (`haechi@1.6.0`, 2026-06-18) |
39
+ | G14 | 1.7.0 crypto envelope v2 — NFKC AAD + freshness + KMS parity | 남은 crypto-envelope canonicalization/freshness 절반을 닫음: 새 local-provider envelope는 `v:2`, `aadEncoding:"nfkc-json-v2"`, `createdAt`, optional `expiresAt`를 사용합니다. decrypt는 envelope 버전에 따라 canonicalization을 선택하므로 legacy `v1`/무표기 envelope는 계속 읽을 수 있고, v2는 `canonicalizeCryptoAad()`(정렬 canonical JSON + NFKC-normalized string value/object key)를 해시합니다. 같은 object level에서 NFKC key collision이 나면 두 AAD binding을 조용히 합치지 않고 fail-closed합니다. token-vault ciphertext는 token `expiresAt`를 envelope에 전달해 vault retention check에 더해 crypto 계층에서도 stale ciphertext를 거부합니다. `haechi-crypto-kms@0.3.0`은 동일 helper를 import하고 core peer floor를 `>=1.7.0 <2.0.0`으로 올립니다. NFKC AAD cross-implementation parity 테스트 포함. Additive frozen exports: `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`; `configVersion`은 `1` 유지; core는 zero runtime dependency 유지. 잔여: stream sequence AAD / replay cache는 streaming encryption이 아직 독립 복호화 가능한 stream envelope를 내보내지 않으므로 후속으로 둡니다. | Pass (`haechi@1.7.0`, 2026-06-23) |
40
+ | G15 | 1.8.0 usage/accounting contracts | `haechi/usage`, `providers.usageRecorder`, `providers.quotaProvider`, 기본 비활성 `usage` 설정, PII-safe `usage_recorded` 이벤트를 추가합니다. 이벤트는 bounded/hash-only 필드(`pathHash`, `modelHash`, status/outcome, byte count, duration, 5-field identity projection)만 포함하며 raw body, token, subject/issuer, scopes, labels, path, model name은 포함하지 않습니다. quota denial은 1.9로 이월하며, 1.8은 provider 계약 검증/노출만 수행합니다. Additive export/config surface; `configVersion`은 `1` 유지; core는 zero runtime dependency 유지. | Pass |
41
+ | G16 | 1.8.0 crypto envelope v3 — authenticated freshness | v2(G14)가 남긴 freshness 인증 공백을 닫습니다: 로컬 AES-256-GCM provider의 새 envelope는 `v:3`, `aadEncoding:"nfkc-json-v3"`를 사용합니다. `createdAt`과(제공된 경우) `expiresAt`는 AAD를 구성하기 전에 계산되어 `canonicalizeCryptoAad({ aad, createdAt, expiresAt })`에 결속되므로, 저장된 envelope의 freshness 필드가 변조/삭제/연장되면 더는 암호화 시점에 인증한 바이트로 canonicalize되지 않아 복호화가 GCM auth-tag 검증에서 fail-closed합니다. decrypt는 envelope 버전/`aadEncoding`에 따라 엄격히 AAD canonicalization을 분기하므로 v1과 v2 envelope는 이전과 동일하게 계속 읽을 수 있습니다 — v2의 canonicalization(`canonicalizeCryptoAad(aad)`, freshness 필드 미결속)은 바이트 단위로 변경되지 않았으므로 **기존 v2 envelope의 freshness는 authenticated가 아니라 advisory로 남습니다.** 이는 1.7.0에서 생성된 envelope에 대한 수용된 잔여 위험이며 회귀가 아닙니다. `haechi-crypto-kms`도 동일한 v3 AAD binding을 미러링합니다. token-vault ciphertext는 계속 token `expiresAt`를 envelope에 결속합니다(새 v3 기록에는 이제 authenticated). 새 frozen export `CRYPTO_AAD_ENCODING_V3`(`tests/api-contract.test.mjs`); `configVersion`은 `1` 유지; core는 zero runtime dependency 유지. | Pass |
39
42
 
40
43
  ## 3. P0 배포 차단 리스크 상태
41
44
 
@@ -231,3 +234,4 @@ Haechi는 여전히 다음 용도로는 사용하지 않습니다.
231
234
 
232
235
  - 운영자 자체의 네트워크 통제와 인증 없이 인터넷에 직접 노출되는 proxy
233
236
  - compliance evidence 또는 법적 준수 증명(Haechi는 컴플라이언스 보장이 아님)
237
+ - **Track non-goal:** 관리형 클라우드 추론 백엔드(**AWS Bedrock**, **Google Vertex AI**). Haechi는 local-first 게이트웨이입니다. body-rewriting redaction은 요청/응답 payload를 실시간으로 변형해야 하는데, 이는 Bedrock의 SigV4 요청/바디 서명과 근본적으로 충돌하고(재작성된 바디는 서명을 무효화함) Vertex AI도 같은 부류의 이유로 범위 밖입니다. 이런 제공자에 도달하려면 Haechi 앞에 자체 호스팅 LiteLLM / OpenAI 호환 proxy를 두십시오
@@ -1,9 +1,9 @@
1
1
  # Haechi Risk Register and Release Gates
2
2
 
3
- - Status: Living document (tracks core 1.6.x)
4
- - Date: 2026-06-18
5
- - Target version: 1.6.x
6
- - Branch: `main`
3
+ - Status: Living document (tracks core 1.8.x)
4
+ - Date: 2026-06-29
5
+ - Target version: 1.8.x
6
+ - Branch: `release/1.8-usage-contracts`
7
7
 
8
8
  ## 1. Current Assessment
9
9
 
@@ -14,9 +14,9 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
14
14
  | Category | Judgment | Rationale |
15
15
  |---|---|---|
16
16
  | GitHub public | Allowed | Security limitations, threat model, and shared responsibility are documented |
17
- | GitHub release/tag | `v1.6.0` cut prepared (tag → attested publish) | `v1.6.0` is an additive minor the AES-GCM nonce-budget fail-closed (G13), its `haechi/crypto` `readNonceBudget` export + `haechi status` surfacing, and the named `gate:security` CI job; all §5.7 / §5.8 findings remain Resolved and G9–G13 are Pass. `v1.5.0` is the prior release |
18
- | npm stable | `haechi@1.6.0` (publishes on the `v1.6.0` tag) | `1.6.0` adds the per-key nonce-budget guard + operator visibility over the `1.5.x` baseline; additive export only, no config/API break (`configVersion` stays `1`) |
19
- | Production use | Operator-gated; upgrade to `1.6.0` | Supported only with operator network controls, authz/authn, and key custody; operators running multiple replicas should inject a shared store (the `haechi-store-redis` satellite) so the audit hash chain and token vault hold across the fleet |
17
+ | GitHub release/tag | Next target `v1.8.0` | `v1.8.0` is an additive minor over `v1.7.0`: usage/accounting provider contracts, PII-safe `usage_recorded` events, default-off `usage` config, and the crypto envelope v3 upgrade that binds `createdAt`/`expiresAt` into the AAD for authenticated (tamper-resistant) freshness. No quota denial yet; G15/G16 track release readiness |
18
+ | npm stable | `haechi@1.7.0` published; next target `haechi@1.8.0` | `1.8.0` adds frozen `haechi/usage` exports, a default-off additive config key (`usage`), and the frozen `CRYPTO_AAD_ENCODING_V3` export (authenticated envelope freshness, v2 stays advisory); `configVersion` stays `1`; core remains zero runtime dependency |
19
+ | Production use | Operator-gated; prepare upgrade to `1.8.0` | Supported only with operator network controls, authz/authn, and key custody; usage events are PII-safe but operators still own retention/access controls for audit and any injected usage store |
20
20
 
21
21
  ## 2. Release Gates
22
22
 
@@ -35,7 +35,10 @@ Haechi has shipped its `1.x` stable line. The developer-preview gate (G2, `haech
35
35
  | G10 | 2026-06-16 code-review round 2 (CR2) remediation gate | The CR2 register (`code-review-risk-register-2026-06-16-round2.md`, §5.8) found **no P0/P1**; its three P2s (`CR2-001` proxy upstream-cancel, `CR2-002` token-vault audit hygiene, `CR2-003` plugin IPC reply bound) plus the P3 cluster (`CR2-004..008`) are all **Resolved and shipped in `haechi@1.3.2`** (`CR2-009` won't-fix, `CR2-010` accepted) and the linked register is updated. | Pass (`haechi@1.3.2`, 2026-06-16) |
36
36
  | G11 | 1.4.0 signed-plugin authoring CLI | First-party CLI for the 1.0 Ed25519 trust gate — `plugin-keygen` (private key `0600`, public key = trust anchor), `plugin-sign` (binds the exact entry bytes), `plugin-verify` (runtime-equivalent verification, fail-closed, `--allow-capability`); no private-key leak to stdout/audit; adversarially verified; the `plugin-signing-and-trust.md` curation runbook closes the P1-SEC-025 "operator must curate anchors" residual. Additive CLI surface (no config/API break, `configVersion` stays `1`); `tests/api-contract.test.mjs` green; core stays zero runtime dependency; core bumped 1.3.3 → 1.4.0 (additive minor). | Pass (`haechi@1.4.0`, 2026-06-17) |
37
37
  | G12 | 1.5.0 horizontal-scale store seams | The audit sink and token vault gain an injectable **store** so a shared store can back the sha256 hash chain + token vault across replicas (closes the per-process / single-writer fleet limitations). `createAuditSink({store})` / `createTokenVault({store})` + the default `createFileAuditStore`/`createFileTokenStore`; the security-critical chaining / `sanitizeAudit` / reveal governance / retention stay in core, the store only abstracts the exclusive read-previous+persist (audit) / mutate+read (vault) primitives. Adversarially verified: file defaults byte-identical, chain math diff-identical to prior, the seam works for a non-file store, concurrent appends/tokenize stay non-forked/lossless, CR2-002 audit-no-plaintext intact. New exports frozen in `api-stability.md` + `tests/api-contract.test.mjs`; `createJsonlAuditSink`/`createLocalTokenVault` are back-compat wrappers; core stays zero runtime dependency; core bumped 1.4.0 → 1.5.0 (additive minor). The `haechi-store-redis` satellite is the production consumer. | Pass (`haechi@1.5.0`, 2026-06-17) |
38
- | G13 | 1.6.0 crypto envelope integrity — nonce budget + security CI gate | Scoping confirmed the gap review's "AAD canonicalization" (GAP-P0-001) was already closed (`canonicalize` sorts keys; fresh random IV per call; decrypt fail-closes on AAD/tamper, conformance-enforced). Closes the nonce half (GAP-P0-002): the local AES-256-GCM provider counts encryptions per `kid`, persists the count to the key file in pre-reserved windows (written before consumption → restart over-counts, never under-counts into reuse), warns once at 50%, and **fails closed at 2^32** (NIST SP 800-38D §8.3) instructing `init --force`. A read-only key file degrades to a per-process limit (warned, `HAECHI_NONCE_BUDGET_NOPERSIST`); multi-process sharing is an out-of-scope residual (single-writer reference provider; prod custody = KMS). Operator visibility via the new frozen `haechi/crypto` `readNonceBudget` export + `haechi status` (`keys.nonceBudget` used%). Also adds GAP-P0-012: a named, branch-protectable `gate:security` CI job (`scripts/security-gate.mjs`) over the cross-cutting security invariants that fails loudly if a listed security test goes missing. Combined-mutation verified (both guards off → suite + gate fail). Tests: `tests/nonce-budget.test.mjs` + the `ops-commands` status assertion; api-contract freezes `readNonceBudget`. Docs EN+KO (threat-model §3, operations-runbook §9). Core bumped 1.5.0 → 1.6.0 (additive minor); zero runtime dependency; `configVersion` stays `1`. Remaining epic residuals deferred: NFKC-on-AAD (needs v2 envelope), streaming sequence AAD/replay, envelope freshness. | Pass (cut prepared; publishes on the `v1.6.0` tag) |
38
+ | G13 | 1.6.0 crypto envelope integrity — nonce budget + security CI gate | Scoping confirmed the gap review's "AAD canonicalization" (GAP-P0-001) was partially closed by sorted canonical JSON, and closed the nonce half (GAP-P0-002): the local AES-256-GCM provider counts encryptions per `kid`, persists the count to the key file in pre-reserved windows (written before consumption → restart over-counts, never under-counts into reuse), warns once at 50%, and **fails closed at 2^32** (NIST SP 800-38D §8.3) instructing `init --force`. A read-only key file degrades to a per-process limit (warned, `HAECHI_NONCE_BUDGET_NOPERSIST`); multi-process sharing is an out-of-scope residual (single-writer reference provider; prod custody = KMS). Operator visibility via the frozen `haechi/crypto` `readNonceBudget` export + `haechi status` (`keys.nonceBudget` used%). Also adds GAP-P0-012: a named, branch-protectable `gate:security` CI job (`scripts/security-gate.mjs`) over cross-cutting security invariants. Core bumped 1.5.0 → 1.6.0 (additive minor); zero runtime dependency; `configVersion` stays `1`. | Pass (`haechi@1.6.0`, 2026-06-18) |
39
+ | G14 | 1.7.0 crypto envelope v2 — NFKC AAD + freshness + KMS parity | Closes the remaining crypto-envelope canonicalization/freshness half: new local-provider envelopes use `v:2`, `aadEncoding:"nfkc-json-v2"`, `createdAt`, and an optional `expiresAt`; decrypt chooses canonicalization by envelope version so legacy `v1`/unencoded envelopes remain readable, while v2 hashes `canonicalizeCryptoAad()` (sorted canonical JSON with NFKC-normalized string values and object keys). NFKC key collisions at one object level fail closed rather than collapse two AAD bindings. Token-vault ciphertext passes the token `expiresAt` into the envelope so stale vault ciphertext is rejected by crypto as defense-in-depth in addition to vault retention checks. `haechi-crypto-kms@0.3.0` imports the same helper and raises its core peer floor to `>=1.7.0 <2.0.0`; cross-implementation parity is tested against NFKC AAD. Additive frozen exports: `canonicalizeCryptoAad`, `CRYPTO_AAD_ENCODING_V2`; `configVersion` stays `1`; core stays zero runtime dependency. Remaining residual: stream sequence AAD / replay cache for encrypted stream fragments remains deferred because streaming encryption still emits transformed text, not independently decryptable stream envelopes. | Pass (`haechi@1.7.0`, 2026-06-23) |
40
+ | G15 | 1.8.0 usage/accounting contracts | Adds `haechi/usage`, `providers.usageRecorder`, `providers.quotaProvider`, a default-off `usage` config section, and PII-safe `usage_recorded` events. Events contain bounded/hash-only fields (`pathHash`, `modelHash`, status/outcome, byte counts, duration, five-field identity projection) and never raw body, token, subject/issuer, scopes, labels, path, or model name. Quota denial is explicitly deferred to 1.9; 1.8 validates/exposes the provider contract only. Additive export/config surface; `configVersion` stays `1`; core stays zero runtime dependency. | Pass |
41
+ | G16 | 1.8.0 crypto envelope v3 — authenticated freshness | Closes the freshness-authentication gap left open by v2 (G14): the local AES-256-GCM provider's new envelopes are `v:3` with `aadEncoding:"nfkc-json-v3"`; `createdAt` and (when supplied) `expiresAt` are computed before the AAD is built and folded into `canonicalizeCryptoAad({ aad, createdAt, expiresAt })`, so a tampered/stripped/extended freshness field on a stored envelope no longer canonicalizes to the bytes the encryptor authenticated and decryption fails closed on the GCM auth-tag check. Decrypt dispatches AAD canonicalization strictly by envelope version/`aadEncoding`, so v1 and v2 envelopes remain readable exactly as before — v2's canonicalization (`canonicalizeCryptoAad(aad)`, freshness fields NOT bound) is byte-for-byte unchanged, so **freshness on existing v2 envelopes stays advisory only, not authenticated**; this is an accepted residual for envelopes created under 1.7.0, not a regression. `haechi-crypto-kms` mirrors the same v3 AAD binding. Token-vault ciphertext continues to bind its token `expiresAt` into the envelope (now authenticated for new v3 writes). New frozen export `CRYPTO_AAD_ENCODING_V3` (`tests/api-contract.test.mjs`); `configVersion` stays `1`; core stays zero runtime dependency. | Pass |
39
42
 
40
43
  ## 3. P0 Distribution-Blocking Risk Status
41
44
 
@@ -239,3 +242,4 @@ Haechi is still not intended for the following uses:
239
242
 
240
243
  - A proxy directly exposed to the internet without the operator's own network controls and authentication
241
244
  - Compliance evidence or legal conformance proof (Haechi is not a compliance guarantee)
245
+ - **Track non-goal:** a managed-cloud inference backend (**AWS Bedrock**, **Google Vertex AI**). Haechi is a local-first gateway; body-rewriting redaction requires mutating the request/response payload in flight, which is fundamentally incompatible with Bedrock's SigV4 request/body signing (a rewritten body invalidates the signature) and is out of scope for Vertex AI for the same class of reasons. Reach these providers through a self-hosted LiteLLM / OpenAI-compatible proxy placed in front of Haechi
@@ -1,6 +1,6 @@
1
1
  # Haechi Shared Responsibility
2
2
 
3
- - 문서 상태: Living document (core 1.5.x 추적)
3
+ - 문서 상태: Living document (core 1.7.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 책임 매트릭스
@@ -1,6 +1,6 @@
1
1
  # Haechi Shared Responsibility
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.7.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Responsibility Matrix
@@ -1,6 +1,6 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - 문서 상태: Living document(core 1.5.x 추적)
3
+ - 문서 상태: Living document(core 1.8.x 추적)
4
4
  - 작성일: 2026-06-10
5
5
 
6
6
  ## 1. 보호 대상
@@ -13,7 +13,7 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
13
13
  | Tool/resource result | MCP result, local inference response | 응답 내 PII/secret 재유출 차단 |
14
14
  | TokenVault record | tokenized PII mapping | 저장 시 암호화, reveal 기본 차단 |
15
15
  | Audit event | detection metadata, decision summary | 평문 비포함, hash chain 무결성 |
16
- | Crypto envelope | encrypted segments | canonical AAD binding, key provider 교체성 |
16
+ | Crypto envelope | encrypted segments | versioned NFKC AAD binding; v3 envelope는 `createdAt`/`expiresAt`를 AAD에 묶어 authenticated(변조 저항) freshness를 제공하고, v2 freshness는 advisory-only로 남음; key provider 교체성 |
17
17
  | Plugin manifest | custom provider/filter declaration | capability disclosure, dynamic runtime 차단 |
18
18
 
19
19
  ## 2. 신뢰 경계
@@ -55,6 +55,7 @@ Haechi가 보호하려는 주요 자산은 다음과 같습니다.
55
55
  | Audit tail truncation | 꼬리 audit 레코드의 무음 삭제 | 추가 전용/별도 미디어의 `audit.anchor` head-hash anchoring으로 마지막 anchor까지의 절단 탐지 (0.7) |
56
56
  | Local dev key in production | 소프트웨어 키의 운영 custody 오용 | `assertCryptoProviderConformance`를 통한 외부 `cryptoProvider` 주입; reference KMS adapter (envelope 암호화) |
57
57
  | 단일 키의 GCM nonce 고갈 | 로컬 AES-256-GCM provider는 랜덤 96-bit IV를 쓰며, 한 키로 ~2^32회 암호화를 넘기면 birthday bound로 IV 충돌(GCM에 치명적 — 평문 XOR 누출 + 위조 가능) 확률이 무시할 수 없게 됨 | 로컬 provider는 **키당 2^32회 암호화에서 fail-closed**(NIST SP 800-38D §8.3) — 암호화를 거부하고 `haechi init --force` 회전을 안내. 호출 수는 kid별로 카운트되어 미리 예약한 윈도우 단위로 키 파일에 영속화되므로 재시작을 넘겨도 유지됨(과대집계는 가능, 재사용으로의 과소집계는 불가). 50%에서 1회 경고. **수용된 잔여 위험:** 읽기 전용 키 파일은 **프로세스 단위** 한도로 degrade(경고 `HAECHI_NONCE_BUDGET_NOPERSIST`)되고, 하나의 키 파일을 여러 프로세스가 공유하는 경우는 범위 밖(로컬 provider는 단일 writer 레퍼런스이며, 운영 custody는 자체 nonce 규율을 갖는 KMS 위성 사용) |
58
+ | Unicode AAD spoofing 또는 stale ciphertext replay | full-width key/value, compatibility 문자 등 시각적으로 동등한 Unicode AAD로 복호화 context를 흔들거나, 암호학적으로 결속되지 않은 envelope의 freshness 필드를 그대로 신뢰함 | 새 crypto envelope는 `v:3`, `aadEncoding:"nfkc-json-v3"`를 사용합니다. `canonicalizeCryptoAad()`가 string value와 object key를 NFKC 정규화한 뒤 정렬 canonical JSON으로 해시하며 — v3에서 새로 추가된 부분으로 — envelope의 `createdAt`과 선택적 `expiresAt`도 GCM tag를 계산하기 전에 같은 AAD에 결속됩니다. 저장된 envelope의 두 필드 중 하나를 변조(`expiresAt` 제거/연장, `createdAt` 소급)하면 더는 암호화 시점에 인증했던 바이트로 canonicalize되지 않으므로 복호화가 **auth-tag 검증에서 fail-closed**합니다: v3 freshness는 advisory가 아니라 authenticated입니다. decrypt는 여전히 envelope 버전별로 AAD canonicalization을 분기하므로, v2(`aadEncoding:"nfkc-json-v2"`, 1.7.0에서 출시)와 legacy v1 envelope는 이전과 동일하게 계속 읽을 수 있고, 같은 object level의 NFKC key collision은 모든 버전에서 여전히 fail-closed합니다. **v2 freshness는 advisory일 뿐 변조 저항이 아닙니다:** v2의 AAD canonicalization은 `createdAt`/`expiresAt`를 포함하지 않으므로 이 필드들은 AEAD 경계 밖에 있습니다 — 저장된 v2 envelope의 평문 JSON wrapper를 다시 쓸 수 있는 주체는 GCM tag를 무효화하지 않고도 `expiresAt`를 바꾸거나 지울 수 있습니다. freshness check(`assertEnvelopeFresh`)는 여전히 실행되지만 주어진 필드 값을 그대로 신뢰합니다. 이는 **1.7.0에서 생성된 envelope에 대한 수용된 잔여 위험**이며 새로운 결함이 아닙니다. 해당 레코드에 authenticated freshness가 필요한 운영자는 v3를 지원하는 provider로 재암호화해야 합니다. `haechi-crypto-kms`도 동일한 v3 AAD binding을 미러링합니다. Token-vault ciphertext는 계속 token `expiresAt`를 envelope에 전달합니다(새 v3 기록에는 authenticated, 기존 v2 ciphertext에는 advisory). **잔여:** streaming transform은 아직 독립 복호화 가능한 stream envelope를 만들지 않으므로 stream sequence AAD / replay cache는 후속입니다 |
58
59
  | Tampered release artifact | 변조된 tarball 설치 | npm provenance + GitHub release tarball의 sigstore attestation + `SHA256SUMS` (0.7) |
59
60
  | audit에 원시 credentials/identity 노출 | audit 로그를 통한 token 또는 subject 유출 | Token은 keyed-HMAC 해시로만 저장; identity subject/issuer는 keyed HMAC 처리; `auth_denied` 레코드에 token 미포함 |
60
61
  | token round-trip의 타 토큰 복원 | 클라이언트/요청 간 평문 복구 | detokenization은 opt-in(`detokenizeResponses`)이며 요청 스코프: 같은 요청을 보호하며 발급된 토큰만 복원 |
@@ -1,6 +1,6 @@
1
1
  # Haechi Threat Model
2
2
 
3
- - Status: Living document (tracks core 1.5.x)
3
+ - Status: Living document (tracks core 1.8.x)
4
4
  - Date: 2026-06-10
5
5
 
6
6
  ## 1. Assets Under Protection
@@ -13,7 +13,7 @@ The primary assets Haechi protects are:
13
13
  | Tool/resource result | MCP result, local inference response | Prevent re-leakage of PII/secrets in responses |
14
14
  | TokenVault record | tokenized PII mapping | Encrypted at rest, reveal blocked by default |
15
15
  | Audit event | detection metadata, decision summary | No plaintext content, hash chain integrity |
16
- | Crypto envelope | encrypted segments | Canonical AAD binding, swappable key provider |
16
+ | Crypto envelope | encrypted segments | Versioned NFKC AAD binding; v3 envelopes bind `createdAt`/`expiresAt` into the AAD for authenticated (tamper-resistant) freshness, v2 freshness stays advisory-only; swappable key provider |
17
17
  | Plugin manifest | custom provider/filter declaration | Capability disclosure, dynamic runtime blocked |
18
18
 
19
19
  ## 2. Trust Boundaries
@@ -55,6 +55,7 @@ The primary assets Haechi protects are:
55
55
  | Audit tail truncation | Silent deletion of trailing audit records | `audit.anchor` head-hash anchoring on append-only/separate media detects truncation back to the last anchor (0.7) |
56
56
  | Local dev key in production | Software key misused as production custody | External `cryptoProvider` injection with `assertCryptoProviderConformance`; reference KMS adapter (envelope encryption) |
57
57
  | GCM nonce exhaustion under one key | The local AES-256-GCM provider uses random 96-bit IVs; past ~2^32 encryptions under one key the birthday bound makes an IV collision (catastrophic for GCM — leaks plaintext XOR + enables forgery) non-negligible | The local provider **fails closed at 2^32 encryptions per key** (NIST SP 800-38D §8.3) — it refuses to encrypt and instructs `haechi init --force` to rotate. Invocations are counted per-kid, persisted to the key file in pre-reserved windows so the count survives restarts (over-counts, never under-counts into reuse); a one-time warning fires at 50%. **Accepted residual:** a read-only key file degrades to a per-PROCESS limit (warned, `HAECHI_NONCE_BUDGET_NOPERSIST`) and multiple processes sharing one key file are out of scope (the local provider is the single-writer reference; production custody uses a KMS satellite that owns its own nonce discipline) |
58
+ | Unicode AAD spoofing or stale ciphertext replay | A caller uses visually equivalent Unicode in AAD (full-width keys/values, compatibility characters) to destabilize decryption context, or a stale/tampered envelope's freshness fields are trusted without being cryptographically bound | New crypto envelopes are `v:3` with `aadEncoding:"nfkc-json-v3"`: `canonicalizeCryptoAad()` NFKC-normalizes string values and object keys before sorted canonical JSON hashing, and — new in v3 — the envelope's `createdAt` and optional `expiresAt` are folded into that same AAD before the GCM tag is computed. Tampering either field on a stored envelope (stripping/extending `expiresAt`, back-dating `createdAt`) no longer canonicalizes to the bytes the encryptor authenticated, so decryption **fails closed on the auth-tag check**: v3 freshness is authenticated, not merely advisory. Decrypt still dispatches AAD canonicalization by envelope version, so v2 (`aadEncoding:"nfkc-json-v2"`, shipped in 1.7.0) and legacy v1 envelopes remain readable exactly as before, and NFKC key collisions at one object level still fail closed for every version. **v2 freshness is advisory only, not tamper-resistant:** v2's AAD canonicalization does not include `createdAt`/`expiresAt`, so those fields sit outside the AEAD boundary — a party able to rewrite a stored v2 envelope's plaintext JSON wrapper can alter or strip `expiresAt` without invalidating the GCM tag; the freshness check (`assertEnvelopeFresh`) still runs but trusts the field's value as given. This is an **accepted residual for envelopes created under 1.7.0**, not a new gap; operators who need authenticated freshness on those records should re-encrypt them under a v3-capable provider. `haechi-crypto-kms` mirrors the same v3 AAD binding. Token-vault ciphertext continues to pass its token `expiresAt` into the envelope (authenticated for new v3 writes, advisory for pre-existing v2 ciphertext). **Residual:** stream sequence AAD / replay cache is still deferred because streaming transforms do not create independently decryptable stream envelopes |
58
59
  | Tampered release artifact | Modified tarball installed | npm provenance + sigstore attestation of the GitHub release tarball + `SHA256SUMS` (0.7) |
59
60
  | Raw credentials/identity in audit | Token or subject leak through the audit log | Tokens stored only as keyed-HMAC hashes; identity subject/issuer are keyed HMAC; `auth_denied` records no token |
60
61
  | Token round-trip restoring foreign tokens | Cross-client/request plaintext recovery | Detokenization is opt-in (`detokenizeResponses`) and request-scoped: only tokens issued while protecting the same request are restored |
@@ -84,6 +84,15 @@
84
84
  "metrics": {
85
85
  "enabled": true
86
86
  },
87
+ "usage": {
88
+ "enabled": false,
89
+ "audit": true,
90
+ "recorder": "none",
91
+ "quota": {
92
+ "provider": "none",
93
+ "failClosed": true
94
+ }
95
+ },
87
96
  "auth": {
88
97
  "provider": "none",
89
98
  "store": ".haechi/auth.json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haechi",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Self-hosted AI context enforcement across LLM, MCP, vLLM, Ollama, and agent traffic — a stable, zero-dependency security gateway.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,7 +52,8 @@
52
52
  "./stream-filter": "./packages/stream-filter/index.mjs",
53
53
  "./auth": "./packages/auth/index.mjs",
54
54
  "./ssrf": "./packages/ssrf/index.mjs",
55
- "./metrics": "./packages/metrics/index.mjs"
55
+ "./metrics": "./packages/metrics/index.mjs",
56
+ "./usage": "./packages/usage/index.mjs"
56
57
  },
57
58
  "files": [
58
59
  "README.md",
@@ -14,6 +14,7 @@ import { applyPrivacyProfile, getPrivacyProfile } from "../privacy-profiles/inde
14
14
  import { createBearerAuthProvider } from "../auth/index.mjs";
15
15
  import { createSandboxedAuthProviderSync, createProcessIsolatedAuthProviderSync } from "../plugin/index.mjs";
16
16
  import { DEFAULT_PROXY_PORT, hasUsableTlsMaterial } from "../proxy/index.mjs";
17
+ import { coerceQuotaProvider, coerceUsageRecorder, createNoopQuotaProvider, createNoopUsageRecorder } from "../usage/index.mjs";
17
18
 
18
19
  // Capability keys an operator may allowlist for a plugin. Mirrors the plugin
19
20
  // manifest's declared-capability set plus the authProvider-specific
@@ -149,6 +150,20 @@ export function defaultConfig() {
149
150
  metrics: {
150
151
  enabled: true
151
152
  },
153
+ // 1.8 usage/accounting contract. Disabled by default to preserve 1.7
154
+ // behavior. When enabled, the proxy emits PII-safe usage events through an
155
+ // injectable usageRecorder and, optionally, the existing hash-chained audit
156
+ // sink. Quota enforcement is only a reserved contract here; 1.9 wires denial
157
+ // decisions before upstream forwarding.
158
+ usage: {
159
+ enabled: false,
160
+ audit: true,
161
+ recorder: "none",
162
+ quota: {
163
+ provider: "none",
164
+ failClosed: true
165
+ }
166
+ },
152
167
  auth: {
153
168
  provider: "none",
154
169
  store: ".haechi/auth.json",
@@ -345,6 +360,9 @@ export function createRuntime(config, providers = {}) {
345
360
  const metrics = providers.metrics ?? createMetrics();
346
361
  assertProvider("metrics", metrics, ["increment", "observe", "render"]);
347
362
 
363
+ const usageRecorder = resolveUsageRecorder(normalized, providers);
364
+ const quotaProvider = resolveQuotaProvider(normalized, providers);
365
+
348
366
  return {
349
367
  config: normalized,
350
368
  tokenVault,
@@ -353,6 +371,8 @@ export function createRuntime(config, providers = {}) {
353
371
  policyProfiles,
354
372
  rateLimiter,
355
373
  metrics,
374
+ usageRecorder,
375
+ quotaProvider,
356
376
  protocolAdapter: createProtocolAdapter(normalized.target),
357
377
  haechi: createHaechi({
358
378
  mode: normalized.mode,
@@ -439,6 +459,14 @@ export function normalizeConfig(config) {
439
459
  ...defaultConfig().metrics,
440
460
  ...(config.metrics ?? {})
441
461
  },
462
+ usage: {
463
+ ...defaultConfig().usage,
464
+ ...(config.usage ?? {}),
465
+ quota: {
466
+ ...defaultConfig().usage.quota,
467
+ ...(config.usage?.quota ?? {})
468
+ }
469
+ },
442
470
  auth: {
443
471
  ...defaultConfig().auth,
444
472
  ...(config.auth ?? {}),
@@ -531,6 +559,7 @@ export function normalizeConfig(config) {
531
559
  if (typeof merged.metrics.enabled !== "boolean") {
532
560
  throw new Error("metrics.enabled must be boolean");
533
561
  }
562
+ validateUsage(merged.usage);
534
563
  if (!["fail-closed", "allow"].includes(merged.responseProtection.failureMode)) {
535
564
  throw new Error(`Invalid responseProtection.failureMode: ${merged.responseProtection.failureMode}`);
536
565
  }
@@ -783,6 +812,30 @@ function assertRate(value, label) {
783
812
  }
784
813
  }
785
814
 
815
+ function validateUsage(usage) {
816
+ if (typeof usage !== "object" || usage === null || Array.isArray(usage)) {
817
+ throw new Error("usage must be an object");
818
+ }
819
+ if (typeof usage.enabled !== "boolean") {
820
+ throw new Error("usage.enabled must be boolean");
821
+ }
822
+ if (typeof usage.audit !== "boolean") {
823
+ throw new Error("usage.audit must be boolean");
824
+ }
825
+ if (!["none", "external"].includes(usage.recorder)) {
826
+ throw new Error(`Invalid usage.recorder: ${usage.recorder}`);
827
+ }
828
+ if (typeof usage.quota !== "object" || usage.quota === null || Array.isArray(usage.quota)) {
829
+ throw new Error("usage.quota must be an object");
830
+ }
831
+ if (!["none", "external"].includes(usage.quota.provider)) {
832
+ throw new Error(`Invalid usage.quota.provider: ${usage.quota.provider}`);
833
+ }
834
+ if (typeof usage.quota.failClosed !== "boolean") {
835
+ throw new Error("usage.quota.failClosed must be boolean");
836
+ }
837
+ }
838
+
786
839
  // Enumerated, fail-closed validation of auth.provider:"plugin" (1.0 §2.3). Every
787
840
  // rule throws a distinct error so a bad option is attributable. Mirrors the
788
841
  // keys/tokenVault rigor — no silent degradation.
@@ -1034,6 +1087,20 @@ function resolveAuthProvider(config, providers, cryptoProvider, auditSink) {
1034
1087
  return null;
1035
1088
  }
1036
1089
 
1090
+ function resolveUsageRecorder(config, providers) {
1091
+ if (config.usage.recorder === "external" && !providers.usageRecorder) {
1092
+ throw new Error("usage.recorder external requires createRuntime(config, { usageRecorder })");
1093
+ }
1094
+ return coerceUsageRecorder(providers.usageRecorder ?? createNoopUsageRecorder());
1095
+ }
1096
+
1097
+ function resolveQuotaProvider(config, providers) {
1098
+ if (config.usage.quota.provider === "external" && !providers.quotaProvider) {
1099
+ throw new Error("usage.quota.provider external requires createRuntime(config, { quotaProvider })");
1100
+ }
1101
+ return coerceQuotaProvider(providers.quotaProvider ?? createNoopQuotaProvider());
1102
+ }
1103
+
1037
1104
  // The config form is a non-empty array of { keyId, publicKey } OR an object map.
1038
1105
  // verifySignedPlugin resolves the anchor by signerKeyId against an object map, so
1039
1106
  // normalize an array form into that map here.