diff --git a/cmd/object-api-options-replication_test.go b/cmd/object-api-options-replication_test.go
new file mode 100644
index 000000000..e4bcee269
--- /dev/null
+++ b/cmd/object-api-options-replication_test.go
@@ -0,0 +1,114 @@
+// Copyright (c) 2015-2025 MinIO, Inc.
+// Copyright (c) 2025-2026 PGSTY
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "encoding/base64"
+ "net/http"
+ "reflect"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ xhttp "github.com/minio/minio/internal/http"
+)
+
+func TestPutOptsFromHeadersReplicationTimestamps(t *testing.T) {
+ stamp := time.Date(2026, 9, 15, 1, 2, 3, 123456789, time.UTC)
+ context := base64.StdEncoding.EncodeToString([]byte(`{"purpose":"tag-replication"}`))
+ for _, encryption := range []struct {
+ name string
+ headers map[string]string
+ }{
+ {name: "none"},
+ {name: "SSE-S3", headers: map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionAES}},
+ {name: "SSE-KMS", headers: map[string]string{xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS}},
+ {name: "SSE-KMS-context", headers: map[string]string{
+ xhttp.AmzServerSideEncryption: xhttp.AmzEncryptionKMS, xhttp.AmzServerSideEncryptionKmsID: "tag-replication-key",
+ xhttp.AmzServerSideEncryptionKmsContext: context,
+ }},
+ {name: "SSE-C", headers: ssecKeyHeaders([]byte("01234567890123456789012345678901"), false)},
+ } {
+ t.Run(encryption.name, func(t *testing.T) {
+ for _, trusted := range []bool{false, true} {
+ t.Run("trusted="+strconv.FormatBool(trusted), func(t *testing.T) {
+ for _, tagging := range []struct {
+ name, header string
+ want time.Time
+ invalid bool
+ }{
+ {name: "absent"},
+ {name: "nanoseconds", header: stamp.Format(time.RFC3339Nano), want: stamp},
+ {name: "offset-whitespace", header: " " + stamp.In(time.FixedZone("UTC+8", 8*60*60)).Format(time.RFC3339Nano) + " ", want: stamp},
+ {name: "invalid", header: "not-a-timestamp", invalid: true},
+ } {
+ t.Run(tagging.name, func(t *testing.T) {
+ for _, metadata := range []map[string]string{nil, {"x-amz-meta-test": "kept"}} {
+ hdr := make(http.Header)
+ wantEncryption := make(http.Header)
+ for key, value := range encryption.headers {
+ hdr.Set(key, value)
+ wantEncryption.Set(key, value)
+ }
+ hdr.Set(xhttp.MinIOSourceTaggingTimestamp, tagging.header)
+ hdr.Set(xhttp.MinIOSourceMTime, stamp.Add(-time.Hour).Format(time.RFC3339Nano))
+ hdr.Set(xhttp.MinIOSourceObjectRetentionTimestamp, stamp.Add(-time.Minute).Format(time.RFC3339Nano))
+ hdr.Set(xhttp.MinIOSourceObjectLegalHoldTimestamp, stamp.Add(-time.Second).Format(time.RFC3339Nano))
+ hdr.Set(xhttp.MinIOSourceETag, "source-etag")
+ opts, err := putOptsFromHeaders(t.Context(), hdr, metadata, trusted)
+ if trusted && tagging.invalid {
+ if err == nil || !strings.Contains(err.Error(), xhttp.MinIOSourceTaggingTimestamp) {
+ t.Fatalf("malformed trusted timestamp: got %v", err)
+ }
+ continue
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantTag, wantMTime, wantRetention, wantLegalhold, wantETag := time.Time{}, time.Time{}, time.Time{}, time.Time{}, ""
+ if trusted {
+ wantTag, wantMTime = tagging.want, stamp.Add(-time.Hour)
+ wantRetention, wantLegalhold, wantETag = stamp.Add(-time.Minute), stamp.Add(-time.Second), "source-etag"
+ }
+ if !opts.ReplicationSourceTaggingTimestamp.Equal(wantTag) {
+ t.Errorf("tag timestamp=%s, want %s", opts.ReplicationSourceTaggingTimestamp, wantTag)
+ }
+ if !opts.MTime.Equal(wantMTime) || !opts.ReplicationSourceRetentionTimestamp.Equal(wantRetention) ||
+ !opts.ReplicationSourceLegalholdTimestamp.Equal(wantLegalhold) || opts.PreserveETag != wantETag || opts.ReplicationRequest != trusted {
+ t.Error("other source fields did not preserve the replication trust boundary")
+ }
+ if opts.UserDefined == nil || (metadata != nil && !reflect.DeepEqual(opts.UserDefined, metadata)) {
+ t.Errorf("metadata=%v, want nonnil map preserving %v", opts.UserDefined, metadata)
+ }
+ gotEncryption := make(http.Header)
+ if opts.ServerSideEncryption != nil {
+ opts.ServerSideEncryption.Marshal(gotEncryption)
+ }
+ if !reflect.DeepEqual(gotEncryption, wantEncryption) {
+ t.Errorf("SSE headers=%v, want %v", gotEncryption, wantEncryption)
+ }
+ }
+ })
+ }
+ })
+ }
+ })
+ }
+}
diff --git a/cmd/object-api-options.go b/cmd/object-api-options.go
index 4d3400508..930808eae 100644
--- a/cmd/object-api-options.go
+++ b/cmd/object-api-options.go
@@ -452,11 +452,11 @@ func putOptsFromHeaders(ctx context.Context, hdr http.Header, metadata map[strin
MTime: mtime,
PreserveETag: etag,
ReplicationRequest: trustedReplication,
- // The Object Lock timestamps order replicated retention and legal
- // hold updates. Dropping them here would leave every update on an
- // SSE-KMS destination unordered.
+ // These timestamps order replicated retention, legal hold and tagging
+ // updates on an SSE-KMS destination.
ReplicationSourceLegalholdTimestamp: lholdtimestmp,
ReplicationSourceRetentionTimestamp: retaintimestmp,
+ ReplicationSourceTaggingTimestamp: taggingtimestmp,
}
return op, nil
}
diff --git a/cmd/object-copy-replication-tagging_test.go b/cmd/object-copy-replication-tagging_test.go
new file mode 100644
index 000000000..f5538c9f5
--- /dev/null
+++ b/cmd/object-copy-replication-tagging_test.go
@@ -0,0 +1,144 @@
+// Copyright (c) 2015-2025 MinIO, Inc.
+// Copyright (c) 2025-2026 PGSTY
+//
+// This file is part of MinIO Object Storage stack
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/minio/minio/internal/auth"
+ "github.com/minio/minio/internal/crypto"
+ xhttp "github.com/minio/minio/internal/http"
+ "github.com/minio/minio/internal/kms"
+)
+
+// TestAPICopyObjectReplicaTaggingTimestampUnderKMS covers signed replica COPY
+// requests through encryption, metadata replacement and disk persistence. Both
+// the single-disk and 16-disk fixtures are single-pool backends.
+func TestAPICopyObjectReplicaTaggingTimestampUnderKMS(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testAPICopyObjectReplicaTaggingTimestampUnderKMS})
+}
+
+func testAPICopyObjectReplicaTaggingTimestampUnderKMS(obj ObjectLayer, instance, bucket string, router http.Handler, creds auth.Credentials, t *testing.T) {
+ // Ignore the host free-space percentage while retaining real disk I/O.
+ for _, pool := range obj.(*erasureServerPools).serverPools {
+ for _, set := range pool.sets {
+ original := set.getDisks
+ disks := append([]StorageAPI(nil), original()...)
+ for i := range disks {
+ disks[i] = tagTestCapacityDisk{StorageAPI: disks[i]}
+ }
+ set.getDisks = func() []StorageAPI { return disks }
+ defer func() { set.getDisks = original }()
+ }
+ }
+ oldKMS, oldAuto := GlobalKMS, globalAutoEncryption
+ GlobalKMS = kms.NewStub("replica-tags-key")
+ globalAutoEncryption = false
+ defer func() { GlobalKMS, globalAutoEncryption = oldKMS, oldAuto }()
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketVersioningConfig, enabledBucketVersioningConfig); err != nil {
+ t.Fatal(err)
+ }
+ const tsKey = ReservedMetadataPrefixLower + TaggingTimestamp
+ stamp := time.Date(2026, 9, 15, 1, 0, 0, 123456789, time.UTC)
+ for _, mode := range []string{"none", "explicit-sse-s3", "explicit-kms", "auto-kms", "bucket-kms"} {
+ t.Run(instance+"/"+mode, func(t *testing.T) {
+ globalAutoEncryption = mode == "auto-kms"
+ if mode == "bucket-kms" {
+ sseXML := []byte(`aws:kmsreplica-tags-key`)
+ if _, err := globalBucketMetadataSys.Update(t.Context(), bucket, bucketSSEConfig, sseXML); err != nil {
+ t.Fatal(err)
+ }
+ }
+ const data = "encrypted replica copy remains readable"
+ oi, err := obj.PutObject(t.Context(), bucket, mode, mustGetPutObjReader(t, bytes.NewReader([]byte(data)), int64(len(data)), "", ""), ObjectOptions{
+ Versioned: true, UserDefined: map[string]string{xhttp.AmzObjectTagging: "key=old", tsKey: stamp.Format(time.RFC3339Nano)},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, event := range []struct {
+ name, tags, wantTags string
+ delta, wantDelta time.Duration
+ missingTimestamp bool
+ }{
+ {"newer", "key=new", "key=new", 2, 2, false},
+ {"stale", "key=stale", "key=new", 1, 2, false},
+ {"duplicate", "key=new", "key=new", 2, 2, false},
+ {"newer-again", "key=latest", "key=latest", 3, 3, false},
+ {"missing-timestamp", "key=unordered", "key=latest", 0, 3, true},
+ } {
+ headers := map[string]string{
+ xhttp.AmzCopySource: "/" + bucket + "/" + mode + "?versionId=" + oi.VersionID,
+ xhttp.AmzMetadataDirective: "REPLACE", xhttp.AmzTagDirective: "REPLACE",
+ xhttp.AmzObjectTagging: event.tags, xhttp.MinIOSourceReplicationRequest: "true",
+ xhttp.AmzBucketReplicationStatus: "REPLICA", xhttp.MinIOSourceTaggingTimestamp: stamp.Add(event.delta).Format(time.RFC3339Nano),
+ xhttp.MinIOSourceMTime: oi.ModTime.Format(time.RFC3339Nano), xhttp.MinIOSourceETag: oi.ETag,
+ }
+ if event.missingTimestamp {
+ delete(headers, xhttp.MinIOSourceTaggingTimestamp)
+ }
+ if mode == "explicit-sse-s3" {
+ headers[xhttp.AmzServerSideEncryption] = xhttp.AmzEncryptionAES
+ }
+ if mode == "explicit-kms" {
+ headers[xhttp.AmzServerSideEncryption] = "aws:kms"
+ headers[xhttp.AmzServerSideEncryptionKmsID] = "replica-tags-key"
+ }
+ req, err := newTestSignedRequestV4(http.MethodPut, "/"+bucket+"/"+mode+"?versionId="+oi.VersionID, 0, nil, creds.AccessKey, creds.SecretKey, headers)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("%s: COPY %d %s", event.name, w.Code, w.Body.String())
+ }
+ got, err := obj.GetObjectInfo(t.Context(), bucket, mode, ObjectOptions{VersionID: oi.VersionID})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Logf("%s: tags=%q timestamp=%q kms=%v", event.name, got.UserTags, got.UserDefined[tsKey], crypto.S3KMS.IsEncrypted(got.UserDefined))
+ if got.UserTags != event.wantTags || got.UserDefined[tsKey] != stamp.Add(event.wantDelta).Format(time.RFC3339Nano) {
+ t.Errorf("%s: incorrect persisted tags/timestamp", event.name)
+ }
+ wantKMS := mode == "explicit-kms" || mode == "auto-kms" || mode == "bucket-kms"
+ if crypto.S3KMS.IsEncrypted(got.UserDefined) != wantKMS || crypto.S3.IsEncrypted(got.UserDefined) != (mode == "explicit-sse-s3") {
+ t.Errorf("%s: unexpected destination encryption", event.name)
+ }
+ if got.VersionID != oi.VersionID {
+ t.Errorf("%s: version=%q, want %q", event.name, got.VersionID, oi.VersionID)
+ }
+ req, err = newTestSignedRequestV4(http.MethodGet, "/"+bucket+"/"+mode+"?versionId="+oi.VersionID, 0, nil, creds.AccessKey, creds.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+ if w.Code != http.StatusOK || w.Body.String() != data {
+ t.Fatalf("%s: GET %d %q", event.name, w.Code, w.Body.String())
+ }
+ }
+ })
+ }
+}
diff --git a/docs/investigations/r4/consensus.md b/docs/investigations/r4/consensus.md
new file mode 100644
index 000000000..7fe4e4222
--- /dev/null
+++ b/docs/investigations/r4/consensus.md
@@ -0,0 +1,33 @@
+# R4 plan consensus and review disposition
+
+## Agreed version
+
+- Plan: [plan v1](plan-v1.md), SHA-256 `ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`. The frozen file remains unchanged.
+- Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`.
+- Actual reviewer: Claude Code 2.1.270, every assistant model in the review stream is `claude-opus-5`; explicit `--effort max`.
+- Opus: **GO_WITH_NONBLOCKING_NOTES**, zero blockers; explicitly agrees that this exact plan can enter local implementation. [Unedited returned review](opus-v1-review.md), [machine-readable provenance](opus-v1.metadata.json).
+- Codex: agrees that adding the already-parsed timestamp to the KMS literal fixes R4, and accepts the nonblocking dispositions below. **No blocking disagreement remains on plan v1.** No production source edits were made before this record was saved.
+- The agreement permits the planned local implementation and tests; it is not implementation acceptance, a merge decision or production release approval.
+
+## Item-by-item disposition
+
+| Opus ID | Disposition |
+|---|---|
+| R4-01 | Accepted citation correction here, leaving the agreed hash frozen: `ReplicaLockReconcile` is at baseline `object-handlers.go:1847`; encryption merge is at `:1903`. |
+| R4-02 | Accepted scope clarification: ErasureSD and Erasure16 are both single-pool local backends. KMS rewrites use PutObject under-lock reconciliation. Multi-pool and multi-site validation are optional and deferred to the wider integration gate. Test comments and the final report will identify this boundary. |
+| R4-03 | Accepted wording clarification: source encryption alone does not request destination encryption. Source-only SSE-C copy headers do not prevent destination bucket/default auto-KMS from selecting KMS. The three destination trigger categories stay unchanged. |
+| R4-04 | Accepted intent. The regression matrix uses identical expected mtime, ETag, trust and all three source timestamps across all encryption modes, giving field-by-field equivalence without constructing expected values through the production function. The temporary expanded baseline matrix fails only trusted valid KMS tag timestamps. |
+| R4-05 | Accepted optional test within the existing scope: a signed KMS COPY with nonempty tags and no source tag timestamp must preserve the stored value/time. This adds evidence, not production behavior. |
+| R4-06 | Registered as a separate unverified-impact finding: KMS construction also omits `ProxyHeaderSet`, `ProxyRequest`, `Speedtest` relative to `getDefaultOpts`. No R4 fix or correctness claim for those flags. Send the observation to the parent for separate triage; do not assign it to R5. |
+| R4-07 | Accepted. Assertions target final disk state; the REPLACE handler rebuilds metadata, while final stored-tag rejection occurs under the storage write lock. HTTP 200 alone is not acceptance. |
+| R4-08 | Resolved provenance uncertainty by Codex: SHA-256 recomputed before/after review, baseline identity and current GitHub main/PR query captured in `baseline-identity.txt`. History was inspected locally with `git blame` / `git show`. Opus's read-only tools did not independently recompute the hash or check GitHub; those facts remain attributed to the local commands. |
+
+## Raw evidence
+
+Directory: `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`.
+
+- `review-prompt-v1.md`, `opus-review-v1.jsonl`, `opus-review-v1.stderr.log`, `opus-review-v1.exit`.
+- `baseline-identity.txt`, `r4_repro_test.go`, `overlay.json`, `baseline-repro.log`.
+- `options_repro_test.go`, `options-overlay.json`, `baseline-options.log`.
+
+The stream includes an attempted Write to Claude's own plan file. Its tool was disabled; the reviewer returned the full result in text and did not edit production source. The successful result and actual assistant models are checked separately from rate-limit status and auxiliary-model usage.
diff --git a/docs/investigations/r4/opus-v1-review.md b/docs/investigations/r4/opus-v1-review.md
new file mode 100644
index 000000000..070def5dd
--- /dev/null
+++ b/docs/investigations/r4/opus-v1-review.md
@@ -0,0 +1,46 @@
+## R4 独立评审(Opus 5.0,只读复核)
+
+**计划**:plan v1 — `docs/investigations/r4/plan-v1.md`
+**哈希(按任务给定)**:`ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`
+**Baseline**:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`
+
+### 裁定:GO_WITH_NONBLOCKING_NOTES
+
+无阻断项。**我同意这份 exact plan(上述哈希)可以进入本地实现。** 下列 R4-01…R4-08 均为非阻断;其中 R4-02/04/05 的测试建议是**可选**的,不自动并入必做范围。
+
+(说明:本会话 Write 工具被禁用,评审仅以正文返回,未写入任何文件,也未改动任何源码。)
+
+### 我实际核验到的关键事实(支撑"单字段补丁正确且充分")
+
+1. **缺陷确认**:`cmd/object-api-options.go:449-460` 的 KMS 字面量带了 MTime/PreserveETag/ReplicationRequest + 两个 Object Lock 时间戳,独缺 tagging;默认路径 `:473` 有。补丁片段中的变量名 `taggingtimestmp` 与 `:419` 完全一致,可直接编译;gofmt 对齐由更长的两个 Lock 键决定,不会扰动他行。
+2. **影响面封闭**:全仓 `ReplicationSourceTaggingTimestamp` 只在 `cmd/object-handlers.go:1820` 被读取(定义于 `object-api-interface.go:99`)。因此该字段对 PUT/分段路径天然无效果——既印证 R4/R5 的切分合理,也说明补丁不可能回归其他路径。
+3. **充分性的关键点(我重点查证的风险)**:`encMetadata` 只有在 SSE-C 轮换分支 `object-handlers.go:1648-1659` 才批量快照全部保留键,而该分支与 KMS options 分支互斥(目的端是 SSE-C 时 `crypto.S3KMS.IsRequested` 为假)。故 `:1903` 的 `maps.Copy(srcInfo.UserDefined, encMetadata)` **不会**覆盖 KMS COPY 新写入的 tags/时间戳 —— 单字段补丁在 R4 边界内充分。
+4. **三个触发点准确**:`bucket-sse-config.go:135-153`(显式请求优先 → nil 配置 + AutoEncrypt → KMS → bucket 默认 KMS 写 header+keyID;默认 AES 走 AES 分支),配合 `object-handlers.go:1428-1433` 仅在非联邦时套用目的端默认。
+5. **REPLACE 副本路径准确**:`reconcileStoredObjectTags`(`erasure-server-pool-consistency.go:232-243`)语义即"存量有效时间戳胜过缺失/更旧/相等的 incoming,并连同 tag 值一起还原"。KMS 目的端因 `isTargetEncrypted` 使 `metadataOnly=false`,实际落到 `erasure-server-pool.go:1499-1513`(`ReplicaLockReconcile` 经 `:1509` 透传)→ `erasure-object.go:1276-1316`,在 `cloneMSS`(:1324) 之前于写锁内完成对账;纯元数据路径走 `erasure-object.go:136-139`。计划同时引用 `:136` 与 `:1509`,判断正确。
+6. **证据可信**:`baseline-repro.log` 中 options 用例非 KMS 保留 `...123456789Z`、KMS 返回零值;COPY 用例 6/6(ErasureSD + Erasure16 × explicit/auto/bucket KMS)失败,且均为 200、`kms=true`、明文 GET 通过、tags 停在 `key=old`。即"请求成功、加密正常,但复制标签被静默丢弃",与计划表述一致,未夸大。
+7. **修复后推演**:newer/stale/duplicate/newer-again 在 handler(:1817-1833) 与写锁对账的双重排序下分别得到 new/new/new/latest,与测试期望吻合;旧发送端不带 `X-Minio-Source-Tagging-Timestamp` 时仍为零值 → 行为不变,兼容性主张成立。
+
+### 问题清单
+
+| ID | 阻断 | 内容与建议 |
+|---|---|---|
+| **R4-01** | 否 | 行号漂移:计划写的 `1851/1910`,实际是 `object-handlers.go:1847`(`ReplicaLockReconcile`)与 `:1903`(encMetadata merge)。建议更正引用。 |
+| **R4-02** | 否(建议可选) | `ExecObjectLayerAPITest` 两种后端均为**单 pool**(`test-utils_test.go:216` `mustGetPoolEndpoints(0, ...)`),故 `erasure-server-pool.go:1443` 多池分支未被覆盖;且 KMS 目的端命中的是 PutObject 重写对账而非 `CopyObject:136`。建议在计划或测试注释中点明"单盘/16 盘均为单池";补多池覆盖**可选**,不必进必做范围。 |
+| **R4-03** | 否 | 措辞:`crypto.Requested`(`internal/crypto/sse.go:74`)只检查**目的端** SSE 头,因此仅带 SSE-C *copy-source* 头的请求在 KMS 默认桶/自动加密下仍会进入 KMS 分支(归入触发点 2/3,枚举仍完整)。建议澄清 "source encryption alone…" 一句。 |
+| **R4-04** | 否(**可选**) | 建议在 options 矩阵里加一条 KMS 分支 vs 默认分支的**逐字段等价断言**(MTime/PreserveETag/ReplicationRequest/三个复制时间戳)。这是阻止第三次复发最廉价的护栏(2021 漏、2026 补了两个 Lock 时间戳仍漏此项)。计划第 1 条已基本覆盖,此为结构化建议。 |
+| **R4-05** | 否(**可选**) | 建议加一例"KMS 目的端 + 有 tags 但无 tagging 时间戳头 → 存量不变",把兼容性主张钉在 handler 层而不仅在 options 层。 |
+| **R4-06** | 否(范围外,仅登记) | 同一 KMS 字面量相对 `getDefaultOpts`(`object-api-options.go:40-44`) 还遗漏 `ProxyHeaderSet/ProxyRequest/Speedtest`;`opts.Speedtest` 在 `erasure-object.go:1625` 被读取,全局自动加密下 speedtest PUT 会丢该标志。**不要在 R4 修**,且当前也不在 R5 声明范围内,建议单列条目登记。 |
+| **R4-07** | 否 | REPLACE 时 `getCpObjMetadataFromHeader:1143-1156` 会重建 map,`lastTaggingTimestamp` 为空 → handler 对 stale 事件**恒接受**,真正的拒绝来自写锁内对账。因此回归测试必须断言**最终落盘状态**(现有复现已如此),不要改为断言 handler 层行为。 |
+| **R4-08** | 否(不确定性) | 本会话无 shell,无法独立复算计划 SHA-256、验证 `c4373ef290 / b2dca43fda / cfefc049c` 历史归属与 PR #184/#187。可由 `shasum -a 256 docs/investigations/r4/plan-v1.md` 与 `git log -L` 输出消解;均不影响补丁正确性。`cfefc049c` 的 KMS context 编码修复实体(`:436-444` 的 `sdkContext`)仍在,补丁不触碰。 |
+
+### 对计划各主张的逐项裁定
+
+- 单字段补丁**正确且对本 bounded issue 充分**:同意(依据 2/3/7)。
+- 三个目的端 KMS 触发点**描述准确**:同意(R4-03 仅措辞澄清)。
+- 当前 REPLACE 副本路径**描述准确**:同意(R4-01/02 属引用精度)。
+- 回归矩阵与存量状态说明**充分**:同意;存量部分"不自动回填、丢失源时间不可重建、并列/更旧事件不保证修复"的表述与 `reconcileStoredObjectTags` 实际语义一致。
+- 信任边界、错误行为、加密 key/context、tie 语义、兼容性:补丁均未触碰,维持不变。
+
+### 交付
+
+本轮为**计划共识**,非实现验收。我未作任何源码或文件修改;R4 可按 plan v1 在本地实施,实施后的差异与测试证据需另行验收。
diff --git a/docs/investigations/r4/opus-v1.metadata.json b/docs/investigations/r4/opus-v1.metadata.json
new file mode 100644
index 000000000..76ffb154c
--- /dev/null
+++ b/docs/investigations/r4/opus-v1.metadata.json
@@ -0,0 +1,36 @@
+{
+ "baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
+ "plan": "docs/investigations/r4/plan-v1.md",
+ "plan_sha256": "ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754",
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "cli_version": "2.1.270",
+ "started_at": "2026-09-15T15:45:46.438630+00:00",
+ "status": "completed",
+ "raw_output": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/opus-review-v1.jsonl",
+ "raw_stderr": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/opus-review-v1.stderr.log",
+ "command": "/opt/homebrew/bin/claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --add-dir /Users/vonng/tmp/silo-r4-evidence-20260915-a9cb --output-format stream-json --verbose",
+ "completed_at": "2026-09-15T15:49:48.150062+00:00",
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "observed_model": "claude-opus-5",
+ "verdict": "GO_WITH_NONBLOCKING_NOTES",
+ "blocking_findings": 0,
+ "result_subtype": "success",
+ "is_error": false,
+ "session_id": "63e14a68-8565-41fa-9746-3e405fb63e9f",
+ "duration_ms": 161784,
+ "num_turns": 35,
+ "used_tools": {
+ "Read": 18,
+ "Glob": 4,
+ "Grep": 11,
+ "Write": 1
+ },
+ "stream_sha256": "eb0918d8a6185b180dddcfc664a96682f05502ecf3b686b08a0547f09879d57d",
+ "review_sha256": "e1dc12dd99326ae432623ff8de201813e6e84e7ed16a5556c21f9c514d663676",
+ "prompt_sha256": "07c225beff1523e056c154b3a387cf1ae345def4b4d0173065b882140ac5abdf",
+ "tool_scope_note": "Read/Grep/Glob allowed. Claude attempted Write to its own plan; the tool was disabled and no file was written. git diff before consensus showed no production source changes.",
+ "auxiliary_model_note": "assistant_models records actual reviewing assistant messages. Auxiliary usage is distinct. --effort max is explicit in the command, not inferred from model usage."
+}
diff --git a/docs/investigations/r4/plan-v1.md b/docs/investigations/r4/plan-v1.md
new file mode 100644
index 000000000..0ea961c66
--- /dev/null
+++ b/docs/investigations/r4/plan-v1.md
@@ -0,0 +1,65 @@
+# R4 plan v1: preserve the replicated tag timestamp for SSE-KMS
+
+## Baseline and ownership
+
+- Baseline: `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`, verified against GitHub main on 2026-09-15.
+- Branch: `codex/r4-kms-tag-timestamp`; worktree: `/Users/vonng/.codex/worktrees/a9cb/silo`.
+- Live open PRs at inspection: #184 and #187, neither owns this options change.
+- The worktree lacks the ignored `AGENTS.md`; the parent explicitly confirms `/Users/vonng/pgsty/silo/AGENTS.md` applies. Maintain the PGSTY product graph and inexpensive compatibility.
+- R4 owns only the missing field in `cmd/object-api-options.go` and its regression tests. R5 owns DELETE/empty tags, PUT/multipart receiving, sender propagation and full receiver ordering. R4 will supply a standalone source patch to R5; neither task edits the other's worktree.
+
+## Proven defect and actual trigger
+
+`putOptsFromHeaders` parses the trusted source tag timestamp before selecting encryption. The SSE-KMS branch constructs and returns another `ObjectOptions` carrying mtime, ETag, replication trust and both Object Lock timestamps, but omits `ReplicationSourceTaggingTimestamp`. The normal path retains it. The parser accepts and preserves RFC3339 fractional seconds even though its layout is `time.RFC3339`; the reproduction uses nanoseconds.
+
+`CopyObjectHandler` applies local destination encryption configuration before `copyDstOpts` → `putOptsFromReq` → `putOpts` → `putOptsFromHeaders`. The omission is reached by:
+
+1. Explicit destination SSE-KMS request headers (with or without a key ID/context).
+2. A destination bucket with default SSE-KMS, when the request has no explicit SSE choice.
+3. Global automatic encryption with no bucket SSE override and no explicit SSE choice.
+
+Explicit AES256/SSE-C takes its existing branch; source encryption alone does not select the destination KMS branch. Remote federation skips local destination defaults. The relevant trigger is trusted metadata entering the destination KMS branch, not every SSE-KMS object or every tag operation.
+
+At `CopyObjectHandler`'s tag decision, a zero source timestamp skips the tag update. Current under-lock reconciliation can preserve the stored tag/timestamp when metadata REPLACE reconstructs the map with no timestamp. In the observed same-version replica COPY, the request succeeds, destination encryption is valid, and the old tags/timestamp remain. A missing field in the options layer is not itself proof of a content-read failure.
+
+PUT and multipart consumers' independent failure to persist a parsed tag timestamp remain R5's responsibility. R4 does not claim to fix all tag replication by correcting this constructor.
+
+## Source and reproduction evidence
+
+- `cmd/object-api-options.go`: trusted parsing at 383–426; KMS construction at 433–460; normal assignments at 469–475.
+- `cmd/object-handlers.go`: destination default encryption at 1425–1435; `copyDstOpts` at 1454; tag timestamp consumption at 1807–1834; replica reconciliation enabled at 1851; encryption metadata merge at 1910.
+- `internal/bucket/encryption/bucket-sse-config.go:135`: explicit request wins, absent config + auto encryption selects KMS, otherwise configured bucket algorithm/key ID applies.
+- `cmd/erasure-server-pool-consistency.go:232`: stored valid timestamp wins over absent, older or equal incoming timestamp; writes preserve the stored tag value alongside its timestamp.
+- `cmd/erasure-object.go:136` and `cmd/erasure-server-pool.go:1509`: same-version replica COPY reaches existing under-lock tag reconciliation, including object-data rewrites.
+- History: the omission exists in `c4373ef290` (2021-09-18); `b2dca43fda` (2026-09-05) added the two Object Lock timestamps but not the tag timestamp. `cfefc049c` fixed KMS context encoding independently and must remain intact.
+- Fresh temporary reproduction: `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/r4_repro_test.go` and `baseline-repro.log` (overlay; no production edits).
+- Command: `GOMAXPROCS=2 go test -p 2 -overlay /Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/overlay.json ./cmd -run '^TestReviewR4' -count=1 -timeout 5m -v`.
+- Result: expected failure. Unencrypted and AES256 options preserve `2026-09-15T01:00:00.123456789Z`; KMS returns zero. Signed metadata REPLACE COPY on ErasureSD and Erasure (16 disks), across explicit/default/automatic KMS, returns 200 but retains `key=old` and the old timestamp for newer events. Actual encrypted metadata and plaintext GET roundtrips pass. Test deltas are 1–3 nanoseconds.
+- These are in-process signed HTTP router and real local disk tests. `kms.NewStub` replaces the remote key service; the normal server encryption/decryption code still runs. Existing `tagTestCapacityDisk` avoids the host's free-space percentage threshold; it delegates all object data/metadata I/O to real test disks.
+
+## Proposed production change
+
+Add exactly this field to the existing KMS `ObjectOptions` literal:
+
+```go
+ReplicationSourceTaggingTimestamp: taggingtimestmp,
+```
+
+Update the neighboring explanatory comment to include tagging alongside retention/legal hold. Do not refactor the common return paths, change parsing/fallback/equal-timestamp semantics, change encryption context encoding, modify trust decisions, add SDK dependencies, or change storage/wire format. Those changes are unnecessary to restore the missing existing contract.
+
+## Required validation after consensus
+
+1. Add an options regression matrix covering unencrypted, SSE-S3, SSE-KMS with no context, SSE-KMS with a context, and SSE-C. Validate trusted/untrusted requests, missing/valid/malformed tag timestamps, nanosecond and timezone/whitespace handling, all three replication timestamps, mtime/ETag/trust, nonnil metadata, and unchanged SSE header serialization (including KMS key/context).
+2. Promote the temporary COPY reproduction into a named, isolated regression test. Use actual signed same-version metadata COPY with REPLACE metadata and tagging directives, on single-disk and 16-disk backends. For explicit, bucket-default and automatic SSE-KMS, check newer update, older delivery, duplicate replay, and a second newer update. Verify stored tags, exact timestamp, version ID, encryption kind and plaintext GET after each operation. Include an unencrypted/SSE-S3 control if the fixture can do so without expanding implementation scope.
+3. Fail the final regression tests against unmodified baseline using an overlay. Then run them on the fixed source, alongside existing replication-trust/options and bucket-KMS Object Lock tests. Check `gofmt`, `git diff --check`, and `go vet ./cmd`.
+4. Run the new focused tests under `-race`. Use `GOMAXPROCS=2` and `-p 2` while sibling tasks share the host. A one-field pure option fix does not justify concurrent full-repository suites in all five tasks; full Linux CI and multi-site validation remain separate delivery gates.
+5. If a test exposes a separate handler/storage defect, report evidence and coordinate with R5. Do not broaden R4's production patch to make unrelated tests pass.
+
+## Compatibility, existing state, effort and delivery
+
+- Public API, header names, stored key names, KMS context/key handling and supported dependencies remain unchanged. Untrusted source headers stay ignored; malformed trusted timestamps continue to fail; absent timestamp remains zero. Existing non-KMS behavior remains unchanged.
+- No automatic rewrite/backfill. Lost source tag times cannot be reconstructed from the receiver alone. Upgrading permits subsequent properly timestamped events to be consumed. Review source-of-truth and target state before any targeted resync; full historical convergence also depends on R5. Repeated events subject to existing timestamp/tie semantics are not a universal repair guarantee.
+- The source fix can land independently; complete deletion/empty-tag and mixed-encryption convergence needs R5 plus its integration evidence.
+- Expected effort: approximately 0.5–1 engineer-day including reproduction, review and local validation; key-service deployment, multi-site failures and existing-state remediation are separate.
+- After actual Opus 5.0/max agreement on this exact plan hash, implement locally without another user permission prompt. Preserve raw review, assistant model identity, request effort, baseline and plan hash, issue-by-issue disposition and explicit consensus before source edits.
+- Deliver a reviewable local diff, tests and evidence. No main merge, remote publication/release, deployment or existing-state rewrite is authorized by this plan.
diff --git a/docs/investigations/r4/research.md b/docs/investigations/r4/research.md
new file mode 100644
index 000000000..d6ca3c2ac
--- /dev/null
+++ b/docs/investigations/r4/research.md
@@ -0,0 +1,18 @@
+# R4 research log
+
+## Verified baseline
+
+2026-09-15: local clean HEAD and GitHub main both `9ebe81c1b3611f9cc73e676b5b741c2be62c467a`. Branch created as `codex/r4-kms-tag-timestamp`. Live GitHub open PRs #184 (`6addf9eb916b5a4b837480cf534cd1efa5407d3c`) and #187 (`b8f2fdde41dff3dc3b8db669c1d42d30ca5c1d3d`) concern other tasks. Claude Code reports `2.1.270`; Go reports `go1.27.1 darwin/arm64`.
+
+## Coordination
+
+- Parent task: `01a0a5ab-ee43-7911-bddd-1aca6f8afcc8`.
+- R5: `01a0a5b9-602d-7470-9882-4817cf5fdcd1`, `/Users/vonng/.codex/worktrees/77ad/silo`.
+- Parent and R5 acknowledged the ownership boundary: R4 options constructor and nonempty KMS COPY tests; R5 producer/receiver ordering and empty values. R5 will consume R4's minimal patch for combined KMS acceptance.
+- Initial conservative expectation separated metadata COPY from REPLACE ordering. Inspection of current `ReplicaLockReconcile` and `reconcileStoredObjectTags` shows that stored timestamps are also reconciled under the write lock for REPLACE. The temporary reproduction therefore uses REPLACE directly; the fix must demonstrate the actual sender-shaped path without changing the handler.
+
+## Baseline reproduction
+
+Temporary overlay test source and output are in `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`. The options and HTTP/disk reproductions fail for the expected missing timestamp. Every KMS HTTP request completed with 200; newer tags remained old; encrypted object metadata and subsequent ordinary plaintext GET succeeded. The result is narrower than claiming all KMS replication fails, and stronger than merely comparing options.
+
+The temporary source is not a production implementation. See [plan v1](plan-v1.md) for exact scope and required acceptance. The plan is frozen by SHA-256 before invoking real Opus.
diff --git a/docs/investigations/r4/verification.json b/docs/investigations/r4/verification.json
new file mode 100644
index 000000000..076c40107
--- /dev/null
+++ b/docs/investigations/r4/verification.json
@@ -0,0 +1,358 @@
+{
+ "baseline": "9ebe81c1b3611f9cc73e676b5b741c2be62c467a",
+ "branch": "codex/r4-kms-tag-timestamp",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "source_files_match_all_test_runs": true,
+ "checks": [
+ {
+ "name": "baseline-final",
+ "command": [
+ "go",
+ "test",
+ "-p",
+ "2",
+ "-overlay",
+ "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/baseline-final-overlay.json",
+ "./cmd",
+ "-run",
+ "^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS)$",
+ "-count=1",
+ "-timeout=5m",
+ "-v"
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:50:49.430860+00:00",
+ "finished_at": "2026-09-15T15:51:20.637630+00:00",
+ "exit_code": 1,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/baseline-final.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "f849c2082235213764e3db7a314d52af75c4859102478a1e8f837afcaa8f1ea8",
+ "overlay_sha256": "f4cbc16e4ffccaf63191de2e8476162876055796adb4c38cda2d8c569a3bbabc",
+ "overlay_sources": {
+ "/Users/vonng/.codex/worktrees/a9cb/silo/cmd/object-api-options.go": {
+ "path": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/object-api-options.baseline.go",
+ "sha256": "16a560d0990ae929393f682f22b32ecd2e7f4d9390484b03b54e176fcd00cff5"
+ }
+ },
+ "assessment": "Expected baseline regression failure; KMS timestamp loss. Non-KMS controls pass."
+ },
+ {
+ "name": "focused",
+ "command": [
+ "go",
+ "test",
+ "-p",
+ "2",
+ "./cmd",
+ "-run",
+ "^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS|ReplicationTrustControlsInternalOptionsAndEvents|GetAndValidateAttributesOpts.*|APICopyObjectReplicaRetentionRemovalUnderBucketKMS)$",
+ "-count=1",
+ "-timeout=5m",
+ "-v"
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:51:20.638633+00:00",
+ "finished_at": "2026-09-15T15:51:48.382212+00:00",
+ "exit_code": 1,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/focused.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "9a64cbc46e9fd6186b1d3031034b720857851ce70b1466b1da5da6d1a52b76ba",
+ "assessment": "New tests and options/trust pass; pre-existing KMS lock fixture blocked by host disk free-space percentage."
+ },
+ {
+ "name": "focused-capacity-adapted",
+ "command": [
+ "go",
+ "test",
+ "-p",
+ "2",
+ "-overlay",
+ "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/capacity-overlay.json",
+ "./cmd",
+ "-run",
+ "^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS|ReplicationTrustControlsInternalOptionsAndEvents|GetAndValidateAttributesOpts.*|APICopyObjectReplicaRetentionRemovalUnderBucketKMS)$",
+ "-count=1",
+ "-timeout=5m",
+ "-v"
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:52:20.113475+00:00",
+ "finished_at": "2026-09-15T15:52:50.842773+00:00",
+ "exit_code": 0,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/focused-capacity-adapted.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "13f7aa54564a433bef4dddcf2c5ad1fe46fa03869527fb902a255ce4c3263bc9",
+ "overlay_sha256": "76a7e6fb364bdaaa3469b1dc79f9ea318059f287a4888f322639920c76dfe53a",
+ "overlay_sources": {
+ "/Users/vonng/.codex/worktrees/a9cb/silo/cmd/replication-trust_test.go": {
+ "path": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/replication-trust-capacity_test.go",
+ "sha256": "c81b526ae51983881bbf464199e6b90f074fa695300fa8ff005e427e4d3c8208"
+ }
+ },
+ "assessment": "PASS"
+ },
+ {
+ "name": "race",
+ "command": [
+ "go",
+ "test",
+ "-p",
+ "2",
+ "-race",
+ "./cmd",
+ "-run",
+ "^Test(PutOptsFromHeadersReplicationTimestamps|APICopyObjectReplicaTaggingTimestampUnderKMS)$",
+ "-count=1",
+ "-timeout=5m",
+ "-v"
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:52:50.843898+00:00",
+ "finished_at": "2026-09-15T15:53:43.789325+00:00",
+ "exit_code": 0,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/race.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "ae66a8c9e569a5c4b57ae56e75afc85c06a8b76f1567187519da0726605a4de4",
+ "assessment": "PASS"
+ },
+ {
+ "name": "vet",
+ "command": [
+ "go",
+ "vet",
+ "-p",
+ "2",
+ "./cmd"
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:53:43.790197+00:00",
+ "finished_at": "2026-09-15T15:53:51.129773+00:00",
+ "exit_code": 0,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/vet.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "assessment": "PASS"
+ },
+ {
+ "name": "lint",
+ "command": [
+ "/Users/vonng/pgsty/silo/.bin/golangci/v2.13.1/golangci-lint",
+ "run",
+ "--build-tags",
+ "kqueue",
+ "--timeout=10m",
+ "--config",
+ "./.golangci.yml",
+ "./cmd/..."
+ ],
+ "cwd": "/Users/vonng/.codex/worktrees/a9cb/silo",
+ "env_override": {
+ "GOMAXPROCS": "2"
+ },
+ "started_at": "2026-09-15T15:53:51.130456+00:00",
+ "finished_at": "2026-09-15T15:55:39.114303+00:00",
+ "exit_code": 0,
+ "log": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/lint.log",
+ "source_sha256": {
+ "cmd/object-api-options.go": "25e2e9484fafd94d1b2c857b94373758e481893ad93fb1a063edf7746277accc",
+ "cmd/object-api-options-replication_test.go": "1ea2a060987e32a4c76fce96ee974df475944c2d6ab482a4893e33daf7bca849",
+ "cmd/object-copy-replication-tagging_test.go": "5437a77e68736b4ce69de9c777675251fef24b0352dfe30bd8a836fc7ee810e3"
+ },
+ "log_sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47",
+ "assessment": "PASS"
+ }
+ ],
+ "format_checks": [
+ {
+ "command": [
+ "gofmt",
+ "-l",
+ "cmd/object-api-options.go",
+ "cmd/object-api-options-replication_test.go",
+ "cmd/object-copy-replication-tagging_test.go"
+ ],
+ "exit_code": 0,
+ "output": ""
+ },
+ {
+ "command": [
+ "git",
+ "diff",
+ "--check"
+ ],
+ "exit_code": 0,
+ "output": ""
+ }
+ ],
+ "evidence_directory": "/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb",
+ "evidence_files": {
+ "baseline-final-overlay.json": {
+ "size": 177,
+ "sha256": "f4cbc16e4ffccaf63191de2e8476162876055796adb4c38cda2d8c569a3bbabc"
+ },
+ "baseline-final.json": {
+ "size": 1584,
+ "sha256": "5bf7be51e5a0eb41a40dc5fc5d3a1aba5df7733ad5dcb001f8d870a01c4233ba"
+ },
+ "baseline-final.log": {
+ "size": 22493,
+ "sha256": "f849c2082235213764e3db7a314d52af75c4859102478a1e8f837afcaa8f1ea8"
+ },
+ "baseline-identity.txt": {
+ "size": 1676,
+ "sha256": "9b21841e19a0cbb8ded18c2597488a527a27bedc65109d05d4ff598103073b68"
+ },
+ "baseline-options.log": {
+ "size": 11395,
+ "sha256": "d692f0a4bc9c58e2ac0087afa356ddf48f86e1040ea8138d68ffc4d0992bf3cb"
+ },
+ "baseline-repro.log": {
+ "size": 5693,
+ "sha256": "aa89b76f4723c6a3ce224faa7796403628d978a8707544bd97848b8887de2113"
+ },
+ "capacity-fixture.diff": {
+ "size": 870,
+ "sha256": "8d01e0b0068441f37ecee37125b81424d1f30d7c4fb37d435ea0cfe2e4617e5e"
+ },
+ "capacity-overlay.json": {
+ "size": 185,
+ "sha256": "76a7e6fb364bdaaa3469b1dc79f9ea318059f287a4888f322639920c76dfe53a"
+ },
+ "final_copy_repro_test.go": {
+ "size": 5942,
+ "sha256": "8949e07d96d2949a79f5a9e83c7a7c0473733d77b407e9c51da477d4ab74f1a8"
+ },
+ "focused-capacity-adapted.json": {
+ "size": 1661,
+ "sha256": "1a599b41caabfc5eb44db8d89c8b7e4f4f84f5f036d008369155fd337f65bdf9"
+ },
+ "focused-capacity-adapted.log": {
+ "size": 20384,
+ "sha256": "13f7aa54564a433bef4dddcf2c5ad1fe46fa03869527fb902a255ce4c3263bc9"
+ },
+ "focused.json": {
+ "size": 1253,
+ "sha256": "d9bb4979ea8aeaabb809cdc6e400a8673530bc83abf3dc2b2a06853a8523d0d9"
+ },
+ "focused.log": {
+ "size": 20475,
+ "sha256": "9a64cbc46e9fd6186b1d3031034b720857851ce70b1466b1da5da6d1a52b76ba"
+ },
+ "format-checks.json": {
+ "size": 352,
+ "sha256": "7569260900a799d5efdfb39db1f575ab1dadbbb04ace222e036968e66b6b59e7"
+ },
+ "lint.json": {
+ "size": 991,
+ "sha256": "a7144713b069f470a94b1ebe6fca6683a4b866a891a2756e15f28c280666ca14"
+ },
+ "lint.log": {
+ "size": 10,
+ "sha256": "e92606b0bf483111dff0a120c315ea165821348f31365020e2468a0059095c47"
+ },
+ "object-api-options.baseline.go": {
+ "size": 16653,
+ "sha256": "16a560d0990ae929393f682f22b32ecd2e7f4d9390484b03b54e176fcd00cff5"
+ },
+ "options-overlay.json": {
+ "size": 185,
+ "sha256": "5506b9c3b998b32f01c45af3cf01605eae9e4fb262c9ff3a6b0040abe719d4d9"
+ },
+ "options_repro_test.go": {
+ "size": 4192,
+ "sha256": "1a57a47bdd370042fa0f0d2d90efe447abedee9b9ef48a938d4bed631d83ec0b"
+ },
+ "opus-review-v1.exit": {
+ "size": 2,
+ "sha256": "9a271f2a916b0b6ee6cecb2426f0b3206ef074578be55d9bc94f6f3fe3ab86aa"
+ },
+ "opus-review-v1.jsonl": {
+ "size": 410466,
+ "sha256": "eb0918d8a6185b180dddcfc664a96682f05502ecf3b686b08a0547f09879d57d"
+ },
+ "opus-review-v1.stderr.log": {
+ "size": 0,
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ "overlay.json": {
+ "size": 158,
+ "sha256": "95f7c3f7e206fe36731e6d7e4a48f90c7403f07ae8155c125c6b86d2f1c2d487"
+ },
+ "r4-kms-tag-timestamp.patch": {
+ "size": 874,
+ "sha256": "2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f"
+ },
+ "r4_repro_test.go": {
+ "size": 5508,
+ "sha256": "9bcefb6da2416b577b58085485cad60f677c2265e9dfa84d02e465e1b203766b"
+ },
+ "race.json": {
+ "size": 1026,
+ "sha256": "50b73e4acbc2426f3dcfadde78d0f0a86f10702345d2939a30204600bc750a13"
+ },
+ "race.log": {
+ "size": 18566,
+ "sha256": "ae66a8c9e569a5c4b57ae56e75afc85c06a8b76f1567187519da0726605a4de4"
+ },
+ "replication-trust-capacity_test.go": {
+ "size": 61271,
+ "sha256": "c81b526ae51983881bbf464199e6b90f074fa695300fa8ff005e427e4d3c8208"
+ },
+ "review-prompt-v1.md": {
+ "size": 2770,
+ "sha256": "07c225beff1523e056c154b3a387cf1ae345def4b4d0173065b882140ac5abdf"
+ },
+ "run-checks.py": {
+ "size": 2266,
+ "sha256": "ddecea5220bc9c286df18c0e9eca101f3d8ab731c307cd4acef937f3ecd11e65"
+ },
+ "vet.json": {
+ "size": 853,
+ "sha256": "e0964444bc91640ed6cf78229050bad5b94af4210bdf44b11d1a848f1ee930a6"
+ },
+ "vet.log": {
+ "size": 0,
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ }
+ },
+ "scope": "Darwin arm64; real signed HTTP + disk I/O; KMS service stub; single-pool single/16-disk fixtures; no remote CI, multi-site, release or deployment."
+}
diff --git a/docs/investigations/r4/verification.md b/docs/investigations/r4/verification.md
new file mode 100644
index 000000000..c787fd08a
--- /dev/null
+++ b/docs/investigations/r4/verification.md
@@ -0,0 +1,50 @@
+# R4 修复与本地验收
+
+## 结果
+
+在 `putOptsFromHeaders` 的 SSE-KMS 选项构造中补齐 `ReplicationSourceTaggingTimestamp`。目的端使用显式 SSE-KMS、桶默认 KMS 或自动加密时,可信复制 COPY 现在能消费来源标签时间戳,并在现有存储锁内完成排序。
+
+生产修改只有一个字段和相邻注释。API、存储格式、KMS key/context、信任判断和既有排序规则保持兼容。R5 的删除/空标签及 PUT/multipart 时间戳传播独立交付。
+
+## 方案与 Opus 共识
+
+- 基线:`9ebe81c1b3611f9cc73e676b5b741c2be62c467a`,已重新查询 GitHub main。
+- 分支:`codex/r4-kms-tag-timestamp`。
+- [冻结方案 v1](plan-v1.md):SHA-256 `ad539f2071155de6955b583991684ed33c4bfe2e29660005840cdc97d7e1a754`。
+- 真实评审为本机 Claude Code 2.1.270,实际 assistant 模型 `claude-opus-5`,显式 `--effort max`。结论 **GO_WITH_NONBLOCKING_NOTES,0 个阻断项**。
+- [逐条意见处置与双方共识](consensus.md)、[原始返回评审正文](opus-v1-review.md)、[模型与哈希记录](opus-v1.metadata.json) 已保存。先保存共识,再修改生产源码。
+
+## 变更与测试
+
+| 文件 | 内容 |
+|---|---|
+| `cmd/object-api-options.go` | 在 KMS 字面量中保留已解析的来源标签时间戳。 |
+| `cmd/object-api-options-replication_test.go` | 无加密、SSE-S3、SSE-KMS、带 key/context 的 KMS、SSE-C;可信/非可信;缺失、有效、无效标签时间;纳秒、时区与空格;mtime/ETag/三个时间戳、metadata 与 SSE 序列化。 |
+| `cmd/object-copy-replication-tagging_test.go` | 两种单池后端 × 五种目的端加密模式 × 五个有序事件,共 50 次签名 COPY 和 50 次普通 GET。每步检查最终标签、精确时间戳、对象版本、加密类型及明文内容。 |
+
+COPY 使用 `metadata=REPLACE`、`tagging=REPLACE` 和可信复制身份。事件为较新更新、乱序旧更新、重复事件、再次更新,以及不带来源标签时间戳的请求。更新间隔仅 1–3 纳秒,防止时间精度退化被秒级测试掩盖。无加密与 AES256 是对照;KMS 覆盖显式、桶默认和自动加密入口。
+
+## 验证状态
+
+| 检查 | 结果 | 证据文件 |
+|---|---|---|
+| 最终测试 + 未修复基线 constructor overlay | 预期失败;只有可信 KMS 有效标签时间戳及 KMS COPY 更新失败,对照通过 | `baseline-final.log/json` |
+| 修复后最终新增测试与既有 trust/options 测试 | 通过;未使用生产源码 overlay | `focused.log` |
+| 既有 KMS Object Lock 回归 | 首次受宿主机磁盘余量阈值阻挡;仅适配测试容量报告后,与上述定向测试一起通过 | `focused-capacity-adapted.log/json`、`capacity-fixture.diff` |
+| 新增测试 `-race` | 通过 | `race.log/json` |
+| `go vet -p 2 ./cmd` | 通过 | `vet.log/json` |
+| 仓库配置的 golangci-lint,范围 `./cmd/...`、`kqueue` build tag | 通过,0 issues | `lint.log/json` |
+| gofmt、git diff --check | 通过 | `format-checks.json` |
+
+原始日志和每条命令的运行记录位于 `/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/`。每份检查 JSON 都记录命令、退出码、时间和三个源码/测试文件的 SHA-256;最终交付已逐一确认文件哈希一致。[验证清单](verification.json) 另记录 overlay 的实际替换文件哈希,避免混淆基线与修复版执行代码。
+
+测试使用真实签名 HTTP 路由、实际本地对象数据/元数据读写、服务器加解密代码;远程密钥服务由 `kms.NewStub` 代替。ErasureSD 与 16 盘 Erasure 均为单池。容量适配只使用已有 `tagTestCapacityDisk`,避免本机磁盘使用比例触发防写阈值,所有对象 I/O 仍由真实测试磁盘承担;未调整生产容量保护。
+
+## 交付与剩余边界
+
+- 本地实现和要求的定向验证均已完成,将源码、回归、研究、共识和验收记录作为一个本地提交交付。
+- R4 的独立生产补丁已提供给 R5:`/Users/vonng/tmp/silo-r4-evidence-20260915-a9cb/r4-kms-tag-timestamp.patch`,SHA-256 `2d4806d986bbd94ba4bc3951f3aeee48401ee1921c28ded0988fa09ca76ca26f`。
+- Opus 共识为方案级共识;本地测试结论来自实际运行,不把它记作 Opus 执行了测试。
+- 多池/多站点故障恢复、外部 KMS 服务、完整 Linux CI、主干合并、远端发布和部署尚未执行。
+- 没有改写存量。丢失的来源时间戳不能仅从接收端推导;后续重放/重同步须核对来源权威性及 R5 的全链路处理,不保证旧事件重放可以修复全部历史状态。
+- 另登记 KMS 字面量缺少 Proxy/Speedtest 标志的范围外观察,已交父任务单独核验,本次未扩大修复。