diff --git a/.doc/test-reports/2026-09-15-http-error-envelope-h1-h3-test-report.md b/.doc/test-reports/2026-09-15-http-error-envelope-h1-h3-test-report.md
new file mode 100644
index 00000000..4fc694b5
--- /dev/null
+++ b/.doc/test-reports/2026-09-15-http-error-envelope-h1-h3-test-report.md
@@ -0,0 +1,54 @@
+# HTTP/SSE 错误信封 H1–H3 测试报告
+
+## 一、 测试背景 (Background)
+
+H0 盘点公开路由存在纯文本 `err.Error()`、JSON `{error,message}` 与 SSE `error_info` 三套错误体。本波 additive 统一信封,不改状态码。
+
+## 二、 测试目标 (Test Objectives)
+
+- H1:`code`/`message`/`request_id`/`error` 双写;SQL/路径/token/XSS 不出现在响应。
+- H2:前端优先 `code`;未知 code 不靠 substring。
+- H3:去掉「无权访问该项目」文本匹配;不改状态码故无 ADR。
+
+## 三、 测试方案与环境 (Test Plan & Environment)
+
+| 项 | 值 |
+|----|-----|
+| 分支 | `feature/http-error-envelope` |
+| Go | `go test -short ./internal/httpx ./internal/stream ./internal/server` |
+| 前端 | `npx tsx --test src/api/projectSettingsError.test.ts` |
+
+## 四、 预期结果 (Expected Results)
+
+信封 golden 负例 PASS;server 短测 PASS;前端解析 `code` 优先于 `error`。
+
+## 五、 实际测试结果与判定 (Actual Results & Evaluation)
+
+### 5.1 httpx + SSE
+
+- **测试输入 (Input)**:`TestWriteSanitizesInternalAndKeepsLegacyCode`;`TestClassifyError_DoesNotLeakSQL`
+- **实际输出 (Actual Output)**:`ok httpx`;`ok stream`
+- **评价判定 (Evaluation / Verdict)**:**PASS**
+- **性能与耗时 (Duration & Latency)**:<10ms
+
+### 5.2 server 回归
+
+- **测试输入 (Input)**:`go test -short ./internal/server`
+- **实际输出 (Actual Output)**:`ok 10.069s`
+- **评价判定 (Evaluation / Verdict)**:**PASS**
+- **性能与耗时 (Duration & Latency)**:10s
+
+### 5.3 前端
+
+- **测试输入 (Input)**:`projectSettingsError.test.ts`
+- **实际输出 (Actual Output)**:5 tests pass
+- **评价判定 (Evaluation / Verdict)**:**PASS**
+- **性能与耗时 (Duration & Latency)**:113ms
+
+## 六、 自动化测试套件汇总 (Test Suite Execution)
+
+上述均 PASS。未改 HTTP 状态码,无 ADR。
+
+## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
+
+H1–H3 完成。`error` 字段仍作稳定 code 别名以兼容旧客户端。下一步 PI typed-result。
diff --git a/internal/httpx/envelope.go b/internal/httpx/envelope.go
new file mode 100644
index 00000000..56c6fb52
--- /dev/null
+++ b/internal/httpx/envelope.go
@@ -0,0 +1,123 @@
+package httpx
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "strings"
+)
+
+const (
+ CodeAUTHZ = "AUTHZ"
+ CodeVALIDATION = "VALIDATION"
+ CodeVERSION_CONFLICT = "VERSION_CONFLICT"
+ CodeIDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT"
+ CodeINTERNAL = "INTERNAL"
+ HeaderRequestID = "X-Request-ID"
+)
+
+type Envelope struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ RequestID string `json:"request_id"`
+ Error string `json:"error"`
+ Details map[string]any `json:"details,omitempty"`
+}
+
+func CodeFromStatus(status int) string {
+ switch status {
+ case http.StatusUnauthorized, http.StatusForbidden:
+ return CodeAUTHZ
+ case http.StatusConflict:
+ return CodeVERSION_CONFLICT
+ case http.StatusBadRequest, http.StatusUnprocessableEntity:
+ return CodeVALIDATION
+ default:
+ if status >= 500 {
+ return CodeINTERNAL
+ }
+ if status == http.StatusNotFound {
+ return CodeAUTHZ
+ }
+ return CodeVALIDATION
+ }
+}
+
+func SafeMessage(code string) string {
+ switch code {
+ case CodeAUTHZ:
+ return "无权访问"
+ case CodeVALIDATION:
+ return "请求参数无效"
+ case CodeVERSION_CONFLICT:
+ return "资源版本冲突"
+ case CodeIDEMPOTENCY_CONFLICT:
+ return "重复提交"
+ default:
+ return "请求无法完成"
+ }
+}
+
+func LooksInternal(s string) bool {
+ low := strings.ToLower(s)
+ needles := []string{"sql", "pq:", "postgres", "stack", "panic", "token", "password", "secret", "api_key", "bearer ", "../", "prompt"}
+ for _, n := range needles {
+ if strings.Contains(low, n) {
+ return true
+ }
+ }
+ if strings.Contains(s, "/home/") || strings.Contains(s, "/var/") || strings.Contains(s, "\\") {
+ return true
+ }
+ return false
+}
+
+func RequestID(r *http.Request) string {
+ if r == nil {
+ return newRequestID()
+ }
+ if v := strings.TrimSpace(r.Header.Get(HeaderRequestID)); v != "" {
+ return v
+ }
+ return newRequestID()
+}
+
+func newRequestID() string {
+ var b [8]byte
+ _, _ = rand.Read(b[:])
+ return "req_" + hex.EncodeToString(b[:])
+}
+
+func PublicLegacy(code, legacy string) string {
+ legacy = strings.TrimSpace(legacy)
+ if legacy == "" || LooksInternal(legacy) {
+ return code
+ }
+ if strings.HasPrefix(legacy, "<") || strings.Contains(legacy, "", "req_3", nil)
+ if strings.Contains(rec.Body.String(), "