diff --git a/buildscripts/rebrand-guard/compat-baseline.json b/buildscripts/rebrand-guard/compat-baseline.json
index 3b280e3dc..41ce7b102 100644
--- a/buildscripts/rebrand-guard/compat-baseline.json
+++ b/buildscripts/rebrand-guard/compat-baseline.json
@@ -512,6 +512,7 @@
"MINIO_SITE_COMMENT",
"MINIO_SITE_NAME",
"MINIO_SITE_REGION",
+ "MINIO_SITE_REPLICATION_METADATA_TOMBSTONES",
"MINIO_STORAGE_CLASS_COMMENT",
"MINIO_STORAGE_CLASS_INLINE_BLOCK",
"MINIO_STORAGE_CLASS_OPTIMIZE",
diff --git a/cmd/admin-bucket-handlers.go b/cmd/admin-bucket-handlers.go
index 01984e4de..308aa955c 100644
--- a/cmd/admin-bucket-handlers.go
+++ b/cmd/admin-bucket-handlers.go
@@ -76,7 +76,7 @@ func (a adminAPIHandlers) PutBucketQuotaConfigHandler(w http.ResponseWriter, r *
return
}
- quotaConfig, err := parseBucketQuota(bucket, data)
+ _, err = parseBucketQuota(bucket, data)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -94,9 +94,6 @@ func (a adminAPIHandlers) PutBucketQuotaConfigHandler(w http.ResponseWriter, r *
Quota: data,
UpdatedAt: updatedAt,
}
- if quotaConfig.Size == 0 && quotaConfig.Quota == 0 {
- bucketMeta.Quota = nil
- }
// Call site replication hook.
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, bucketMeta))
@@ -438,7 +435,7 @@ func (a adminAPIHandlers) ExportBucketMetadataHandler(w http.ResponseWriter, r *
writeErrorResponse(ctx, w, exportError(ctx, err, cfgFile, bucket), r.URL)
return
}
- configData, err := json.Marshal(config)
+ configData, err := canonicalBucketPolicy(config)
if err != nil {
writeErrorResponse(ctx, w, exportError(ctx, err, cfgFile, bucket), r.URL)
return
@@ -922,7 +919,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
continue
}
- configData, err := json.Marshal(bucketPolicy)
+ configData, err := canonicalBucketPolicy(bucketPolicy)
if err != nil {
rpt.SetStatus(bucket, fileName, err)
continue
@@ -1081,18 +1078,39 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
continue
}
var merged BucketMetadata
+ var commitAt time.Time
err := func() error {
lockCtx, unlock, err := lockBucketMetadata(ctx, objectAPI, bucket)
if err != nil {
return err
}
defer unlock()
- merged, err = loadBucketMetadataParse(lockCtx, objectAPI, bucket, true)
+ merged, err = loadBucketMetadataParse(lockCtx, objectAPI, bucket, false)
if err != nil {
return err
}
+ if err := ensureBucketMetadataCreated(lockCtx, objectAPI, &merged); err != nil {
+ return err
+ }
+ commitAt = UTCNow()
+ for _, file := range replicatedBucketConfigs {
+ if _, ok := fields[file]; ok {
+ commitAt = localBucketConfigUpdatedAt(merged, file, commitAt)
+ }
+ }
applyImportedBucketMetadata(&merged, *meta, fields)
- return globalBucketMetadataSys.saveMetadata(lockCtx, objectAPI, merged)
+ for _, file := range replicatedBucketConfigs {
+ if _, ok := fields[file]; !ok {
+ continue
+ }
+ data, at := replicatedBucketConfig(&merged, file)
+ payload, _, err := bucketConfigPayload(bucket, file, *data, len(merged.ObjectLockConfigXML) != 0)
+ if err != nil {
+ return err
+ }
+ *data, *at = payload, commitAt
+ }
+ return globalBucketMetadataSys.saveMetadata(lockCtx, objectAPI, &merged)
}()
if err != nil {
rpt.SetStatus(bucket, "", err)
@@ -1100,7 +1118,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
}
*meta = merged
globalNotificationSys.LoadBucketMetadata(bgContext(ctx), bucket)
- hook := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: updatedAt}
+ hook := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: commitAt}
var hookNeeded bool
if _, ok := fields[bucketQuotaConfigFile]; ok {
hook.Quota = meta.QuotaConfigJSON
@@ -1108,7 +1126,7 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
}
if _, ok := fields[bucketPolicyConfig]; ok {
hook.Policy = meta.PolicyConfigJSON
- hookNeeded = true
+ hookNeeded = hookNeeded || len(hook.Policy) != 0
}
if _, ok := fields[bucketVersioningConfig]; ok {
hook.Versioning = enc(meta.VersioningConfigXML)
@@ -1129,6 +1147,12 @@ func (a adminAPIHandlers) ImportBucketMetadataHandler(w http.ResponseWriter, r *
if hookNeeded {
err = globalSiteReplicationSys.BucketMetaHook(ctx, hook)
}
+ if _, ok := fields[bucketPolicyConfig]; ok && len(meta.PolicyConfigJSON) == 0 {
+ // An omitted bulk Policy cannot express deletion.
+ err = errors.Join(err, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
+ Type: madmin.SRBucketMetaTypePolicy, Bucket: bucket, UpdatedAt: commitAt,
+ }))
+ }
if _, ok := fields[bucketCorsConfig]; ok {
// CORS carries its own timestamp, so it replicates through the
// dedicated event rather than the shared bucket metadata hook. It
diff --git a/cmd/bucket-cors-site-replication_test.go b/cmd/bucket-cors-site-replication_test.go
index 64c37330b..275d0fa01 100644
--- a/cmd/bucket-cors-site-replication_test.go
+++ b/cmd/bucket-cors-site-replication_test.go
@@ -467,25 +467,6 @@ func testAdversarialHealCorsPropagatesNewerEqualValueTimestamp(obj ObjectLayer,
}
}
-func TestAdversarialBucketMetadataComparisonIsBase64CaseSensitive(t *testing.T) {
- upper := "QQ=="
- lower := "qQ=="
- upperBytes, err := base64.StdEncoding.Strict().DecodeString(upper)
- if err != nil {
- t.Fatal(err)
- }
- lowerBytes, err := base64.StdEncoding.Strict().DecodeString(lower)
- if err != nil {
- t.Fatal(err)
- }
- if string(upperBytes) == string(lowerBytes) {
- t.Fatal("test inputs unexpectedly decode to the same bytes")
- }
- if isBucketMetadataEqual(&upper, &lower) {
- t.Fatal("different decoded payloads were treated as equal")
- }
-}
-
func TestSiteReplicationStatusDetectsCorsTimestampMismatch(t *testing.T) {
ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{
t: t,
diff --git a/cmd/bucket-metadata-replication.go b/cmd/bucket-metadata-replication.go
new file mode 100644
index 000000000..fdb181b46
--- /dev/null
+++ b/cmd/bucket-metadata-replication.go
@@ -0,0 +1,330 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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"
+ "context"
+ "encoding/json"
+ "errors"
+ "sort"
+ "time"
+
+ "github.com/minio/minio-go/v7/pkg/tags"
+ bucketsse "github.com/minio/minio/internal/bucket/encryption"
+ objectlock "github.com/minio/minio/internal/bucket/object/lock"
+ "github.com/minio/minio/internal/bucket/versioning"
+ "github.com/pgsty/silo-pkg/v3/policy"
+)
+
+// Only these fields share the site-replication source-time ordering contract.
+// Bulk apply/import process Object Lock before Versioning, whose effective
+// document depends on it. Periodic heal retains its existing type order.
+var replicatedBucketConfigs = [...]string{
+ objectLockConfig, bucketVersioningConfig, bucketPolicyConfig,
+ bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile,
+}
+
+func replicatedBucketConfig(meta *BucketMetadata, file string) (*[]byte, *time.Time) {
+ switch file {
+ case bucketPolicyConfig:
+ return &meta.PolicyConfigJSON, &meta.PolicyConfigUpdatedAt
+ case bucketTaggingConfig:
+ return &meta.TaggingConfigXML, &meta.TaggingConfigUpdatedAt
+ case bucketSSEConfig:
+ return &meta.EncryptionConfigXML, &meta.EncryptionConfigUpdatedAt
+ case bucketQuotaConfigFile:
+ return &meta.QuotaConfigJSON, &meta.QuotaConfigUpdatedAt
+ case bucketVersioningConfig:
+ return &meta.VersioningConfigXML, &meta.VersioningConfigUpdatedAt
+ case objectLockConfig:
+ return &meta.ObjectLockConfigXML, &meta.ObjectLockConfigUpdatedAt
+ }
+ return nil, nil
+}
+
+// Callers that only need to know whether a file is under the contract must not
+// probe replicatedBucketConfig with a throwaway BucketMetadata.
+func isReplicatedBucketConfig(file string) bool {
+ for _, replicated := range replicatedBucketConfigs {
+ if replicated == file {
+ return true
+ }
+ }
+ return false
+}
+
+func bucketConfigUpdateOnly(file string) bool {
+ return file == bucketVersioningConfig || file == objectLockConfig
+}
+
+// Reuse the persistence rule before comparison, so an accepted Versioning
+// event and the document Save actually writes have the same comparison key.
+func effectiveBucketVersioning(data []byte, lockEnabled bool) []byte {
+ if lockEnabled {
+ config, err := versioning.ParseConfig(bytes.NewReader(data))
+ if err != nil || !config.Enabled() || config.PrefixesExcluded() {
+ return enabledBucketVersioningConfig
+ }
+ }
+ return data
+}
+
+// A parsed policy still contains map-backed sets with nondeterministic Marshal
+// order. Sort every set array recursively, including statements and conditions.
+// RawMessage keeps integer values intact; decoding through float64 would not.
+func canonicalBucketPolicyJSON(data json.RawMessage) (json.RawMessage, error) {
+ data = bytes.TrimSpace(data)
+ if len(data) == 0 {
+ return nil, nil
+ }
+ switch data[0] {
+ case '{':
+ var obj map[string]json.RawMessage
+ if err := json.Unmarshal(data, &obj); err != nil {
+ return nil, err
+ }
+ for key, value := range obj {
+ var err error
+ obj[key], err = canonicalBucketPolicyJSON(value)
+ if err != nil {
+ return nil, err
+ }
+ }
+ return json.Marshal(obj)
+ case '[':
+ var arr []json.RawMessage
+ if err := json.Unmarshal(data, &arr); err != nil {
+ return nil, err
+ }
+ for i := range arr {
+ var err error
+ arr[i], err = canonicalBucketPolicyJSON(arr[i])
+ if err != nil {
+ return nil, err
+ }
+ }
+ sort.Slice(arr, func(i, j int) bool { return bytes.Compare(arr[i], arr[j]) < 0 })
+ return json.Marshal(arr)
+ default:
+ var compact bytes.Buffer
+ if err := json.Compact(&compact, data); err != nil {
+ return nil, err
+ }
+ return compact.Bytes(), nil
+ }
+}
+
+// Encode the validated policy fields explicitly: BPStatement's required
+// Action/Resource tags otherwise try to marshal empty sets for the supported
+// NotAction/NotResource alternatives. This stays within the existing schema.
+func canonicalBucketPolicy(cfg *policy.BucketPolicy) ([]byte, error) {
+ if cfg.IsEmpty() {
+ return nil, nil
+ }
+ doc := map[string]any{"Version": cfg.Version}
+ if cfg.ID != "" {
+ doc["ID"] = cfg.ID
+ }
+ statements := make([]map[string]any, 0, len(cfg.Statements))
+ for _, st := range cfg.Statements {
+ statement := map[string]any{"Effect": st.Effect, "Principal": st.Principal}
+ if st.SID != "" {
+ statement["Sid"] = st.SID
+ }
+ if len(st.Actions) != 0 {
+ statement["Action"] = st.Actions
+ }
+ if len(st.NotActions) != 0 {
+ statement["NotAction"] = st.NotActions
+ }
+ if len(st.Resources) != 0 {
+ statement["Resource"] = st.Resources
+ }
+ if len(st.NotResources) != 0 {
+ statement["NotResource"] = st.NotResources
+ }
+ if len(st.Conditions) != 0 {
+ statement["Condition"] = st.Conditions
+ }
+ statements = append(statements, statement)
+ }
+ doc["Statement"] = statements
+ data, err := json.Marshal(doc)
+ if err != nil {
+ return nil, err
+ }
+ return canonicalBucketPolicyJSON(data)
+}
+
+// Validate with the same parsers as Save. Policy's established empty-policy
+// semantics are deletion; a parsed zero quota is still a live document.
+func bucketConfigPayload(bucket, file string, data []byte, lockEnabled bool) ([]byte, []byte, error) {
+ if len(data) == 0 {
+ return nil, nil, nil
+ }
+ var err error
+ key := data
+ switch file {
+ case bucketPolicyConfig:
+ var cfg *policy.BucketPolicy
+ cfg, err = policy.ParseBucketPolicyConfig(bytes.NewReader(data), bucket)
+ if err == nil {
+ if cfg.IsEmpty() {
+ return nil, nil, nil
+ }
+ key, err = canonicalBucketPolicy(cfg)
+ }
+ case bucketQuotaConfigFile:
+ cfg, parseErr := parseBucketQuota(bucket, data)
+ err = parseErr
+ if err == nil {
+ key, err = json.Marshal(cfg)
+ }
+ case bucketTaggingConfig:
+ _, err = tags.ParseBucketXML(bytes.NewReader(data))
+ case bucketSSEConfig:
+ _, err = bucketsse.ParseBucketSSEConfig(bytes.NewReader(data))
+ case objectLockConfig:
+ _, err = objectlock.ParseObjectLockConfig(bytes.NewReader(data))
+ case bucketVersioningConfig:
+ data = effectiveBucketVersioning(data, lockEnabled)
+ key = data
+ _, err = versioning.ParseConfig(bytes.NewReader(data))
+ }
+ return data, key, err
+}
+
+type bucketConfigState struct {
+ data, key []byte
+ at time.Time
+ real, valid bool
+}
+
+func newBucketConfigState(bucket, file string, data []byte, at, created time.Time, lockEnabled bool) (bucketConfigState, error) {
+ data, key, err := bucketConfigPayload(bucket, file, data, lockEnabled)
+ if err != nil {
+ return bucketConfigState{}, err
+ }
+ if at.IsZero() {
+ at = created
+ }
+ valid := !created.IsZero() && !at.Before(created)
+ modified := valid && at.After(created)
+ if bucketConfigUpdateOnly(file) && len(data) == 0 {
+ modified = false
+ }
+ return bucketConfigState{data: data, key: key, at: at, real: modified, valid: valid}, nil
+}
+
+func (s bucketConfigState) candidate() bool {
+ return s.valid && (s.real || len(s.data) != 0)
+}
+
+func compareBucketConfigStates(a, b bucketConfigState) int {
+ if a.valid != b.valid {
+ if a.valid {
+ return 1
+ }
+ return -1
+ }
+ if a.real != b.real {
+ if a.real {
+ return 1
+ }
+ return -1
+ }
+ if a.real {
+ if n := a.at.Compare(b.at); n != 0 {
+ return n
+ }
+ // At equal source time a real deletion wins, preventing resurrection.
+ if (len(a.data) == 0) != (len(b.data) == 0) {
+ if len(a.data) == 0 {
+ return 1
+ }
+ return -1
+ }
+ }
+ return bytes.Compare(a.key, b.key)
+}
+
+func localBucketConfigUpdatedAt(meta BucketMetadata, file string, now time.Time) time.Time {
+ _, at := replicatedBucketConfig(&meta, file)
+ for _, lower := range []time.Time{meta.Created, *at} {
+ if !now.After(lower) {
+ now = lower.Add(time.Nanosecond)
+ }
+ }
+ return now.UTC()
+}
+
+func ensureBucketMetadataCreated(ctx context.Context, obj ObjectLayer, meta *BucketMetadata) error {
+ if !meta.Created.IsZero() {
+ return nil
+ }
+ info, err := obj.GetBucketInfo(ctx, meta.Name, BucketOptions{NoMetadata: true})
+ if err != nil {
+ return err
+ }
+ if info.Created.IsZero() {
+ return errors.New("bucket metadata creation time is unknown")
+ }
+ meta.Created = info.Created.UTC()
+ return nil
+}
+
+// applyBucketConfig runs under metadata.lock, on freshly loaded metadata. It
+// changes only the selected field; the caller persists once after all checks.
+func applyBucketConfig(meta *BucketMetadata, file string, data []byte, at time.Time) (bool, error) {
+ if bucketConfigUpdateOnly(file) && len(data) == 0 {
+ return false, nil
+ }
+ current, currentAt := replicatedBucketConfig(meta, file)
+ lockEnabled := len(meta.ObjectLockConfigXML) != 0
+ incoming, err := newBucketConfigState(meta.Name, file, data, at, meta.Created, lockEnabled)
+ if err != nil {
+ return false, err
+ }
+ if !incoming.candidate() {
+ return false, nil
+ }
+ local, err := newBucketConfigState(meta.Name, file, *current, *currentAt, meta.Created, lockEnabled)
+ if err != nil {
+ return false, err
+ }
+ if compareBucketConfigStates(incoming, local) <= 0 {
+ return false, nil
+ }
+ *current, *currentAt = bytes.Clone(incoming.data), incoming.at.UTC()
+ return true, nil
+}
+
+func rebaseBucketConfigDefaults(meta *BucketMetadata, oldCreated time.Time) {
+ if meta.Created.Equal(oldCreated) {
+ return
+ }
+ // These six fields alone use Created to distinguish a baseline from a
+ // tombstone. Preserve actual source times when adopting an existing bucket.
+ for _, file := range replicatedBucketConfigs {
+ _, at := replicatedBucketConfig(meta, file)
+ if at.IsZero() || at.Equal(oldCreated) {
+ *at = meta.Created
+ }
+ }
+}
diff --git a/cmd/bucket-metadata-sys.go b/cmd/bucket-metadata-sys.go
index 8096e717d..afe09e6aa 100644
--- a/cmd/bucket-metadata-sys.go
+++ b/cmd/bucket-metadata-sys.go
@@ -126,19 +126,42 @@ func (sys *BucketMetadataSys) Set(bucket string, meta BucketMetadata) {
}
}
-func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket string, configFile string, configData []byte, parse, lifecycleDelete bool) (updatedAt time.Time, err error) {
+// bucketMetadataUpdate returns the committed snapshot to the caller. meta and
+// updatedAt hold the saved state only when changed is true. Local writes always
+// change state, because localBucketConfigUpdatedAt is strictly greater than the
+// current field time, so their handlers can broadcast meta without rechecking.
+type bucketMetadataUpdate struct {
+ meta BucketMetadata
+ updatedAt time.Time
+ changed bool
+}
+
+func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket, configFile string, configData []byte, parse, lifecycleDelete bool) (time.Time, error) {
+ result, err := sys.updateAndParseMetadata(ctx, bucket, configFile, configData, parse, lifecycleDelete, nil)
+ return result.updatedAt, err
+}
+
+func (sys *BucketMetadataSys) updateAndParseMetadata(ctx context.Context, bucket string, configFile string, configData []byte, parse, lifecycleDelete bool, sourceTime *time.Time) (result bucketMetadataUpdate, err error) {
objAPI := newObjectLayerFn()
if objAPI == nil {
- return updatedAt, errServerNotInitialized
+ return result, errServerNotInitialized
}
if isMinioMetaBucketName(bucket) {
- return updatedAt, errInvalidArgument
+ return result, errInvalidArgument
+ }
+ // Load deletions without parsed caches (notably quota), and compare the
+ // six replicated fields against the raw document under the same lock.
+ if isReplicatedBucketConfig(configFile) {
+ parse = false
+ if bucketConfigUpdateOnly(configFile) && len(configData) == 0 {
+ return result, nil
+ }
}
notifyCtx := ctx
ctx, unlock, err := lockBucketMetadata(ctx, objAPI, bucket)
if err != nil {
- return updatedAt, err
+ return result, err
}
err = func() error {
@@ -158,55 +181,73 @@ func (sys *BucketMetadataSys) updateAndParse(ctx context.Context, bucket string,
return err
}
}
- updatedAt = UTCNow()
- switch configFile {
- case bucketPolicyConfig:
- meta.PolicyConfigJSON = configData
- meta.PolicyConfigUpdatedAt = updatedAt
- case bucketNotificationConfig:
- meta.NotificationConfigXML = configData
- meta.NotificationConfigUpdatedAt = updatedAt
- case bucketLifecycleConfig:
- meta.LifecycleConfigXML = configData
- meta.LifecycleConfigUpdatedAt = updatedAt
- case bucketSSEConfig:
- meta.EncryptionConfigXML = configData
- meta.EncryptionConfigUpdatedAt = updatedAt
- case bucketTaggingConfig:
- meta.TaggingConfigXML = configData
- meta.TaggingConfigUpdatedAt = updatedAt
- case bucketQuotaConfigFile:
- meta.QuotaConfigJSON = configData
- meta.QuotaConfigUpdatedAt = updatedAt
- case objectLockConfig:
- meta.ObjectLockConfigXML = configData
- meta.ObjectLockConfigUpdatedAt = updatedAt
- case bucketVersioningConfig:
- meta.VersioningConfigXML = configData
- meta.VersioningConfigUpdatedAt = updatedAt
- case bucketReplicationConfig:
- meta.ReplicationConfigXML = configData
- meta.ReplicationConfigUpdatedAt = updatedAt
- case bucketTargetsFile:
- meta.BucketTargetsConfigJSON, meta.BucketTargetsConfigMetaJSON, err = encryptBucketMetadata(ctx, meta.Name, configData, kms.Context{
- bucket: meta.Name,
- bucketTargetsFile: bucketTargetsFile,
- })
- if err != nil {
- return fmt.Errorf("Error encrypting bucket target metadata %w", err)
+ updatedAt := UTCNow()
+ if isReplicatedBucketConfig(configFile) {
+ if err := ensureBucketMetadataCreated(ctx, objAPI, &meta); err != nil {
+ var at time.Time
+ if sourceTime != nil {
+ at = *sourceTime
+ }
+ logBucketConfigReplication(ctx, bucket, configFile, "indeterminate", at, meta.Created, err.Error())
+ return err
+ }
+ if sourceTime == nil || sourceTime.IsZero() {
+ updatedAt = localBucketConfigUpdatedAt(meta, configFile, updatedAt)
+ if sourceTime != nil {
+ logBucketConfigReplication(ctx, bucket, configFile, "legacy-zero", *sourceTime, meta.Created, "assigned local source time")
+ }
+ } else {
+ updatedAt = sourceTime.UTC()
+ }
+ if updatedAt.Before(meta.Created) {
+ logBucketConfigReplication(ctx, bucket, configFile, "before-created", updatedAt, meta.Created, "peer event")
+ return nil
+ }
+ changed, err := applyBucketConfig(&meta, configFile, configData, updatedAt)
+ if err != nil {
+ return err
+ }
+ if !changed {
+ return nil
+ }
+ } else {
+ switch configFile {
+ case bucketNotificationConfig:
+ meta.NotificationConfigXML = configData
+ meta.NotificationConfigUpdatedAt = updatedAt
+ case bucketLifecycleConfig:
+ meta.LifecycleConfigXML = configData
+ meta.LifecycleConfigUpdatedAt = updatedAt
+ case bucketReplicationConfig:
+ meta.ReplicationConfigXML = configData
+ meta.ReplicationConfigUpdatedAt = updatedAt
+ case bucketTargetsFile:
+ meta.BucketTargetsConfigJSON, meta.BucketTargetsConfigMetaJSON, err = encryptBucketMetadata(ctx, meta.Name, configData, kms.Context{
+ bucket: meta.Name,
+ bucketTargetsFile: bucketTargetsFile,
+ })
+ if err != nil {
+ return fmt.Errorf("Error encrypting bucket target metadata %w", err)
+ }
+ meta.BucketTargetsConfigUpdatedAt = updatedAt
+ meta.BucketTargetsConfigMetaUpdatedAt = updatedAt
+ default:
+ return fmt.Errorf("Unknown bucket %s metadata update requested %s", bucket, configFile)
}
- meta.BucketTargetsConfigUpdatedAt = updatedAt
- meta.BucketTargetsConfigMetaUpdatedAt = updatedAt
- default:
- return fmt.Errorf("Unknown bucket %s metadata update requested %s", bucket, configFile)
}
- return sys.saveMetadata(ctx, objAPI, meta)
+ if err := sys.saveMetadata(ctx, objAPI, &meta); err != nil {
+ return err
+ }
+ result = bucketMetadataUpdate{meta: meta, updatedAt: updatedAt, changed: true}
+ return nil
}()
if err != nil {
- return updatedAt, err
+ return result, err
}
- globalNotificationSys.LoadBucketMetadata(bgContext(notifyCtx), bucket) // Do not use caller context here
- return updatedAt, nil
+ if result.changed {
+ globalNotificationSys.LoadBucketMetadata(bgContext(notifyCtx), bucket)
+ }
+ return result, nil
}
func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) error {
@@ -219,7 +260,7 @@ func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) err
return errInvalidArgument
}
- if err := sys.saveMetadata(ctx, objAPI, meta); err != nil {
+ if err := sys.saveMetadata(ctx, objAPI, &meta); err != nil {
return err
}
@@ -229,7 +270,7 @@ func (sys *BucketMetadataSys) save(ctx context.Context, meta BucketMetadata) err
// saveMetadata persists and publishes metadata locally. Callers performing a
// read-modify-write must hold metadata.lock and release it before peer fan-out.
-func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLayer, meta BucketMetadata) error {
+func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLayer, meta *BucketMetadata) error {
// A writer may have queued for metadata.lock before DeleteBucket completed.
// Recheck the physical bucket under that lock, before recreating metadata.
if _, err := objAPI.GetBucketInfo(ctx, meta.Name, BucketOptions{NoMetadata: true}); err != nil {
@@ -238,7 +279,7 @@ func (sys *BucketMetadataSys) saveMetadata(ctx context.Context, objAPI ObjectLay
if err := meta.Save(ctx, objAPI); err != nil {
return err
}
- sys.Set(meta.Name, meta)
+ sys.Set(meta.Name, *meta)
return nil
}
@@ -351,7 +392,7 @@ func (sys *BucketMetadataSys) UpdateExpiryLCConfig(ctx context.Context, bucket s
}
meta.LifecycleConfigXML = configData
meta.LifecycleConfigUpdatedAt = UTCNow()
- return sys.saveMetadata(ctx, objAPI, meta)
+ return sys.saveMetadata(ctx, objAPI, &meta)
}()
if err != nil {
return err
diff --git a/cmd/bucket-metadata.go b/cmd/bucket-metadata.go
index 0bb59bb9e..8bf5cbe82 100644
--- a/cmd/bucket-metadata.go
+++ b/cmd/bucket-metadata.go
@@ -388,15 +388,7 @@ func (b *BucketMetadata) parseAllConfigs(ctx context.Context, objectAPI ObjectLa
} else {
b.objectLockConfig = nil
}
- if b.objectLockConfig != nil {
- // Object Lock requires every object to be versioned. Whatever the lock
- // document contains, a suspended or prefix-excluded versioning document
- // is replaced by plain Enabled versioning; Save persists the result.
- config, versioningErr := versioning.ParseConfig(bytes.NewReader(b.VersioningConfigXML))
- if versioningErr != nil || !config.Enabled() || config.PrefixesExcluded() {
- b.VersioningConfigXML = enabledBucketVersioningConfig
- }
- }
+ b.VersioningConfigXML = effectiveBucketVersioning(b.VersioningConfigXML, b.objectLockConfig != nil)
if len(b.VersioningConfigXML) != 0 {
b.versioningConfig, err = versioning.ParseConfig(bytes.NewReader(b.VersioningConfigXML))
diff --git a/cmd/bucket-policy-handlers.go b/cmd/bucket-policy-handlers.go
index 20d5afe61..fe00f175b 100644
--- a/cmd/bucket-policy-handlers.go
+++ b/cmd/bucket-policy-handlers.go
@@ -19,7 +19,6 @@ package cmd
import (
"bytes"
- "encoding/json"
"io"
"net/http"
@@ -100,13 +99,13 @@ func (api objectAPIHandlers) PutBucketPolicyHandler(w http.ResponseWriter, r *ht
return
}
- configData, err := json.Marshal(bucketPolicy)
+ configData, err := canonicalBucketPolicy(bucketPolicy)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
}
- updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, configData)
+ result, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketPolicyConfig, configData, false, false, nil)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -116,8 +115,8 @@ func (api objectAPIHandlers) PutBucketPolicyHandler(w http.ResponseWriter, r *ht
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
Type: madmin.SRBucketMetaTypePolicy,
Bucket: bucket,
- Policy: bucketPolicyBytes,
- UpdatedAt: updatedAt,
+ Policy: result.meta.PolicyConfigJSON,
+ UpdatedAt: result.updatedAt,
}))
// Success.
@@ -200,7 +199,7 @@ func (api objectAPIHandlers) GetBucketPolicyHandler(w http.ResponseWriter, r *ht
return
}
- configData, err := json.Marshal(config)
+ configData, err := canonicalBucketPolicy(config)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
diff --git a/cmd/bucket-versioning-handler.go b/cmd/bucket-versioning-handler.go
index bd09c2367..cd4399d50 100644
--- a/cmd/bucket-versioning-handler.go
+++ b/cmd/bucket-versioning-handler.go
@@ -97,7 +97,7 @@ func (api objectAPIHandlers) PutBucketVersioningHandler(w http.ResponseWriter, r
return
}
- updatedAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, configData)
+ result, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketVersioningConfig, configData, false, false, nil)
if err != nil {
writeErrorResponse(ctx, w, toAPIError(ctx, err), r.URL)
return
@@ -107,12 +107,12 @@ func (api objectAPIHandlers) PutBucketVersioningHandler(w http.ResponseWriter, r
//
// We encode the xml bytes as base64 to ensure there are no encoding
// errors.
- cfgStr := base64.StdEncoding.EncodeToString(configData)
+ cfgStr := base64.StdEncoding.EncodeToString(result.meta.VersioningConfigXML)
replLogIf(ctx, globalSiteReplicationSys.BucketMetaHook(ctx, madmin.SRBucketMeta{
Type: madmin.SRBucketMetaTypeVersionConfig,
Bucket: bucket,
Versioning: &cfgStr,
- UpdatedAt: updatedAt,
+ UpdatedAt: result.updatedAt,
}))
writeSuccessResponseHeadersOnly(w)
diff --git a/cmd/common-main.go b/cmd/common-main.go
index 7ffe22202..393ab17d0 100644
--- a/cmd/common-main.go
+++ b/cmd/common-main.go
@@ -900,6 +900,7 @@ func serverHandleEnvVars() {
}
globalEnableSyncBoot = env.Get("MINIO_SYNC_BOOT", config.EnableOff) == config.EnableOn
+ globalSiteReplicationMetadataTombstones = env.Get("MINIO_SITE_REPLICATION_METADATA_TOMBSTONES", config.EnableOff) == config.EnableOn
}
func loadRootCredentials() auth.Credentials {
diff --git a/cmd/erasure-server-pool.go b/cmd/erasure-server-pool.go
index 3755f324c..e94ae0c10 100644
--- a/cmd/erasure-server-pool.go
+++ b/cmd/erasure-server-pool.go
@@ -2276,6 +2276,11 @@ func (z *erasureServerPools) GetBucketInfo(ctx context.Context, bucket string, o
if err != nil {
return bucketInfo, toObjectErr(err, bucket)
}
+ // Physical existence/creation probes must not be overwritten by cached
+ // metadata, which can legitimately lack Created on an unmigrated bucket.
+ if opts.NoMetadata {
+ return bucketInfo, nil
+ }
meta, err := globalBucketMetadataSys.Get(bucket)
if err == nil {
diff --git a/cmd/site-replication-metadata-gate_test.go b/cmd/site-replication-metadata-gate_test.go
new file mode 100644
index 000000000..de9a37bb6
--- /dev/null
+++ b/cmd/site-replication-metadata-gate_test.go
@@ -0,0 +1,337 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+)
+
+func TestBucketMetadataTombstoneExportAndInitialSync(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ recordBucketConfigPeer(t, cred)
+ old := globalSiteReplicationMetadataTombstones
+ defer func() { globalSiteReplicationMetadataTombstones = old }()
+ created := UTCNow().Add(-time.Hour)
+ deletedAt := created.Add(time.Minute)
+ for _, enabled := range []bool{false, true} {
+ globalSiteReplicationMetadataTombstones = enabled
+ meta := newBucketMetadata(bucket)
+ meta.Created = created
+ meta.defaultTimestamps()
+ for _, file := range []string{bucketPolicyConfig, bucketTaggingConfig, bucketSSEConfig, bucketQuotaConfigFile} {
+ _, at := replicatedBucketConfig(&meta, file)
+ *at = deletedAt
+ }
+ if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
+ t.Fatal(err)
+ }
+ // Force a disk reload instead of accepting the just-published cache.
+ globalBucketMetadataSys.Remove(bucket)
+ info, err := globalSiteReplicationSys.SiteReplicationMetaInfo(t.Context(), obj, madmin.SRStatusOptions{Buckets: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ exported := info.Buckets[bucket]
+ if !exported.PolicyUpdatedAt.Equal(deletedAt) {
+ t.Fatal("Policy tombstone hidden by gate")
+ }
+ for _, at := range []time.Time{exported.TagConfigUpdatedAt, exported.SSEConfigUpdatedAt, exported.QuotaConfigUpdatedAt} {
+ if enabled && !at.Equal(deletedAt) || !enabled && !at.IsZero() {
+ t.Fatalf("gate=%v timestamp=%v", enabled, at)
+ }
+ }
+ for file, data := range bucketConfigTestData(bucket) {
+ event, send, err := initialBucketConfigReplicationEvent(meta, file)
+ wantSend := enabled && !bucketConfigUpdateOnly(file)
+ if err != nil || send != wantSend || send && !event.UpdatedAt.Equal(deletedAt) {
+ t.Fatalf("%s initial gate=%v: %+v %v %v", file, enabled, event, send, err)
+ }
+ baseline := newBucketMetadata(bucket)
+ baseline.Created = created
+ baseline.defaultTimestamps()
+ if _, send, err := initialBucketConfigReplicationEvent(baseline, file); err != nil || send {
+ t.Fatalf("empty baseline sent: %s", file)
+ }
+ value, _ := replicatedBucketConfig(&baseline, file)
+ *value = data
+ event, send, err = initialBucketConfigReplicationEvent(baseline, file)
+ if err != nil || !send || !event.UpdatedAt.Equal(created) {
+ t.Fatalf("historical baseline-live omitted: %s %v", file, err)
+ }
+ }
+ }
+ })
+ }})
+}
+
+func TestPeerBucketMetadataLegacyAndGeneration(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ old := globalSiteReplicationMetadataTombstones
+ defer func() { globalSiteReplicationMetadataTombstones = old }()
+ created := UTCNow().Add(-time.Hour)
+ for _, enabled := range []bool{false, true} {
+ globalSiteReplicationMetadataTombstones = enabled
+ for file, data := range bucketConfigTestData(bucket) {
+ meta := newBucketMetadata(bucket)
+ meta.Created = created
+ meta.defaultTimestamps()
+ if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
+ t.Fatal(err)
+ }
+ counter := &bucketConfigWriteCounter{ObjectLayer: obj}
+ setObjectLayer(counter)
+ event := newBucketConfigReplicationEvent(bucket, file, bucketConfigState{data: data, at: created.Add(-time.Second)})
+ rec := applySRBucketMetaViaAdmin(t, cred, event)
+ if rec.Code != http.StatusOK || counter.writes.Load() != 0 {
+ t.Fatalf("pre-creation apply: %s %d writes=%d", file, rec.Code, counter.writes.Load())
+ }
+ // A live baseline may initialize. The same-time nil cannot delete it.
+ event.UpdatedAt = created
+ rec = applySRBucketMetaViaAdmin(t, cred, event)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("baseline apply: %s %s", file, rec.Body.String())
+ }
+ before := counter.writes.Load()
+ rec = applySRBucketMetaViaAdmin(t, cred, madmin.SRBucketMeta{Type: event.Type, Bucket: bucket, UpdatedAt: created})
+ if rec.Code != http.StatusOK || counter.writes.Load() != before {
+ t.Fatalf("nil baseline cleared configuration: %s", file)
+ }
+ event.UpdatedAt = time.Time{}
+ for range 2 {
+ rec = applySRBucketMetaViaAdmin(t, cred, event)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("legacy-zero rejected: %s %s", file, rec.Body.String())
+ }
+ }
+ meta, err := loadBucketMetadata(t.Context(), obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ value, at := replicatedBucketConfig(&meta, file)
+ if len(*value) == 0 || !at.After(created) {
+ t.Fatalf("legacy-zero not reclocked: %s %v", file, *at)
+ }
+ setObjectLayer(obj)
+ }
+ }
+ })
+ }})
+}
+
+type bucketMetadataCreatedObjectLayer struct {
+ ObjectLayer
+ missing bool
+}
+
+func (o bucketMetadataCreatedObjectLayer) GetBucketInfo(ctx context.Context, bucket string, opts BucketOptions) (BucketInfo, error) {
+ if opts.NoMetadata {
+ if o.missing {
+ return BucketInfo{}, BucketNotFound{Bucket: bucket}
+ }
+ // A physical bucket that reports no creation time either.
+ return BucketInfo{Name: bucket}, nil
+ }
+ return o.ObjectLayer.GetBucketInfo(ctx, bucket, opts)
+}
+
+func TestPeerBucketMetadataUnknownCreated(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ defer setObjectLayer(obj)
+ data := bucketConfigTestData(bucket)[bucketTaggingConfig]
+ for _, mode := range []string{"unknown", "missing"} {
+ t.Run(mode, func(t *testing.T) {
+ setObjectLayer(obj)
+ if err := globalBucketMetadataSys.save(t.Context(), newBucketMetadata(bucket)); err != nil {
+ t.Fatal(err)
+ }
+ counter := &bucketConfigWriteCounter{ObjectLayer: bucketMetadataCreatedObjectLayer{ObjectLayer: obj, missing: mode == "missing"}}
+ setObjectLayer(counter)
+ stamp := UTCNow()
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(t.Context(), bucket, bucketTaggingConfig, data, false, false, &stamp)
+ if err == nil || counter.writes.Load() != 0 {
+ t.Fatalf("unknown generation was invented: %v writes=%d", err, counter.writes.Load())
+ }
+ })
+ }
+ })
+ }})
+}
+
+// setPhysicalBucketCreated stamps the bucket directory on every local drive,
+// which is what StatVol reports as the physical creation time.
+func setPhysicalBucketCreated(t *testing.T, bucket string, at time.Time) {
+ t.Helper()
+ globalLocalDrivesMu.RLock()
+ drives := cloneDrives(globalLocalDrivesMap)
+ globalLocalDrivesMu.RUnlock()
+ if len(drives) == 0 {
+ t.Fatal("no local drives registered")
+ }
+ for _, drive := range drives {
+ if err := os.Chtimes(pathJoin(drive.Endpoint().Path, bucket), at, at); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// Recovery has to run against the real ObjectLayer: a stub GetBucketInfo
+// returning the expected time would hide the cached zero creation time
+// overwriting it, which is what a bucket that never held a configuration has.
+func TestBucketMetadataPhysicalCreatedRecovery(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ // One known time on every drive, so the recovered value can be neither
+ // confused with UTCNow() nor dependent on which drive answers first.
+ physical := UTCNow().Add(-3 * time.Hour).Truncate(time.Second)
+ for _, missing := range []bool{false, true} {
+ for _, file := range replicatedBucketConfigs {
+ t.Run(backend+"/"+file+"/missing="+strconv.FormatBool(missing), func(t *testing.T) {
+ ctx := t.Context()
+ setPhysicalBucketCreated(t, bucket, physical)
+ if err := globalBucketMetadataSys.save(ctx, newBucketMetadata(bucket)); err != nil {
+ t.Fatal(err)
+ }
+ if missing {
+ if err := deleteConfig(ctx, obj, pathJoin(bucketMetaPrefix, bucket, bucketMetadataFile)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ data := bucketConfigTestData(bucket)[file]
+ at, err := globalBucketMetadataSys.Update(ctx, bucket, file, data)
+ if err != nil {
+ t.Fatalf("bucket without a recorded creation time cannot update %s: %v", file, err)
+ }
+ got, err := readBucketMetadata(ctx, obj, bucket)
+ if err != nil || !got.Created.Equal(physical) || !at.After(physical) {
+ t.Fatalf("physical creation not persisted: created=%v physical=%v updated=%v err=%v", got.Created, physical, at, err)
+ }
+ })
+ }
+ }
+ }})
+}
+
+func TestBucketMetadataInitialSyncPhysicalCreated(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ serviceCred, _, err := globalIAMSys.NewServiceAccount(ctx, cred.AccessKey, nil, newServiceAccountOpts{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { globalIAMSys.DeleteServiceAccount(context.Background(), serviceCred.AccessKey, false) })
+ physical := UTCNow().Add(-3 * time.Hour).Truncate(time.Second)
+ setPhysicalBucketCreated(t, bucket, physical)
+ meta := newBucketMetadata(bucket)
+ meta.TaggingConfigXML = bucketConfigTestData(bucket)[bucketTaggingConfig]
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ var mu sync.Mutex
+ var createdAt string
+ var events []madmin.SRBucketMeta
+ peer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ if r.URL.Query().Get("operation") == string(madmin.MakeWithVersioningBktOp) {
+ createdAt = r.URL.Query().Get("createdAt")
+ }
+ if strings.HasSuffix(r.URL.Path, "/bucket-meta") {
+ var event madmin.SRBucketMeta
+ if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
+ t.Error(err)
+ }
+ events = append(events, event)
+ }
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer peer.Close()
+ // Exercise the complete outgoing sync sequence with real source
+ // storage. This peer acknowledges RPCs; it is not a second ObjectLayer.
+ c := &SiteReplicationSys{enabled: true, state: srState{
+ ServiceAccountAccessKey: serviceCred.AccessKey,
+ Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}},
+ }}
+ if err := c.syncToAllPeers(ctx, madmin.SRAddOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if createdAt != physical.Format(time.RFC3339Nano) {
+ t.Fatalf("peer creation time %q, want physical time %s", createdAt, physical)
+ }
+ for _, event := range events {
+ if event.Bucket == bucket && event.Type == madmin.SRBucketMetaTypeTags {
+ if event.Tags == nil || *event.Tags != base64.StdEncoding.EncodeToString(meta.TaggingConfigXML) || !event.UpdatedAt.Equal(physical) {
+ t.Fatalf("historical tags lost baseline: %+v", event)
+ }
+ return
+ }
+ }
+ t.Fatal("initial sync silently skipped historical tags")
+ })
+ }})
+}
+
+func TestPeerBucketMetadataPhysicalCreatedBoundary(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ physical := UTCNow().Truncate(time.Second)
+ setPhysicalBucketCreated(t, bucket, physical)
+ if err := globalBucketMetadataSys.save(ctx, newBucketMetadata(bucket)); err != nil {
+ t.Fatal(err)
+ }
+ counter := &bucketConfigWriteCounter{ObjectLayer: obj}
+ setObjectLayer(counter)
+ defer setObjectLayer(obj)
+ data := bucketConfigTestData(bucket)[bucketTaggingConfig]
+ at := physical.Add(-time.Hour)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketTaggingConfig, data, false, false, &at)
+ if err != nil || counter.writes.Load() != 0 {
+ t.Fatalf("event before recovered creation must be skipped: err=%v writes=%d", err, counter.writes.Load())
+ }
+ // Physical mtime is only an approximation. A local correction can
+ // establish it; an earlier peer event cannot lower the bucket identity.
+ at, err = globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, data)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := readBucketMetadata(ctx, obj, bucket)
+ if err != nil || !got.Created.Equal(physical) || !at.After(physical) || !bytes.Equal(got.TaggingConfigXML, data) {
+ t.Fatalf("local correction did not establish physical creation: %+v %v", got, err)
+ }
+ })
+ }})
+}
diff --git a/cmd/site-replication-metadata-heal_test.go b/cmd/site-replication-metadata-heal_test.go
new file mode 100644
index 000000000..6c00fb8c1
--- /dev/null
+++ b/cmd/site-replication-metadata-heal_test.go
@@ -0,0 +1,323 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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"
+ "encoding/base64"
+ "fmt"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+ "github.com/minio/minio/internal/logger"
+ "github.com/minio/minio/internal/logger/target/testlogger"
+)
+
+func bucketConfigTestData(bucket string) map[string][]byte {
+ return map[string][]byte{
+ bucketPolicyConfig: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket)),
+ bucketTaggingConfig: []byte(`keyvalue`),
+ bucketSSEConfig: []byte(`AES256`),
+ bucketQuotaConfigFile: []byte(`{"quota":1024,"quotatype":"hard"}`),
+ bucketVersioningConfig: enabledBucketVersioningConfig,
+ objectLockConfig: enabledBucketObjectLockConfig,
+ }
+}
+
+type bucketConfigLogCapture struct {
+ testing.TB
+ mu sync.Mutex
+ lines []string
+}
+
+func (c *bucketConfigLogCapture) Logf(format string, args ...any) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.lines = append(c.lines, fmt.Sprintf(format, args...))
+}
+
+func TestHealBucketConfigDiagnostics(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ recordBucketConfigPeer(t, cred)
+ capture := &bucketConfigLogCapture{TB: t}
+ defer testlogger.T.SetLogTB(capture)()
+ disabled := logger.DisableLog
+ logger.DisableLog = false
+ defer func() { logger.DisableLog = disabled }()
+ created := UTCNow().Add(-time.Hour)
+ local := globalDeploymentID()
+ bs := map[string]srBucketStatsSummary{
+ local: bucketConfigTestInfo(bucket, bucketTaggingConfig, nil, created, created),
+ "missing-bucket": {},
+ "broken-rpc": bucketConfigTestInfo(bucket, bucketTaggingConfig, nil, created, created),
+ }
+ info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "missing-bucket": {}, "unreachable": {}, "broken-rpc": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: bs}}
+ if err := globalSiteReplicationSys.healBucketConfig(t.Context(), bucket, bucketTaggingConfig, info); err != nil {
+ t.Fatal(err)
+ }
+ if len(capture.lines) != 0 {
+ t.Fatal("empty baselines produced diagnostics")
+ }
+ bs[local] = bucketConfigTestInfo(bucket, bucketTaggingConfig, bucketConfigTestData(bucket)[bucketTaggingConfig], created.Add(time.Minute), created)
+ for range 2 {
+ if err := globalSiteReplicationSys.healBucketConfig(t.Context(), bucket, bucketTaggingConfig, info); err != nil {
+ t.Fatal(err)
+ }
+ }
+ capture.mu.Lock()
+ defer capture.mu.Unlock()
+ var unreachable, peerError int
+ for _, line := range capture.lines {
+ switch {
+ case strings.Contains(line, "bucket metadata replication: unreachable"):
+ unreachable++
+ case strings.Contains(line, "bucket metadata replication: peer-error"):
+ peerError++
+ default:
+ t.Fatalf("unexpected diagnostic: %s", line)
+ }
+ if !strings.HasPrefix(line, "WARNING:") {
+ t.Fatalf("diagnostic is not a warning: %s", line)
+ }
+ }
+ if unreachable != 1 || peerError != 1 {
+ t.Fatalf("distinct reasons were lost or not deduplicated: unreachable=%d peer-error=%d", unreachable, peerError)
+ }
+ })
+ }})
+}
+
+func TestHealBucketConfigWithoutSourceDiagnostics(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ events := recordBucketConfigPeer(t, cred)
+ disabled := logger.DisableLog
+ logger.DisableLog = false
+ defer func() { logger.DisableLog = disabled }()
+ created := UTCNow().Add(-time.Hour)
+ data := bucketConfigTestData(bucket)[bucketTaggingConfig]
+ for _, tc := range []struct {
+ name string
+ data []byte
+ at time.Time
+ created time.Time
+ wantLog bool
+ }{
+ {"unknown-created", data, created.Add(time.Minute), time.Time{}, true},
+ {"malformed", []byte(""), created.Add(time.Minute), created, true},
+ {"before-created", data, created.Add(-time.Minute), created, true},
+ {"empty-baseline", nil, created, created, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ capture := &bucketConfigLogCapture{TB: t}
+ defer testlogger.T.SetLogTB(capture)()
+ // Each case has its own diagnostic key; no source can be selected
+ // and neither local storage nor the recording peer may be written.
+ name := bucket + "-" + tc.name
+ local := globalDeploymentID()
+ info := srStatusInfo{
+ Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
+ BucketStats: map[string]map[string]srBucketStatsSummary{name: {
+ local: bucketConfigTestInfo(name, bucketTaggingConfig, tc.data, tc.at, tc.created),
+ "metadata-peer": {},
+ }},
+ }
+ for range 2 {
+ if err := globalSiteReplicationSys.healBucketConfig(t.Context(), name, bucketTaggingConfig, info); err != nil {
+ t.Fatal(err)
+ }
+ }
+ capture.mu.Lock()
+ defer capture.mu.Unlock()
+ want := 0
+ if tc.wantLog {
+ want = 1
+ }
+ if len(capture.lines) != want {
+ t.Fatalf("got %d diagnostics, want %d: %v", len(capture.lines), want, capture.lines)
+ }
+ for _, line := range capture.lines {
+ if !strings.HasPrefix(line, "WARNING:") || !strings.Contains(line, "bucket metadata replication: indeterminate") {
+ t.Fatalf("unexpected diagnostic: %s", line)
+ }
+ }
+ if len(events()) != 0 {
+ t.Fatal("healing without a source sent a metadata RPC")
+ }
+ })
+ }
+ })
+ }})
+}
+
+// Construct independent wire fixtures, including timestamps hidden by legacy
+// exporters, without going through the production event or state helpers.
+func bucketConfigTestInfo(bucket, file string, data []byte, at, created time.Time) srBucketStatsSummary {
+ info := madmin.SRBucketInfo{Bucket: bucket, CreatedAt: created}
+ var payload *string
+ if len(data) != 0 {
+ encoded := base64.StdEncoding.EncodeToString(data)
+ payload = &encoded
+ }
+ switch file {
+ case bucketPolicyConfig:
+ info.Policy, info.PolicyUpdatedAt = data, at
+ case bucketTaggingConfig:
+ info.Tags, info.TagConfigUpdatedAt = payload, at
+ case bucketSSEConfig:
+ info.SSEConfig, info.SSEConfigUpdatedAt = payload, at
+ case bucketQuotaConfigFile:
+ info.QuotaConfig, info.QuotaConfigUpdatedAt = payload, at
+ case bucketVersioningConfig:
+ info.Versioning, info.VersioningConfigUpdatedAt = payload, at
+ case objectLockConfig:
+ info.ObjectLockConfig, info.ObjectLockConfigUpdatedAt = payload, at
+ }
+ return srBucketStatsSummary{meta: srBucketMetaInfo{SRBucketInfo: info}}
+}
+
+func TestLatestBucketConfigCandidates(t *testing.T) {
+ created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ for file, data := range bucketConfigTestData("bucket") {
+ t.Run(file, func(t *testing.T) {
+ info := srStatusInfo{Sites: map[string]madmin.PeerInfo{"a": {}, "b": {}, "c": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{"bucket": {}}}
+ bs := info.BucketStats["bucket"]
+ baseline := bucketConfigTestInfo("bucket", file, nil, created, created)
+ bs["a"], bs["b"], bs["c"] = baseline, baseline, baseline
+ if _, found := latestBucketConfig("bucket", file, info); found {
+ t.Fatal("empty baseline chosen as source")
+ }
+ // Unknown and unavailable IDs cannot contribute, even with future clocks.
+ bs[""], bs["unknown"] = bucketConfigTestInfo("bucket", file, data, created.Add(10*time.Hour), created), bucketConfigTestInfo("bucket", file, data, created.Add(20*time.Hour), created)
+ if _, found := latestBucketConfig("bucket", file, info); found {
+ t.Fatal("unknown source chosen")
+ }
+ liveBaseline := bucketConfigTestInfo("bucket", file, data, created, created)
+ modified := bucketConfigTestInfo("bucket", file, data, created.Add(time.Hour), created)
+ oldGeneration := bucketConfigTestInfo("bucket", file, data, created, created.Add(time.Second))
+ states := []srBucketStatsSummary{liveBaseline, modified, oldGeneration}
+ for _, order := range [][3]int{{0, 1, 2}, {0, 2, 1}, {1, 0, 2}, {1, 2, 0}, {2, 0, 1}, {2, 1, 0}} {
+ bs["a"], bs["b"], bs["c"] = states[order[0]], states[order[1]], states[order[2]]
+ winner, found := latestBucketConfig("bucket", file, info)
+ if !found || !winner.at.Equal(created.Add(time.Hour)) || !bytes.Equal(winner.data, data) {
+ t.Fatalf("permutation %v chose %+v found=%v", order, winner, found)
+ }
+ }
+ bs["a"], bs["b"], bs["c"] = baseline, liveBaseline, baseline
+ if winner, found := latestBucketConfig("bucket", file, info); !found || !bytes.Equal(winner.data, data) || winner.real {
+ t.Fatal("historical baseline-live cannot initialize")
+ }
+ if !bucketConfigUpdateOnly(file) {
+ // A late-created empty baseline cannot beat a real earlier write.
+ bs["a"], bs["b"], bs["c"] = modified, bucketConfigTestInfo("bucket", file, nil, created.Add(2*time.Hour), created.Add(2*time.Hour)), baseline
+ if winner, found := latestBucketConfig("bucket", file, info); !found || len(winner.data) == 0 {
+ t.Fatal("default became a deletion")
+ }
+ bs["c"] = bucketConfigTestInfo("bucket", file, nil, created.Add(time.Hour), created)
+ if winner, found := latestBucketConfig("bucket", file, info); !found || len(winner.data) != 0 || !winner.real {
+ t.Fatal("real tombstone lost equal-time tie")
+ }
+ }
+ })
+ }
+}
+
+func TestHealBucketConfigSourceAndQuiescence(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ events := recordBucketConfigPeer(t, cred)
+ local := globalDeploymentID()
+ created := UTCNow().Add(-time.Hour)
+ oldAt, newAt := created.Add(time.Minute), created.Add(2*time.Minute)
+ counter := &bucketConfigWriteCounter{ObjectLayer: obj}
+ setObjectLayer(counter)
+ defer setObjectLayer(obj)
+ for file, data := range bucketConfigTestData(bucket) {
+ t.Run(file, func(t *testing.T) {
+ meta := newBucketMetadata(bucket)
+ meta.Created = created
+ meta.defaultTimestamps()
+ value, at := replicatedBucketConfig(&meta, file)
+ *value, *at = data, newAt
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ bs := map[string]srBucketStatsSummary{
+ local: bucketConfigTestInfo(bucket, file, data, newAt, created),
+ "metadata-peer": bucketConfigTestInfo(bucket, file, data, oldAt, created),
+ "": {}, "unknown": bucketConfigTestInfo(bucket, file, nil, newAt.Add(time.Hour), created),
+ // Known but absent from c.state.Peers, forcing getAdminClient failure.
+ "broken": bucketConfigTestInfo(bucket, file, nil, created, created),
+ }
+ info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "broken": {}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: bs}}
+ before := len(events())
+ writes := counter.writes.Load()
+ if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
+ t.Fatal(err)
+ }
+ got := events()[before:]
+ if len(got) != 1 || !got[0].UpdatedAt.Equal(newAt) {
+ t.Fatalf("same-payload timestamp heal did not reach healthy peer: %+v", got)
+ }
+ if file == bucketTaggingConfig && got[0].Tags == nil {
+ t.Fatal("tag event missing payload")
+ }
+ if counter.writes.Load() != writes {
+ t.Fatal("matching local state was rewritten")
+ }
+ bs["metadata-peer"], bs["broken"] = bs[local], bs[local]
+ if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
+ t.Fatal(err)
+ }
+ if len(events()) != before+1 || counter.writes.Load() != writes {
+ t.Fatal("stable second heal wrote or broadcast")
+ }
+ if bucketConfigUpdateOnly(file) {
+ return
+ }
+ bs["metadata-peer"] = bucketConfigTestInfo(bucket, file, nil, newAt.Add(time.Minute), created)
+ delete(bs, "broken")
+ if err := globalSiteReplicationSys.healBucketConfig(ctx, bucket, file, info); err != nil {
+ t.Fatal(err)
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ value, at = replicatedBucketConfig(&after, file)
+ if len(*value) != 0 || !at.Equal(newAt.Add(time.Minute)) {
+ t.Fatalf("local heal lost deletion/source time: %s %v", *value, *at)
+ }
+ if file == bucketQuotaConfigFile {
+ q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket)
+ if err != nil || q.Quota != 0 {
+ t.Fatalf("quota cache not cleared: %+v %v", q, err)
+ }
+ }
+ })
+ }
+ })
+ }})
+}
diff --git a/cmd/site-replication-metadata.go b/cmd/site-replication-metadata.go
new file mode 100644
index 000000000..356fb4e4b
--- /dev/null
+++ b/cmd/site-replication-metadata.go
@@ -0,0 +1,192 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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 (
+ "context"
+ "encoding/base64"
+ "errors"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/logger"
+)
+
+// Read once at startup. Enable only after every participating node is fixed.
+var globalSiteReplicationMetadataTombstones bool
+
+func logBucketConfigReplication(ctx context.Context, bucket, file, reason string, at, created time.Time, detail string) {
+ // LogOnceIf compares error text as well as its key. Keep both stable; changing
+ // times and peer errors belong in ReqInfo, not in the error's message.
+ req := &logger.ReqInfo{API: "SiteReplicationMetadata", BucketName: bucket}
+ req.AppendTags("field", file)
+ req.AppendTags("sourceTime", at.UTC().Format(time.RFC3339Nano))
+ req.AppendTags("created", created.UTC().Format(time.RFC3339Nano))
+ req.AppendTags("detail", detail)
+ replLogOnceIf(logger.SetReqInfo(ctx, req), errors.New("bucket metadata replication: "+reason),
+ "bucket-metadata/"+bucket+"/"+file+"/"+reason, logger.WarningKind)
+}
+
+func initialBucketConfigReplicationEvent(meta BucketMetadata, file string) (madmin.SRBucketMeta, bool, error) {
+ data, at := replicatedBucketConfig(&meta, file)
+ state, err := newBucketConfigState(meta.Name, file, *data, *at, meta.Created, len(meta.ObjectLockConfigXML) != 0)
+ if err != nil {
+ return madmin.SRBucketMeta{}, false, err
+ }
+ if !state.candidate() || (len(state.data) == 0 && !globalSiteReplicationMetadataTombstones) {
+ return madmin.SRBucketMeta{}, false, nil
+ }
+ return newBucketConfigReplicationEvent(meta.Name, file, state), true, nil
+}
+
+func bucketConfigStateFromInfo(bucket, file string, meta madmin.SRBucketInfo) (bucketConfigState, error) {
+ var payload *string
+ var at time.Time
+ switch file {
+ case bucketPolicyConfig:
+ return newBucketConfigState(bucket, file, meta.Policy, meta.PolicyUpdatedAt, meta.CreatedAt, false)
+ case bucketTaggingConfig:
+ payload, at = meta.Tags, meta.TagConfigUpdatedAt
+ case bucketSSEConfig:
+ payload, at = meta.SSEConfig, meta.SSEConfigUpdatedAt
+ case bucketQuotaConfigFile:
+ payload, at = meta.QuotaConfig, meta.QuotaConfigUpdatedAt
+ case bucketVersioningConfig:
+ payload, at = meta.Versioning, meta.VersioningConfigUpdatedAt
+ case objectLockConfig:
+ payload, at = meta.ObjectLockConfig, meta.ObjectLockConfigUpdatedAt
+ }
+ var data []byte
+ if payload != nil {
+ var err error
+ data, err = base64.StdEncoding.DecodeString(*payload)
+ if err != nil {
+ return bucketConfigState{}, err
+ }
+ }
+ lockEnabled := meta.ObjectLockConfig != nil && len(*meta.ObjectLockConfig) != 0
+ return newBucketConfigState(bucket, file, data, at, meta.CreatedAt, lockEnabled)
+}
+
+func newBucketConfigReplicationEvent(bucket, file string, state bucketConfigState) madmin.SRBucketMeta {
+ event := madmin.SRBucketMeta{Bucket: bucket, UpdatedAt: state.at}
+ var payload *string
+ if len(state.data) != 0 {
+ encoded := base64.StdEncoding.EncodeToString(state.data)
+ payload = &encoded
+ }
+ switch file {
+ case bucketPolicyConfig:
+ event.Type, event.Policy = madmin.SRBucketMetaTypePolicy, state.data
+ case bucketTaggingConfig:
+ event.Type, event.Tags = madmin.SRBucketMetaTypeTags, payload
+ case bucketSSEConfig:
+ event.Type, event.SSEConfig = madmin.SRBucketMetaTypeSSEConfig, payload
+ case bucketQuotaConfigFile:
+ event.Type, event.Quota = madmin.SRBucketMetaTypeQuotaConfig, state.data
+ case bucketVersioningConfig:
+ event.Type, event.Versioning = madmin.SRBucketMetaTypeVersionConfig, payload
+ case objectLockConfig:
+ event.Type, event.ObjectLockConfig = madmin.SRBucketMetaTypeObjectLockConfig, payload
+ }
+ return event
+}
+
+func latestBucketConfig(bucket, file string, info srStatusInfo) (bucketConfigState, bool) {
+ var latest bucketConfigState
+ found := false
+ for id, status := range info.BucketStats[bucket] {
+ if _, known := info.Sites[id]; !known || id == "" {
+ continue
+ }
+ state, err := bucketConfigStateFromInfo(bucket, file, status.meta.SRBucketInfo)
+ if err != nil || !state.candidate() {
+ continue
+ }
+ if !found || compareBucketConfigStates(state, latest) > 0 {
+ latest, found = state, true
+ }
+ }
+ return latest, found
+}
+
+func (c *SiteReplicationSys) healBucketConfig(ctx context.Context, bucket, file string, info srStatusInfo) error {
+ c.RLock()
+ defer c.RUnlock()
+ if !c.enabled {
+ return nil
+ }
+ latest, found := latestBucketConfig(bucket, file, info)
+ // Every reason keeps its own log key, so a site that did not report cannot
+ // deduplicate away an unusable peer state or a real heal RPC failure for
+ // the same bucket and field.
+ if found {
+ for id := range info.Sites {
+ if _, present := info.BucketStats[bucket][id]; !present {
+ logBucketConfigReplication(ctx, bucket, file, "unreachable", latest.at, time.Time{}, "peer "+id+" did not report")
+ }
+ }
+ }
+ for id, status := range info.BucketStats[bucket] {
+ if _, known := info.Sites[id]; !known || id == "" {
+ continue
+ }
+ target := status.meta.SRBucketInfo
+ current, currentErr := bucketConfigStateFromInfo(bucket, file, target)
+ // A peer without the bucket reports neither a field nor a creation
+ // time; bucket healing covers that normal transient. Report only a
+ // state that exists and still cannot be ordered.
+ if currentErr != nil || (!current.valid && (len(current.data) != 0 || !current.at.IsZero())) {
+ logBucketConfigReplication(ctx, bucket, file, "indeterminate", current.at, target.CreatedAt, "unusable peer "+id)
+ }
+ // Invalid existing state still needs a diagnosis when no source can
+ // be selected. Only propagation depends on having a valid source.
+ if !found || target.CreatedAt.IsZero() {
+ continue
+ }
+ if latest.at.Before(target.CreatedAt) {
+ logBucketConfigReplication(ctx, bucket, file, "before-created", latest.at, target.CreatedAt, "peer "+id)
+ continue
+ }
+ // Versioning can be normalized differently until Object Lock itself has
+ // converged. Compare what this target would actually persist.
+ incoming, err := newBucketConfigState(bucket, file, latest.data, latest.at, target.CreatedAt,
+ target.ObjectLockConfig != nil && len(*target.ObjectLockConfig) != 0)
+ if err != nil || !incoming.candidate() {
+ continue
+ }
+ if currentErr == nil && compareBucketConfigStates(incoming, current) <= 0 {
+ continue
+ }
+ if id == globalDeploymentID() {
+ _, err = globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, file, latest.data, false, false, &latest.at)
+ } else {
+ var client *madmin.AdminClient
+ client, err = c.getAdminClient(ctx, id)
+ if err == nil {
+ err = client.SRPeerReplicateBucketMeta(ctx, newBucketConfigReplicationEvent(bucket, file, incoming))
+ }
+ }
+ if err != nil {
+ // A missing credential or unreachable peer must not abandon the other
+ // targets simply because it happened to be visited first in this map.
+ logBucketConfigReplication(ctx, bucket, file, "peer-error", latest.at, target.CreatedAt, "peer "+id+": "+err.Error())
+ }
+ }
+ return nil
+}
diff --git a/cmd/site-replication-metadata_test.go b/cmd/site-replication-metadata_test.go
new file mode 100644
index 000000000..de69b2b50
--- /dev/null
+++ b/cmd/site-replication-metadata_test.go
@@ -0,0 +1,779 @@
+// Copyright (c) 2015-2026 MinIO, Inc.
+//
+// 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"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "path"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+ "github.com/pgsty/silo-pkg/v3/policy"
+)
+
+func TestPeerBucketMetadataSourceTimeAndDeletion(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: testPeerBucketMetadataSourceTimeAndDeletion})
+}
+
+func testPeerBucketMetadataSourceTimeAndDeletion(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ ctx := t.Context()
+ created := UTCNow().Add(-time.Hour)
+ putAt := created.Add(10 * time.Minute)
+ delAt := putAt.Add(time.Minute)
+ enc := func(s string) *string { v := base64.StdEncoding.EncodeToString([]byte(s)); return &v }
+ base := newBucketMetadata(bucket)
+ base.SetCreatedAt(created)
+ base.defaultTimestamps()
+ quotaJSON, err := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
+ if err != nil {
+ t.Fatal(err)
+ }
+ cases := []struct {
+ name, file string
+ put madmin.SRBucketMeta
+ value func(BucketMetadata) []byte
+ stamp func(BucketMetadata) time.Time
+ deletable bool
+ }{
+ {"policy", bucketPolicyConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypePolicy, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))}, func(m BucketMetadata) []byte { return m.PolicyConfigJSON }, func(m BucketMetadata) time.Time { return m.PolicyConfigUpdatedAt }, true},
+ {"tags", bucketTaggingConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeTags, Tags: enc(`keyold`)}, func(m BucketMetadata) []byte { return m.TaggingConfigXML }, func(m BucketMetadata) time.Time { return m.TaggingConfigUpdatedAt }, true},
+ {"sse", bucketSSEConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeSSEConfig, SSEConfig: enc(`AES256`)}, func(m BucketMetadata) []byte { return m.EncryptionConfigXML }, func(m BucketMetadata) time.Time { return m.EncryptionConfigUpdatedAt }, true},
+ {"quota", bucketQuotaConfigFile, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeQuotaConfig, Quota: quotaJSON}, func(m BucketMetadata) []byte { return m.QuotaConfigJSON }, func(m BucketMetadata) time.Time { return m.QuotaConfigUpdatedAt }, true},
+ {"versioning", bucketVersioningConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeVersionConfig, Versioning: enc(`Enabled`)}, func(m BucketMetadata) []byte { return m.VersioningConfigXML }, func(m BucketMetadata) time.Time { return m.VersioningConfigUpdatedAt }, false},
+ {"objectlock", objectLockConfig, newSRBucketObjectLockMeta(bucket, enc(`EnabledGOVERNANCE30`), putAt), func(m BucketMetadata) []byte { return m.ObjectLockConfigXML }, func(m BucketMetadata) time.Time { return m.ObjectLockConfigUpdatedAt }, false},
+ }
+ for _, tc := range cases {
+ t.Run(backend+"/"+tc.name, func(t *testing.T) {
+ if err := globalBucketMetadataSys.save(ctx, base); err != nil {
+ t.Fatal(err)
+ }
+ item := tc.put
+ item.Bucket, item.UpdatedAt = bucket, putAt
+ apply := func(item madmin.SRBucketMeta) {
+ t.Helper()
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin apply returned %d: %s", rec.Code, rec.Body.String())
+ }
+ }
+ read := func() BucketMetadata {
+ t.Helper()
+ m, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+ }
+ apply(item)
+ put := read()
+ if len(tc.value(put)) == 0 {
+ t.Fatal("PUT did not establish live config")
+ }
+ if !tc.stamp(put).Equal(putAt) {
+ t.Errorf("SOURCE_TIME: persisted %s, want source %s", tc.stamp(put), putAt)
+ }
+ apply(madmin.SRBucketMeta{Type: item.Type, Bucket: bucket, UpdatedAt: delAt})
+ after := read()
+ if !tc.deletable {
+ if !bytes.Equal(tc.value(put), tc.value(after)) || !tc.stamp(put).Equal(tc.stamp(after)) {
+ t.Error("NIL_NOOP: update-only config changed")
+ }
+ t.Log("nil payload is correctly a no-op")
+ return
+ }
+ if len(tc.value(after)) != 0 {
+ t.Error("NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE")
+ }
+ if _, err := globalBucketMetadataSys.Delete(ctx, bucket, tc.file); err != nil {
+ t.Fatal(err)
+ }
+ tombstone := read()
+ if len(tc.value(tombstone)) != 0 {
+ t.Fatal("local DELETE failed to establish tombstone")
+ }
+ apply(item)
+ after = read()
+ if len(tc.value(after)) != 0 {
+ t.Error("STALE_RESURRECTION: older source PUT resurrected a locally deleted config")
+ } else {
+ t.Log("older source PUT did not resurrect config")
+ }
+ })
+ }
+}
+
+func TestPeerBucketMetadataBulkOrdering(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newAt := meta.Created.Add(time.Hour)
+ newXML := `keynew`
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(newXML), newAt
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ oldXML := base64.StdEncoding.EncodeToString([]byte(`keyold`))
+ item := madmin.SRBucketMeta{Bucket: bucket, Tags: &oldXML, UpdatedAt: newAt.Add(-time.Minute)}
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("bulk apply returned %d: %s", rec.Code, rec.Body.String())
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(after.TaggingConfigXML, []byte(newXML)) || !after.TaggingConfigUpdatedAt.Equal(newAt) {
+ t.Errorf("BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value=%q time=%s", after.TaggingConfigXML, after.TaggingConfigUpdatedAt)
+ }
+ })
+ }})
+}
+
+func TestPeerBucketMetadataOrderingUnderLock(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldAt, newAt := meta.Created.Add(time.Hour), meta.Created.Add(2*time.Hour)
+ oldXML := base64.StdEncoding.EncodeToString([]byte(`keyolder`))
+ newXML := []byte(`keynewest`)
+ lockCtx, unlock, err := lockBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ locked := true
+ defer func() {
+ if locked {
+ unlock()
+ }
+ }()
+ ready := make(chan struct{}, 1)
+ hook := func(name string) {
+ if name == bucket {
+ select {
+ case ready <- struct{}{}:
+ default:
+ }
+ }
+ }
+ lockBucketMetadataAcquireHook.Store(&hook)
+ defer lockBucketMetadataAcquireHook.Store(nil)
+ done := make(chan error, 1)
+ go func() { done <- globalSiteReplicationSys.PeerBucketTaggingHandler(ctx, bucket, &oldXML, oldAt) }()
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("older event never reached metadata lock")
+ }
+ // A newer writer commits while holding the existing metadata lock.
+ // The old peer event has already checked the pre-commit cache.
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = newXML, newAt
+ if err := globalBucketMetadataSys.saveMetadata(lockCtx, obj, &meta); err != nil {
+ t.Fatal(err)
+ }
+ unlock()
+ locked = false
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("older event did not finish")
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after.TaggingConfigXML) != string(newXML) {
+ t.Errorf("CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with %q", after.TaggingConfigXML)
+ }
+ })
+ }})
+}
+
+// Count actual metadata persistence, including writes that would be invisible
+// in a value-only assertion after a duplicate or rejected event.
+type bucketConfigWriteCounter struct {
+ ObjectLayer
+ writes atomic.Int64
+}
+
+func (o *bucketConfigWriteCounter) PutObject(ctx context.Context, bucket, object string, data *PutObjReader, opts ObjectOptions) (ObjectInfo, error) {
+ if bucket == minioMetaBucket && path.Base(object) == bucketMetadataFile {
+ o.writes.Add(1)
+ }
+ return o.ObjectLayer.PutObject(ctx, bucket, object, data, opts)
+}
+
+func TestBucketConfigStateOrdering(t *testing.T) {
+ created := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
+ live := []byte(`ka`)
+ other := bytes.ReplaceAll(live, []byte("a"), []byte("b"))
+ state := func(data []byte, at, birth time.Time) bucketConfigState {
+ t.Helper()
+ s, err := newBucketConfigState("bucket", bucketTaggingConfig, data, at, birth, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return s
+ }
+ baseline := state(nil, created, created)
+ baselineLive := state(live, created, created)
+ put := state(live, created.Add(time.Second), created)
+ deleted := state(nil, put.at, created)
+ lateBaseline := state(other, created.Add(time.Hour), created.Add(time.Hour))
+ tests := []struct {
+ name string
+ lower, higher bucketConfigState
+ }{
+ {"baseline initialization", baseline, baselineLive},
+ {"real before later baseline", lateBaseline, put},
+ {"delete wins tie", put, deleted},
+ {"live key tie", put, state(other, put.at, created)},
+ {"new PUT after deletion", deleted, state(live, put.at.Add(time.Second), created)},
+ {"baseline key ignores creation time", state(live, created.Add(time.Hour), created.Add(time.Hour)), state(other, created, created)},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if compareBucketConfigStates(tc.lower, tc.higher) >= 0 || compareBucketConfigStates(tc.higher, tc.lower) <= 0 {
+ t.Fatal("ordering is not antisymmetric or chose the wrong winner")
+ }
+ })
+ }
+ for _, s := range []bucketConfigState{baseline, state(live, created.Add(-time.Second), created), state(live, created, time.Time{})} {
+ if s.candidate() {
+ t.Fatal("default, pre-creation, or unknown-generation state became a source")
+ }
+ }
+ for _, file := range []string{bucketVersioningConfig, objectLockConfig} {
+ s, err := newBucketConfigState("bucket", file, nil, put.at, created, false)
+ if err != nil || s.candidate() {
+ t.Fatalf("empty update-only candidate: %+v %v", s, err)
+ }
+ }
+}
+
+func TestBucketPolicyReplicationKey(t *testing.T) {
+ // Permute independent set arrays and parse separately, as two sites would.
+ a := []byte(`{"Version":"2012-10-17","Statement":[{"Sid":"one","Effect":"Allow","Principal":{"AWS":["b","a"]},"Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::bucket/b*","arn:aws:s3:::bucket/a*"],"Condition":{"StringLike":{"s3:prefix":["b*","a*"]}}},{"Sid":"two","Effect":"Deny","Principal":"*","NotAction":["s3:GetObject","s3:PutObject"],"NotResource":["arn:aws:s3:::bucket/d*","arn:aws:s3:::bucket/c*"]}]}`)
+ // Use a condition valid for object actions.
+ a = bytes.ReplaceAll(a, []byte("s3:prefix"), []byte("aws:UserAgent"))
+ b := []byte(`{"Statement":[{"NotResource":["arn:aws:s3:::bucket/c*","arn:aws:s3:::bucket/d*"],"NotAction":["s3:PutObject","s3:GetObject"],"Principal":"*","Effect":"Deny","Sid":"two"},{"Condition":{"StringLike":{"aws:UserAgent":["a*","b*"]}},"Resource":["arn:aws:s3:::bucket/a*","arn:aws:s3:::bucket/b*"],"Action":["s3:PutObject","s3:GetObject"],"Principal":{"AWS":["a","b"]},"Effect":"Allow","Sid":"one"}],"Version":"2012-10-17"}`)
+ _, key, err := bucketConfigPayload("bucket", bucketPolicyConfig, a, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range 30 {
+ _, next, err := bucketConfigPayload("bucket", bucketPolicyConfig, b, false)
+ if err != nil || !bytes.Equal(key, next) {
+ t.Fatalf("independent parse %d: %s != %s (%v)", i, key, next, err)
+ }
+ }
+ _, sid, err := bucketConfigPayload("bucket", bucketPolicyConfig, bytes.ReplaceAll(a, []byte(`"one"`), []byte(`"other"`)), false)
+ if err != nil || bytes.Equal(key, sid) {
+ t.Fatalf("Sid difference lost: %v", err)
+ }
+ n, err := canonicalBucketPolicyJSON([]byte(`{"n":[9007199254740993,9007199254740992]}`))
+ if err != nil || string(n) != `{"n":[9007199254740992,9007199254740993]}` {
+ t.Fatalf("integer precision lost: %s %v", n, err)
+ }
+}
+
+func TestBucketPolicyReplicationStatusLegacyOrder(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ // Statement order here is the reverse of the canonical encoder's,
+ // which is what an upgraded peer stores: the permutation must not
+ // be reported as a permanent mismatch.
+ legacy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":"allow","Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"},{"Sid":"deny","Effect":"Deny","Principal":"*","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket, bucket))
+ source, err := policy.ParseBucketPolicyConfig(bytes.NewReader(legacy), bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ meta := newBucketMetadata(bucket)
+ meta.Created = UTCNow().Add(-time.Hour)
+ meta.PolicyConfigJSON = legacy
+ meta.PolicyConfigUpdatedAt = meta.Created.Add(time.Minute)
+ event, send, err := initialBucketConfigReplicationEvent(meta, bucketPolicyConfig)
+ if err != nil || !send {
+ t.Fatalf("legacy initial event: %v send=%v", err, send)
+ }
+ target := newBucketMetadata(bucket)
+ target.Created = meta.Created
+ if err := globalBucketMetadataSys.save(ctx, target); err != nil {
+ t.Fatal(err)
+ }
+ if rec := applySRBucketMetaViaAdmin(t, cred, event); rec.Code != http.StatusOK {
+ t.Fatalf("peer apply: %d %s", rec.Code, rec.Body.String())
+ }
+ received, _, err := globalBucketMetadataSys.GetPolicyConfig(bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !isBktPolicyReplicated(2, []*policy.BucketPolicy{source, received}) {
+ t.Fatal("equivalent legacy and received policy reported as permanently mismatched")
+ }
+ changed, err := policy.ParseBucketPolicyConfig(bytes.NewReader(legacy), bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ changed.Statements[0].SID = "different"
+ if isBktPolicyReplicated(2, []*policy.BucketPolicy{source, changed}) {
+ t.Fatal("distinct policy state reported as replicated")
+ }
+ if isBktPolicyReplicated(2, []*policy.BucketPolicy{source, nil}) || !isBktPolicyReplicated(2, []*policy.BucketPolicy{nil, nil}) {
+ t.Fatal("per-site presence accounting changed")
+ }
+ })
+ }})
+}
+
+func TestPeerBucketMetadataWireAtomicity(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ stamp := meta.Created.Add(time.Hour)
+ policyData := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))
+ meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt = policyData, stamp
+ meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt = []byte(`{"quota":1024,"quotatype":"hard"}`), stamp
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(`ka`), stamp
+ if err = globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ counter := &bucketConfigWriteCounter{ObjectLayer: obj}
+ setObjectLayer(counter)
+ defer setObjectLayer(obj)
+ read := func() BucketMetadata {
+ t.Helper()
+ m, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+ }
+ apply := func(fragment string, at time.Time, ok bool) {
+ t.Helper()
+ raw := fmt.Sprintf(`{"bucket":%q,"updatedAt":%q,%s}`, bucket, at.Format(time.RFC3339Nano), fragment)
+ var item madmin.SRBucketMeta
+ if err := json.Unmarshal([]byte(raw), &item); err != nil {
+ t.Fatal(err)
+ }
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if (rec.Code == http.StatusOK) != ok {
+ t.Fatalf("%s: %d %s", raw, rec.Code, rec.Body.String())
+ }
+ }
+ // Omitted/null *string and empty update-only payloads are all no-ops.
+ apply(`"policy":null,"quota":null,"tags":null,"versioningConfig":"","objectLockConfig":""`, stamp.Add(time.Minute), true)
+ current := read()
+ if len(current.PolicyConfigJSON) != 0 || len(current.QuotaConfigJSON) == 0 {
+ t.Fatal("explicit null semantics lost")
+ }
+ if q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err != nil || q.Quota != 0 {
+ t.Fatalf("zero quota cache: %+v %v", q, err)
+ }
+ before := counter.writes.Load()
+ apply(`"tags":null,"versioningConfig":"","objectLockConfig":""`, stamp.Add(2*time.Minute), true)
+ if counter.writes.Load() != before {
+ t.Fatal("omitted fields or empty update-only wrote metadata")
+ }
+ // An invalid second field cannot persist the valid deletion in this bulk.
+ apply(`"tags":"","quota":{"quota":1,"quotatype":"invalid"}`, stamp.Add(2*time.Minute), false)
+ if counter.writes.Load() != before || len(read().TaggingConfigXML) == 0 {
+ t.Fatal("invalid bulk partially committed")
+ }
+ apply(`"tags":""`, stamp.Add(2*time.Minute), true)
+ before = counter.writes.Load()
+ apply(`"tags":""`, stamp.Add(2*time.Minute), true)
+ apply(`"tags":""`, stamp.Add(time.Minute), true)
+ if counter.writes.Load() != before {
+ t.Fatal("duplicate or old event wrote metadata")
+ }
+ localAt, err := globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, []byte(`{}`))
+ if err != nil || !localAt.After(current.QuotaConfigUpdatedAt) {
+ t.Fatalf("local future monotonic time: %v %v", localAt, err)
+ }
+ deletedAt, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketQuotaConfigFile)
+ if err != nil || !deletedAt.After(localAt) {
+ t.Fatalf("adjacent local time: %v %v", deletedAt, err)
+ }
+ if q, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err != nil || q.Quota != 0 {
+ t.Fatalf("deleted quota cache: %+v %v", q, err)
+ }
+ })
+ }})
+}
+
+func TestPeerBucketAdoptionRebasesOnlyDefaults(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ for _, shift := range []time.Duration{-time.Hour, 0, time.Hour, 3 * time.Hour} {
+ t.Run(fmt.Sprintf("%s/%s", backend, shift), func(t *testing.T) {
+ created := UTCNow().Add(-3 * time.Hour)
+ meta := newBucketMetadata(bucket)
+ meta.Created = created
+ meta.defaultTimestamps()
+ meta.QuotaConfigUpdatedAt = time.Time{}
+ meta.TaggingConfigUpdatedAt = created.Add(2 * time.Hour) // real deletion
+ meta.EncryptionConfigXML = []byte(`AES256`)
+ meta.EncryptionConfigUpdatedAt = created.Add(2 * time.Hour)
+ if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
+ t.Fatal(err)
+ }
+ if err := globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(), bucket, MakeBucketOptions{CreatedAt: created.Add(shift)}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := readBucketMetadata(t.Context(), obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.PolicyConfigUpdatedAt.Equal(got.Created) || !got.QuotaConfigUpdatedAt.Equal(got.Created) {
+ t.Fatal("default turned into tombstone or invalid source")
+ }
+ if !got.TaggingConfigUpdatedAt.Equal(meta.TaggingConfigUpdatedAt) || !got.EncryptionConfigUpdatedAt.Equal(meta.EncryptionConfigUpdatedAt) || !bytes.Equal(got.EncryptionConfigXML, meta.EncryptionConfigXML) {
+ t.Fatal("actual state changed during adoption")
+ }
+ if shift > 2*time.Hour {
+ // Preserve history, but do not promote state from an earlier
+ // bucket generation to a new valid source by retimestamping it.
+ for _, file := range []string{bucketTaggingConfig, bucketSSEConfig} {
+ data, at := replicatedBucketConfig(&got, file)
+ state, err := newBucketConfigState(bucket, file, *data, *at, got.Created, false)
+ if err != nil || state.candidate() {
+ t.Fatalf("pre-generation history became a source: %s %v", file, err)
+ }
+ // As a target, that invalid history must yield to a valid
+ // state from the adopted generation, including a live tag
+ // replacing the preserved earlier-generation deletion.
+ incoming := bucketConfigTestData(bucket)[file]
+ incomingAt := got.Created.Add(time.Minute)
+ changed, err := applyBucketConfig(&got, file, incoming, incomingAt)
+ if err != nil || !changed || !at.Equal(incomingAt) || !bytes.Equal(*data, incoming) {
+ t.Fatalf("adopted generation did not replace invalid target: %s %v", file, err)
+ }
+ }
+ }
+ })
+ }
+ }})
+}
+
+func recordBucketConfigPeer(t *testing.T, cred auth.Credentials) func() []madmin.SRBucketMeta {
+ t.Helper()
+ var mu sync.Mutex
+ var events []madmin.SRBucketMeta
+ remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var event madmin.SRBucketMeta
+ if err := json.NewDecoder(r.Body).Decode(&event); err != nil {
+ t.Error(err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ mu.Lock()
+ events = append(events, event)
+ mu.Unlock()
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(remote.Close)
+ serviceCred, err := auth.CreateCredentials("metadata-source-time-svc", "metadata-source-time-service-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ serviceCred.ParentUser = cred.AccessKey
+ globalSiteReplicatorCred.Set(serviceCred.SecretKey)
+ t.Cleanup(func() { globalSiteReplicatorCred.Set("") })
+ if _, err = globalIAMSys.store.AddServiceAccount(t.Context(), serviceCred); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { globalIAMSys.DeleteServiceAccount(context.Background(), serviceCred.AccessKey, false) })
+ globalSiteReplicationSys.Lock()
+ oldEnabled, oldState := globalSiteReplicationSys.enabled, globalSiteReplicationSys.state
+ globalSiteReplicationSys.enabled = true
+ globalSiteReplicationSys.state = srState{Name: "metadata-source-test", ServiceAccountAccessKey: serviceCred.AccessKey, Peers: map[string]madmin.PeerInfo{
+ globalDeploymentID(): {Name: "local", DeploymentID: globalDeploymentID()},
+ "metadata-peer": {Name: "remote", DeploymentID: "metadata-peer", Endpoint: remote.URL},
+ }}
+ globalSiteReplicationSys.Unlock()
+ t.Cleanup(func() {
+ globalSiteReplicationSys.Lock()
+ globalSiteReplicationSys.enabled, globalSiteReplicationSys.state = oldEnabled, oldState
+ globalSiteReplicationSys.Unlock()
+ })
+ return func() []madmin.SRBucketMeta {
+ mu.Lock()
+ defer mu.Unlock()
+ return append([]madmin.SRBucketMeta(nil), events...)
+ }
+}
+
+// The final metadata lock is taken after reading the ZIP. Commit another
+// writer immediately before that acquisition, using the existing lock wrapper.
+type importInterleavingObjectLayer struct {
+ ObjectLayer
+ once atomic.Bool
+ write func()
+}
+
+func (o *importInterleavingObjectLayer) NewNSLock(bucket string, objects ...string) RWLocker {
+ lock := o.ObjectLayer.NewNSLock(bucket, objects...)
+ if bucket != minioMetaBucket || len(objects) != 1 || path.Base(objects[0]) != "metadata.lock" {
+ return lock
+ }
+ return metadataObservedRWLocker{RWLocker: lock, onLock: func(context.Context) {
+ if o.once.CompareAndSwap(false, true) {
+ o.write()
+ }
+ }}
+}
+
+func TestLocalBucketMetadataCommittedEvents(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, endpoints: []string{"PutBucketPolicy", "GetBucketPolicy", "PutBucketVersioning"}, objAPITest: func(obj ObjectLayer, backend, bucket string, router http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ events := recordBucketConfigPeer(t, cred)
+ putPolicy := func(data []byte) {
+ t.Helper()
+ req, err := newTestSignedRequestV4(http.MethodPut, getPutPolicyURL("", bucket), int64(len(data)), bytes.NewReader(data), cred.AccessKey, cred.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNoContent {
+ t.Fatalf("empty policy PUT: %d %s", rec.Code, rec.Body.String())
+ }
+ }
+ putPolicy([]byte(`{"Version":"2012-10-17","Statement":[]}`))
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := events()
+ if len(got) != 1 || got[0].Type != madmin.SRBucketMetaTypePolicy || len(got[0].Policy) != 0 || len(meta.PolicyConfigJSON) != 0 || !meta.PolicyConfigUpdatedAt.Equal(got[0].UpdatedAt) {
+ t.Fatalf("empty policy event/disk mismatch: %+v", got)
+ }
+ req, err := newTestSignedRequestV4(http.MethodGet, getGetPolicyURL("", bucket), 0, nil, cred.AccessKey, cred.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("empty policy GET: %d %s", rec.Code, rec.Body.String())
+ }
+ // Supported negative sets must survive the same serializer on PUT,
+ // peer apply, GET and export instead of failing on empty Action.
+ putPolicy([]byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","NotAction":["s3:DeleteObject"],"NotResource":["arn:aws:s3:::%s/private/*"]}]}`, bucket)))
+ req, err = newTestSignedRequestV4(http.MethodGet, getGetPolicyURL("", bucket), 0, nil, cred.AccessKey, cred.SecretKey, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec = httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"NotAction"`)) || !bytes.Contains(rec.Body.Bytes(), []byte(`"NotResource"`)) {
+ t.Fatalf("negative policy set GET: %d %s", rec.Code, rec.Body.String())
+ }
+
+ corsAdminRequest(t, cred, http.MethodPut, "/set-bucket-quota?bucket="+bucket, []byte(`{}`))
+ meta, err = loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got = events()
+ last := got[len(got)-1]
+ if last.Type != madmin.SRBucketMetaTypeQuotaConfig || len(last.Quota) == 0 || !last.UpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
+ t.Fatalf("zero quota event: %+v", last)
+ }
+
+ injectedAt := UTCNow().Add(2 * time.Hour)
+ interleaving := &importInterleavingObjectLayer{ObjectLayer: obj, write: func() {
+ data := []byte(`{"quota":2048,"quotatype":"hard"}`)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketQuotaConfigFile, data, false, false, &injectedAt)
+ if err != nil {
+ t.Error(err)
+ }
+ }}
+ setObjectLayer(interleaving)
+ defer setObjectLayer(obj)
+ before := len(events())
+ rec = corsAdminRequest(t, cred, http.MethodPut, "/import-bucket-metadata", corsZip(t, map[string][]byte{
+ bucket + "/" + bucketPolicyConfig: []byte(`{"Version":"2012-10-17","Statement":[]}`),
+ bucket + "/quota.json": []byte(`{}`),
+ bucket + "/" + bucketTaggingConfig: []byte(`importedyes`),
+ bucket + "/" + objectLockConfig: enabledBucketObjectLockConfig,
+ bucket + "/" + bucketVersioningConfig: []byte(`Enabledtrue`),
+ }))
+ report := corsImportReport(t, rec).Buckets[bucket]
+ if report.Err != "" || report.Policy.Err != "" || report.Quota.Err != "" || report.Tagging.Err != "" {
+ t.Fatalf("import: %+v", report)
+ }
+ meta, err = loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !meta.QuotaConfigUpdatedAt.After(injectedAt) || !meta.TaggingConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) || !meta.PolicyConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
+ t.Fatalf("import time %v must exceed intervening %v; policy=%v tags=%v", meta.QuotaConfigUpdatedAt, injectedAt, meta.PolicyConfigUpdatedAt, meta.TaggingConfigUpdatedAt)
+ }
+ if !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) || !meta.VersioningConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) || !meta.ObjectLockConfigUpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
+ t.Fatal("import normalization or common source time lost")
+ }
+ got = events()[before:]
+ if len(got) != 2 {
+ t.Fatalf("import events: %+v", got)
+ }
+ for _, event := range got {
+ if !event.UpdatedAt.Equal(meta.QuotaConfigUpdatedAt) {
+ t.Fatal("import hook used ZIP start time")
+ }
+ if event.Type == madmin.SRBucketMetaTypePolicy {
+ if len(event.Policy) != 0 {
+ t.Fatal("empty policy import is not deletion")
+ }
+ } else if event.Tags == nil || len(event.Quota) == 0 {
+ t.Fatalf("bulk omitted imported state: %+v", event)
+ }
+ if event.Type == "" {
+ if event.Versioning == nil || event.ObjectLockConfig == nil {
+ t.Fatal("normalized import fields omitted")
+ }
+ payload, err := base64.StdEncoding.DecodeString(*event.Versioning)
+ if err != nil || !bytes.Equal(payload, enabledBucketVersioningConfig) {
+ t.Fatal("import sent pre-normalization versioning")
+ }
+ }
+ }
+ beforeMeta, beforeEvents := meta, len(events())
+ rec = corsAdminRequest(t, cred, http.MethodPut, "/import-bucket-metadata", corsZip(t, map[string][]byte{
+ bucket + "/" + bucketTaggingConfig: []byte(`onlytags`),
+ }))
+ if status := corsImportReport(t, rec).Buckets[bucket]; status.Err != "" || status.Tagging.Err != "" {
+ t.Fatalf("tags-only import: %+v", status)
+ }
+ meta, err = loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(meta.PolicyConfigJSON, beforeMeta.PolicyConfigJSON) || !meta.PolicyConfigUpdatedAt.Equal(beforeMeta.PolicyConfigUpdatedAt) || !bytes.Equal(meta.QuotaConfigJSON, beforeMeta.QuotaConfigJSON) || !meta.QuotaConfigUpdatedAt.Equal(beforeMeta.QuotaConfigUpdatedAt) {
+ t.Fatal("tags-only import changed Policy or Quota")
+ }
+ got = events()[beforeEvents:]
+ if len(got) != 1 || got[0].Tags == nil || got[0].Policy != nil || got[0].Quota != nil || got[0].Versioning != nil || got[0].ObjectLockConfig != nil || !got[0].UpdatedAt.Equal(meta.TaggingConfigUpdatedAt) {
+ t.Fatalf("tags-only import hook leaked unspecified fields: %+v", got)
+ }
+ })
+ }})
+}
+
+func TestPeerBucketMetadataNormalizesBeforeComparison(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ meta, err := loadBucketMetadata(t.Context(), obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ versioning := base64.StdEncoding.EncodeToString([]byte(`Enabledskip/true`))
+ lock := base64.StdEncoding.EncodeToString(enabledBucketObjectLockConfig)
+ item := madmin.SRBucketMeta{Bucket: bucket, ObjectLockConfig: &lock, Versioning: &versioning, UpdatedAt: meta.Created.Add(time.Hour)}
+ counter := &bucketConfigWriteCounter{ObjectLayer: obj}
+ setObjectLayer(counter)
+ defer setObjectLayer(obj)
+ for range 2 {
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("bulk: %d %s", rec.Code, rec.Body.String())
+ }
+ }
+ if counter.writes.Load() != 1 {
+ t.Fatalf("normalized duplicate persisted %d times", counter.writes.Load())
+ }
+ meta, err = loadBucketMetadata(t.Context(), obj, bucket)
+ if err != nil || !bytes.Equal(meta.VersioningConfigXML, enabledBucketVersioningConfig) || !meta.VersioningConfigUpdatedAt.Equal(item.UpdatedAt) {
+ t.Fatalf("normalization: %s %v", meta.VersioningConfigXML, err)
+ }
+ // Ordinary local updates use the identical effective document in their
+ // commit result, even if Object Lock changed since the handler's precheck.
+ data, _ := base64.StdEncoding.DecodeString(versioning)
+ result, err := globalBucketMetadataSys.updateAndParseMetadata(t.Context(), bucket, bucketVersioningConfig, data, false, false, nil)
+ if err != nil || !bytes.Equal(result.meta.VersioningConfigXML, enabledBucketVersioningConfig) {
+ t.Fatalf("local commit snapshot: %s %v", result.meta.VersioningConfigXML, err)
+ }
+ })
+ }})
+}
+
+func TestPeerBucketMetadataEqualTimeArrivalOrders(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ created := UTCNow().Add(-time.Hour)
+ a := []byte(`keya`)
+ b := bytes.ReplaceAll(a, []byte("a"), []byte("b"))
+ for _, pair := range [][2][]byte{{a, b}, {b, a}, {a, nil}, {nil, a}} {
+ meta := newBucketMetadata(bucket)
+ meta.Created = created
+ meta.defaultTimestamps()
+ if err := globalBucketMetadataSys.save(t.Context(), meta); err != nil {
+ t.Fatal(err)
+ }
+ for _, data := range pair {
+ var payload *string
+ if data != nil {
+ encoded := base64.StdEncoding.EncodeToString(data)
+ payload = &encoded
+ }
+ rec := applySRBucketMetaViaAdmin(t, cred, madmin.SRBucketMeta{Bucket: bucket, Type: madmin.SRBucketMetaTypeTags, Tags: payload, UpdatedAt: created.Add(time.Minute)})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("equal time apply: %d %s", rec.Code, rec.Body.String())
+ }
+ }
+ got, err := readBucketMetadata(t.Context(), obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := b
+ if pair[0] == nil || pair[1] == nil {
+ want = nil
+ }
+ if !bytes.Equal(got.TaggingConfigXML, want) || !got.TaggingConfigUpdatedAt.Equal(created.Add(time.Minute)) {
+ t.Fatalf("arrival order changed winner: %q at %v", got.TaggingConfigXML, got.TaggingConfigUpdatedAt)
+ }
+ }
+ })
+ }})
+}
diff --git a/cmd/site-replication.go b/cmd/site-replication.go
index 31e843e6f..41dce704e 100644
--- a/cmd/site-replication.go
+++ b/cmd/site-replication.go
@@ -819,6 +819,9 @@ func (c *SiteReplicationSys) MakeBucketHook(ctx context.Context, bucket string,
optsMap["forceCreate"] = "true"
}
createdAt, _ := globalBucketMetadataSys.CreatedAt(bucket)
+ if createdAt.IsZero() {
+ createdAt = opts.CreatedAt
+ }
optsMap["createdAt"] = createdAt.UTC().Format(time.RFC3339Nano)
opts.CreatedAt = createdAt
@@ -906,7 +909,7 @@ func enablePeerBucketVersioning(meta *BucketMetadata, lockEnabled bool) error {
config, err := versioning.ParseConfig(bytes.NewReader(meta.VersioningConfigXML))
if err != nil || (lockEnabled && (config.Suspended() || config.PrefixesExcluded())) {
meta.VersioningConfigXML = enabledBucketVersioningConfig
- meta.VersioningConfigUpdatedAt = UTCNow()
+ meta.VersioningConfigUpdatedAt = localBucketConfigUpdatedAt(*meta, bucketVersioningConfig, UTCNow())
return nil
}
if config.Enabled() {
@@ -917,7 +920,7 @@ func enablePeerBucketVersioning(meta *BucketMetadata, lockEnabled bool) error {
if err != nil {
return err
}
- meta.VersioningConfigUpdatedAt = UTCNow()
+ meta.VersioningConfigUpdatedAt = localBucketConfigUpdatedAt(*meta, bucketVersioningConfig, UTCNow())
return nil
}
@@ -947,7 +950,9 @@ func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Con
if err != nil {
return err
}
+ oldCreated := meta.Created
meta.SetCreatedAt(opts.CreatedAt)
+ rebaseBucketConfigDefaults(&meta, oldCreated)
if err = enablePeerBucketVersioning(&meta, opts.LockEnabled || len(meta.ObjectLockConfigXML) != 0); err != nil {
return err
@@ -958,7 +963,7 @@ func (c *SiteReplicationSys) PeerBucketMakeWithVersioningHandler(ctx context.Con
meta.ObjectLockConfigUpdatedAt = meta.Created
}
}
- return globalBucketMetadataSys.saveMetadata(bgContext(ctx), objAPI, meta)
+ return globalBucketMetadataSys.saveMetadata(bgContext(ctx), objAPI, &meta)
}()
if err != nil {
return wrapSRErr(c.annotateErr(makeBucketWithVersion, err))
@@ -1583,24 +1588,18 @@ func (c *SiteReplicationSys) BucketMetaHook(ctx context.Context, item madmin.SRB
// PeerBucketVersioningHandler - updates versioning config to local cluster.
func (c *SiteReplicationSys) PeerBucketVersioningHandler(ctx context.Context, bucket string, versioning *string, updatedAt time.Time) error {
+ var data []byte
if versioning != nil {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetVersioningConfig(bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
- configData, err := base64.StdEncoding.DecodeString(*versioning)
+ var err error
+ data, err = base64.StdEncoding.DecodeString(*versioning)
if err != nil {
return wrapSRErr(err)
}
- _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, configData)
- if err != nil {
- return wrapSRErr(err)
- }
- return nil
}
-
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketVersioningConfig, data, false, false, &updatedAt)
+ if err != nil {
+ return wrapSRErr(err)
+ }
return nil
}
@@ -1647,53 +1646,51 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
if item.Cors != nil {
replLogOnceIf(ctx, fmt.Errorf("ignoring CORS event for bucket %s from %v before bucket creation at %v", item.Bucket, item.UpdatedAt, meta.Created), "cors-event-before-bucket-creation-"+item.Bucket)
}
- return nil
+ item.Cors = nil
}
- if item.Policy != nil {
- meta.PolicyConfigJSON = item.Policy
- meta.PolicyConfigUpdatedAt = item.UpdatedAt
+ // Presence is separate from content: omitted RawMessage is not a delete;
+ // explicit JSON null still goes through the existing policy/quota parser.
+ updates := make(map[string][]byte, len(replicatedBucketConfigs))
+ if len(item.Policy) != 0 {
+ updates[bucketPolicyConfig] = item.Policy
}
-
- if item.Versioning != nil {
- configData, err := base64.StdEncoding.DecodeString(*item.Versioning)
+ if len(item.Quota) != 0 {
+ updates[bucketQuotaConfigFile] = item.Quota
+ }
+ for file, payload := range map[string]*string{
+ objectLockConfig: item.ObjectLockConfig, bucketVersioningConfig: item.Versioning,
+ bucketTaggingConfig: item.Tags, bucketSSEConfig: item.SSEConfig,
+ } {
+ if payload != nil {
+ data, err := base64.StdEncoding.DecodeString(*payload)
+ if err != nil {
+ return wrapSRErr(err)
+ }
+ updates[file] = data
+ }
+ }
+ if len(updates) != 0 {
+ if err := ensureBucketMetadataCreated(ctx, objectAPI, &meta); err != nil {
+ logBucketConfigReplication(ctx, item.Bucket, "bulk", "indeterminate", item.UpdatedAt, meta.Created, err.Error())
+ return wrapSRErr(err)
+ }
+ }
+ changed := false
+ for _, file := range replicatedBucketConfigs {
+ data, supplied := updates[file]
+ if !supplied {
+ continue
+ }
+ if item.UpdatedAt.Before(meta.Created) {
+ logBucketConfigReplication(ctx, item.Bucket, file, "before-created", item.UpdatedAt, meta.Created, "bulk event")
+ continue
+ }
+ applied, err := applyBucketConfig(&meta, file, data, item.UpdatedAt)
if err != nil {
return wrapSRErr(err)
}
- meta.VersioningConfigXML = configData
- meta.VersioningConfigUpdatedAt = item.UpdatedAt
- }
-
- if item.Tags != nil {
- configData, err := base64.StdEncoding.DecodeString(*item.Tags)
- if err != nil {
- return wrapSRErr(err)
- }
- meta.TaggingConfigXML = configData
- meta.TaggingConfigUpdatedAt = item.UpdatedAt
- }
-
- if item.ObjectLockConfig != nil {
- configData, err := base64.StdEncoding.DecodeString(*item.ObjectLockConfig)
- if err != nil {
- return wrapSRErr(err)
- }
- meta.ObjectLockConfigXML = configData
- meta.ObjectLockConfigUpdatedAt = item.UpdatedAt
- }
-
- if item.SSEConfig != nil {
- configData, err := base64.StdEncoding.DecodeString(*item.SSEConfig)
- if err != nil {
- return wrapSRErr(err)
- }
- meta.EncryptionConfigXML = configData
- meta.EncryptionConfigUpdatedAt = item.UpdatedAt
- }
-
- if item.Quota != nil {
- meta.QuotaConfigJSON = item.Quota
- meta.QuotaConfigUpdatedAt = item.UpdatedAt
+ changed = changed || applied
}
if item.Cors != nil {
@@ -1702,10 +1699,14 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
if compareCORSReplicationStates(localState, incoming) < 0 {
meta.CorsConfigXML = bytes.Clone(corsConfigData)
meta.CorsConfigUpdatedAt = item.UpdatedAt
+ changed = true
}
}
- if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, meta); err != nil {
+ if !changed {
+ return nil
+ }
+ if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, &meta); err != nil {
return err
}
unlock()
@@ -1716,62 +1717,35 @@ func (c *SiteReplicationSys) PeerBucketMetadataUpdateHandler(ctx context.Context
// PeerBucketPolicyHandler - copies/deletes policy to local cluster.
func (c *SiteReplicationSys) PeerBucketPolicyHandler(ctx context.Context, bucket string, policy *policy.BucketPolicy, updatedAt time.Time) error {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetPolicyConfig(bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
-
+ var data []byte
if policy != nil {
- configData, err := json.Marshal(policy)
+ var err error
+ data, err = canonicalBucketPolicy(policy)
if err != nil {
return wrapSRErr(err)
}
-
- _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, configData)
- if err != nil {
- return wrapSRErr(err)
- }
- return nil
}
-
- // Delete the bucket policy
- _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketPolicyConfig)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketPolicyConfig, data, false, false, &updatedAt)
if err != nil {
return wrapSRErr(err)
}
-
return nil
}
// PeerBucketTaggingHandler - copies/deletes tags to local cluster.
func (c *SiteReplicationSys) PeerBucketTaggingHandler(ctx context.Context, bucket string, tags *string, updatedAt time.Time) error {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetTaggingConfig(bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
-
+ var data []byte
if tags != nil {
- configData, err := base64.StdEncoding.DecodeString(*tags)
+ var err error
+ data, err = base64.StdEncoding.DecodeString(*tags)
if err != nil {
return wrapSRErr(err)
}
- _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, configData)
- if err != nil {
- return wrapSRErr(err)
- }
- return nil
}
-
- // Delete the tags
- _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketTaggingConfig)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketTaggingConfig, data, false, false, &updatedAt)
if err != nil {
return wrapSRErr(err)
}
-
return nil
}
@@ -1797,51 +1771,32 @@ func (c *SiteReplicationSys) peerBucketObjectLockConfigItem(ctx context.Context,
// PeerBucketObjectLockConfigHandler - sets object lock on local bucket.
func (c *SiteReplicationSys) PeerBucketObjectLockConfigHandler(ctx context.Context, bucket string, objectLockData *string, updatedAt time.Time) error {
+ var data []byte
if objectLockData != nil {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetObjectLockConfig(bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
-
- configData, err := base64.StdEncoding.DecodeString(*objectLockData)
+ var err error
+ data, err = base64.StdEncoding.DecodeString(*objectLockData)
if err != nil {
return wrapSRErr(err)
}
- _, err = globalBucketMetadataSys.Update(ctx, bucket, objectLockConfig, configData)
- if err != nil {
- return wrapSRErr(err)
- }
- return nil
}
-
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, objectLockConfig, data, false, false, &updatedAt)
+ if err != nil {
+ return wrapSRErr(err)
+ }
return nil
}
// PeerBucketSSEConfigHandler - copies/deletes SSE config to local cluster.
func (c *SiteReplicationSys) PeerBucketSSEConfigHandler(ctx context.Context, bucket string, sseConfig *string, updatedAt time.Time) error {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetSSEConfig(bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
-
+ var data []byte
if sseConfig != nil {
- configData, err := base64.StdEncoding.DecodeString(*sseConfig)
+ var err error
+ data, err = base64.StdEncoding.DecodeString(*sseConfig)
if err != nil {
return wrapSRErr(err)
}
- _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketSSEConfig, configData)
- if err != nil {
- return wrapSRErr(err)
- }
- return nil
}
-
- // Delete sse config
- _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketSSEConfig)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketSSEConfig, data, false, false, &updatedAt)
if err != nil {
return wrapSRErr(err)
}
@@ -2046,7 +2001,7 @@ func applyBucketCORSMetadata(ctx context.Context, objectAPI ObjectLayer, bucket
meta.CorsConfigXML = bytes.Clone(configData)
meta.CorsConfigUpdatedAt = updatedAt
- if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, meta); err != nil {
+ if err = globalBucketMetadataSys.saveMetadata(ctx, objectAPI, &meta); err != nil {
return time.Time{}, err
}
unlock()
@@ -2078,32 +2033,18 @@ func (c *SiteReplicationSys) PeerBucketCorsConfigHandler(ctx context.Context, bu
// PeerBucketQuotaConfigHandler - copies/deletes policy to local cluster.
func (c *SiteReplicationSys) PeerBucketQuotaConfigHandler(ctx context.Context, bucket string, quota *madmin.BucketQuota, updatedAt time.Time) error {
- // skip overwrite if local update is newer than peer update.
- if !updatedAt.IsZero() {
- if _, updateTm, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket); err == nil && updateTm.After(updatedAt) {
- return nil
- }
- }
-
+ var data []byte
if quota != nil {
- quotaData, err := json.Marshal(quota)
+ var err error
+ data, err = json.Marshal(quota)
if err != nil {
return wrapSRErr(err)
}
-
- if _, err = globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, quotaData); err != nil {
- return wrapSRErr(err)
- }
-
- return nil
}
-
- // Delete the bucket policy
- _, err := globalBucketMetadataSys.Delete(ctx, bucket, bucketQuotaConfigFile)
+ _, err := globalBucketMetadataSys.updateAndParseMetadata(ctx, bucket, bucketQuotaConfigFile, data, false, false, &updatedAt)
if err != nil {
return wrapSRErr(err)
}
-
return nil
}
@@ -2201,10 +2142,14 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
if err != nil && !errors.Is(err, errConfigNotFound) {
return errSRBackendIssue(err)
}
+ if err := ensureBucketMetadataCreated(ctx, objAPI, &meta); err != nil {
+ logBucketConfigReplication(ctx, bucket, "initial-sync", "indeterminate", time.Time{}, meta.Created, err.Error())
+ return errSRBackendIssue(err)
+ }
opts := MakeBucketOptions{
LockEnabled: meta.ObjectLocking(),
- CreatedAt: bucketInfo.Created.UTC(),
+ CreatedAt: meta.Created.UTC(),
}
// Now call the MakeBucketHook on existing bucket - this will
@@ -2213,57 +2158,17 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
return errSRBucketConfigError(err)
}
- // Replicate bucket policy if present.
- policyJSON, tm := meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt
- if len(policyJSON) > 0 {
- err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypePolicy,
- Bucket: bucket,
- Policy: policyJSON,
- UpdatedAt: tm,
- })
+ // Versioning is bootstrapped by MakeBucketHook and then reconciled by
+ // heal. Preserve the existing initial-sync fields during rolling upgrades.
+ for _, file := range []string{bucketPolicyConfig, bucketTaggingConfig, objectLockConfig, bucketSSEConfig, bucketQuotaConfigFile} {
+ event, send, err := initialBucketConfigReplicationEvent(meta, file)
if err != nil {
return errSRBucketMetaError(err)
}
- }
-
- // Replicate bucket tags if present.
- tagCfg, tm := meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt
- if len(tagCfg) > 0 {
- tagCfgStr := base64.StdEncoding.EncodeToString(tagCfg)
- err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeTags,
- Bucket: bucket,
- Tags: &tagCfgStr,
- UpdatedAt: tm,
- })
- if err != nil {
- return errSRBucketMetaError(err)
- }
- }
-
- // Replicate object-lock config if present.
- objLockCfgData, tm := meta.ObjectLockConfigXML, meta.ObjectLockConfigUpdatedAt
- if len(objLockCfgData) > 0 {
- objLockStr := base64.StdEncoding.EncodeToString(objLockCfgData)
- err = c.BucketMetaHook(ctx, newSRBucketObjectLockMeta(bucket, &objLockStr, tm))
- if err != nil {
- return errSRBucketMetaError(err)
- }
- }
-
- // Replicate existing bucket bucket encryption settings
- sseConfigData, tm := meta.EncryptionConfigXML, meta.EncryptionConfigUpdatedAt
- if len(sseConfigData) > 0 {
- sseConfigStr := base64.StdEncoding.EncodeToString(sseConfigData)
- err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeSSEConfig,
- Bucket: bucket,
- SSEConfig: &sseConfigStr,
- UpdatedAt: tm,
- })
- if err != nil {
- return errSRBucketMetaError(err)
+ if send {
+ if err := c.BucketMetaHook(ctx, event); err != nil {
+ return errSRBucketMetaError(err)
+ }
}
}
@@ -2275,20 +2180,6 @@ func (c *SiteReplicationSys) syncToAllPeers(ctx context.Context, addOpts madmin.
}
}
- // Replicate existing bucket quotas settings
- quotaConfigJSON, tm := meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt
- if len(quotaConfigJSON) > 0 {
- err = c.BucketMetaHook(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeQuotaConfig,
- Bucket: bucket,
- Quota: quotaConfigJSON,
- UpdatedAt: tm,
- })
- if err != nil {
- return errSRBucketMetaError(err)
- }
- }
-
// Replicate ILM expiry rules if needed
if addOpts.ReplicateILMExpiry && (meta.lifecycleConfig != nil && meta.lifecycleConfig.HasExpiry()) {
var expLclCfg lifecycle.Lifecycle
@@ -3900,18 +3791,19 @@ func isBktPolicyReplicated(total int, policies []*policy.BucketPolicy) bool {
return false
}
// check if policies match between sites
- var prev *policy.BucketPolicy
- for i, p := range policies {
+ var prev []byte
+ first := true
+ for _, p := range policies {
if p == nil {
continue
}
- if i == 0 {
- prev = p
- continue
- }
- if !prev.Equals(*p) {
+ // Heal treats statement/set permutations as the same effective state.
+ // Status must agree even when an upgraded peer retains legacy bytes.
+ key, err := canonicalBucketPolicy(p)
+ if err != nil || !first && !bytes.Equal(prev, key) {
return false
}
+ prev, first = key, false
}
return true
}
@@ -4073,6 +3965,8 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
tagCfgStr := base64.StdEncoding.EncodeToString(meta.TaggingConfigXML)
bms.Tags = &tagCfgStr
bms.TagConfigUpdatedAt = meta.TaggingConfigUpdatedAt
+ } else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.TaggingConfigUpdatedAt.After(meta.Created) {
+ bms.TagConfigUpdatedAt = meta.TaggingConfigUpdatedAt
}
if len(meta.VersioningConfigXML) > 0 {
@@ -4091,12 +3985,16 @@ func (c *SiteReplicationSys) SiteReplicationMetaInfo(ctx context.Context, objAPI
quotaConfigStr := base64.StdEncoding.EncodeToString(meta.QuotaConfigJSON)
bms.QuotaConfig = "aConfigStr
bms.QuotaConfigUpdatedAt = meta.QuotaConfigUpdatedAt
+ } else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.QuotaConfigUpdatedAt.After(meta.Created) {
+ bms.QuotaConfigUpdatedAt = meta.QuotaConfigUpdatedAt
}
if len(meta.EncryptionConfigXML) > 0 {
sseConfigStr := base64.StdEncoding.EncodeToString(meta.EncryptionConfigXML)
bms.SSEConfig = &sseConfigStr
bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
+ } else if globalSiteReplicationMetadataTombstones && !meta.Created.IsZero() && meta.EncryptionConfigUpdatedAt.After(meta.Created) {
+ bms.SSEConfigUpdatedAt = meta.EncryptionConfigUpdatedAt
}
bms.CorsConfigUpdatedAt = meta.CorsConfigUpdatedAt
@@ -4950,366 +4848,23 @@ func (c *SiteReplicationSys) healBucketILMExpiry(ctx context.Context, objAPI Obj
}
func (c *SiteReplicationSys) healTagMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- bs := info.BucketStats[bucket]
-
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestTaggingConfig *string
- )
-
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.TagConfigUpdatedAt
- latestID = dID
- latestTaggingConfig = ss.meta.Tags
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.TagConfigUpdatedAt) {
- continue
- }
- if ss.meta.TagConfigUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.TagConfigUpdatedAt
- latestID = dID
- latestTaggingConfig = ss.meta.Tags
- }
- }
- latestPeerName = info.Sites[latestID].Name
- var latestTaggingConfigBytes []byte
- var err error
- if latestTaggingConfig != nil {
- latestTaggingConfigBytes, err = base64.StdEncoding.DecodeString(*latestTaggingConfig)
- if err != nil {
- return err
- }
- }
- for dID, bStatus := range bs {
- if !bStatus.TagMismatch {
- continue
- }
- if isBucketMetadataEqual(latestTaggingConfig, bStatus.meta.Tags) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, latestTaggingConfigBytes); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal tagging metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
- err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeTags,
- Bucket: bucket,
- Tags: latestTaggingConfig,
- })
- if err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal tagging metadata for peer %s from peer %s : %w", peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, bucketTaggingConfig, info)
}
func (c *SiteReplicationSys) healBucketPolicies(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- bs := info.BucketStats[bucket]
-
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestIAMPolicy json.RawMessage
- )
-
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.PolicyUpdatedAt
- latestID = dID
- latestIAMPolicy = ss.meta.Policy
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.PolicyUpdatedAt) {
- continue
- }
- if ss.meta.PolicyUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.PolicyUpdatedAt
- latestID = dID
- latestIAMPolicy = ss.meta.Policy
- }
- }
- latestPeerName = info.Sites[latestID].Name
- for dID, bStatus := range bs {
- if !bStatus.PolicyMismatch {
- continue
- }
- if strings.EqualFold(string(latestIAMPolicy), string(bStatus.meta.Policy)) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketPolicyConfig, latestIAMPolicy); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal bucket policy metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
- if err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypePolicy,
- Bucket: bucket,
- Policy: latestIAMPolicy,
- UpdatedAt: lastUpdate,
- }); err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal bucket policy metadata for peer %s from peer %s : %w",
- peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, bucketPolicyConfig, info)
}
func (c *SiteReplicationSys) healBucketQuotaConfig(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- bs := info.BucketStats[bucket]
-
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestQuotaConfig *string
- latestQuotaConfigBytes []byte
- )
-
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.QuotaConfigUpdatedAt
- latestID = dID
- latestQuotaConfig = ss.meta.QuotaConfig
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.QuotaConfigUpdatedAt) {
- continue
- }
- if ss.meta.QuotaConfigUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.QuotaConfigUpdatedAt
- latestID = dID
- latestQuotaConfig = ss.meta.QuotaConfig
- }
- }
-
- var err error
- if latestQuotaConfig != nil {
- latestQuotaConfigBytes, err = base64.StdEncoding.DecodeString(*latestQuotaConfig)
- if err != nil {
- return err
- }
- }
-
- latestPeerName = info.Sites[latestID].Name
- for dID, bStatus := range bs {
- if !bStatus.QuotaCfgMismatch {
- continue
- }
- if isBucketMetadataEqual(latestQuotaConfig, bStatus.meta.QuotaConfig) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketQuotaConfigFile, latestQuotaConfigBytes); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal quota metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
-
- if err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeQuotaConfig,
- Bucket: bucket,
- Quota: latestQuotaConfigBytes,
- UpdatedAt: lastUpdate,
- }); err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal quota config metadata for peer %s from peer %s : %w",
- peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, bucketQuotaConfigFile, info)
}
func (c *SiteReplicationSys) healVersioningMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestVersioningConfig *string
- )
-
- bs := info.BucketStats[bucket]
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.VersioningConfigUpdatedAt
- latestID = dID
- latestVersioningConfig = ss.meta.Versioning
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.VersioningConfigUpdatedAt) {
- continue
- }
- if ss.meta.VersioningConfigUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.VersioningConfigUpdatedAt
- latestID = dID
- latestVersioningConfig = ss.meta.Versioning
- }
- }
-
- latestPeerName = info.Sites[latestID].Name
- var latestVersioningConfigBytes []byte
- var err error
- if latestVersioningConfig != nil {
- latestVersioningConfigBytes, err = base64.StdEncoding.DecodeString(*latestVersioningConfig)
- if err != nil {
- return err
- }
- }
-
- for dID, bStatus := range bs {
- if !bStatus.VersioningConfigMismatch {
- continue
- }
- if isBucketMetadataEqual(latestVersioningConfig, bStatus.meta.Versioning) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketVersioningConfig, latestVersioningConfigBytes); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal versioning metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
- err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeVersionConfig,
- Bucket: bucket,
- Versioning: latestVersioningConfig,
- UpdatedAt: lastUpdate,
- })
- if err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal versioning config metadata for peer %s from peer %s : %w",
- peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, bucketVersioningConfig, info)
}
func (c *SiteReplicationSys) healSSEMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestSSEConfig *string
- )
-
- bs := info.BucketStats[bucket]
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.SSEConfigUpdatedAt
- latestID = dID
- latestSSEConfig = ss.meta.SSEConfig
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.SSEConfigUpdatedAt) {
- continue
- }
- if ss.meta.SSEConfigUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.SSEConfigUpdatedAt
- latestID = dID
- latestSSEConfig = ss.meta.SSEConfig
- }
- }
-
- latestPeerName = info.Sites[latestID].Name
- var latestSSEConfigBytes []byte
- var err error
- if latestSSEConfig != nil {
- latestSSEConfigBytes, err = base64.StdEncoding.DecodeString(*latestSSEConfig)
- if err != nil {
- return err
- }
- }
-
- for dID, bStatus := range bs {
- if !bStatus.SSEConfigMismatch {
- continue
- }
- if isBucketMetadataEqual(latestSSEConfig, bStatus.meta.SSEConfig) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, bucketSSEConfig, latestSSEConfigBytes); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal sse metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
- err = admClient.SRPeerReplicateBucketMeta(ctx, madmin.SRBucketMeta{
- Type: madmin.SRBucketMetaTypeSSEConfig,
- Bucket: bucket,
- SSEConfig: latestSSEConfig,
- UpdatedAt: lastUpdate,
- })
- if err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal SSE config metadata for peer %s from peer %s : %w",
- peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, bucketSSEConfig, info)
}
func latestCORSConfig(bs map[string]srBucketStatsSummary) (latestID string, latest corsReplicationState, ok bool) {
@@ -5377,73 +4932,7 @@ func (c *SiteReplicationSys) healCORSMetadata(ctx context.Context, objAPI Object
}
func (c *SiteReplicationSys) healOLockConfigMetadata(ctx context.Context, objAPI ObjectLayer, bucket string, info srStatusInfo) error {
- bs := info.BucketStats[bucket]
-
- c.RLock()
- defer c.RUnlock()
- if !c.enabled {
- return nil
- }
- var (
- latestID, latestPeerName string
- lastUpdate time.Time
- latestObjLockConfig *string
- )
-
- for dID, ss := range bs {
- if lastUpdate.IsZero() {
- lastUpdate = ss.meta.ObjectLockConfigUpdatedAt
- latestID = dID
- latestObjLockConfig = ss.meta.ObjectLockConfig
- }
- // avoid considering just created buckets as latest. Perhaps this site
- // just joined cluster replication and yet to be sync'd
- if ss.meta.CreatedAt.Equal(ss.meta.ObjectLockConfigUpdatedAt) {
- continue
- }
- if ss.meta.ObjectLockConfig != nil && ss.meta.ObjectLockConfigUpdatedAt.After(lastUpdate) {
- lastUpdate = ss.meta.ObjectLockConfigUpdatedAt
- latestID = dID
- latestObjLockConfig = ss.meta.ObjectLockConfig
- }
- }
- latestPeerName = info.Sites[latestID].Name
- var latestObjLockConfigBytes []byte
- var err error
- if latestObjLockConfig != nil {
- latestObjLockConfigBytes, err = base64.StdEncoding.DecodeString(*latestObjLockConfig)
- if err != nil {
- return err
- }
- }
-
- for dID, bStatus := range bs {
- if !bStatus.OLockConfigMismatch {
- continue
- }
- if isBucketMetadataEqual(latestObjLockConfig, bStatus.meta.ObjectLockConfig) {
- continue
- }
- if dID == globalDeploymentID() {
- if _, err := globalBucketMetadataSys.Update(ctx, bucket, objectLockConfig, latestObjLockConfigBytes); err != nil {
- replLogIf(ctx, fmt.Errorf("Unable to heal objectlock config metadata from peer site %s : %w", latestPeerName, err))
- }
- continue
- }
-
- admClient, err := c.getAdminClient(ctx, dID)
- if err != nil {
- return wrapSRErr(err)
- }
- peerName := info.Sites[dID].Name
- err = admClient.SRPeerReplicateBucketMeta(ctx, newSRBucketObjectLockMeta(bucket, latestObjLockConfig, lastUpdate))
- if err != nil {
- replLogIf(ctx, c.annotatePeerErr(peerName, replicateBucketMetadata,
- fmt.Errorf("Unable to heal object lock config metadata for peer %s from peer %s : %w",
- peerName, latestPeerName, err)))
- }
- }
- return nil
+ return c.healBucketConfig(ctx, bucket, objectLockConfig, info)
}
func (c *SiteReplicationSys) purgeDeletedBucket(ctx context.Context, objAPI ObjectLayer, bucket string) {
@@ -5680,17 +5169,6 @@ func (c *SiteReplicationSys) healBucketReplicationConfig(ctx context.Context, ob
return nil
}
-func isBucketMetadataEqual(one, two *string) bool {
- switch {
- case one == nil && two == nil:
- return true
- case one == nil || two == nil:
- return false
- default:
- return *one == *two
- }
-}
-
func (c *SiteReplicationSys) healIAMSystem(ctx context.Context, objAPI ObjectLayer) error {
info, err := c.siteReplicationStatus(ctx, objAPI, madmin.SRStatusOptions{
Users: true,
diff --git a/cmd/site-replication_test.go b/cmd/site-replication_test.go
index 6f4064b04..60f05504d 100644
--- a/cmd/site-replication_test.go
+++ b/cmd/site-replication_test.go
@@ -133,28 +133,3 @@ func TestSRBucketMetaCorsRoundTrip(t *testing.T) {
t.Fatalf("expected nil Cors for deletion, got %q", *gotDel.Cors)
}
}
-
-// TestIsBucketMetadataEqualCors covers the pointer-comparison helper used by
-// the CORS heal path to decide whether a peer already holds the latest config.
-func TestIsBucketMetadataEqualCors(t *testing.T) {
- a := base64.StdEncoding.EncodeToString([]byte("config-a"))
- b := base64.StdEncoding.EncodeToString([]byte("config-b"))
-
- cases := []struct {
- name string
- one *string
- two *string
- want bool
- }{
- {"both nil", nil, nil, true},
- {"one nil", &a, nil, false},
- {"other nil", nil, &b, false},
- {"equal", &a, &a, true},
- {"different", &a, &b, false},
- }
- for _, tc := range cases {
- if got := isBucketMetadataEqual(tc.one, tc.two); got != tc.want {
- t.Errorf("%s: got %v want %v", tc.name, got, tc.want)
- }
- }
-}
diff --git a/docs/investigations/issue-77-current.md b/docs/investigations/issue-77-current.md
new file mode 100644
index 000000000..2a8652463
--- /dev/null
+++ b/docs/investigations/issue-77-current.md
@@ -0,0 +1,103 @@
+# Issue #77 当前核验与最小修复建议
+
+> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
+
+核验日期:2026-09-12。对象:[SILO #77](https://github.com/pgsty/silo/issues/77)。代码基准:远端 `main` 的 `5c576581631561c446f30ae5b566f0aa793adc1c`,在独立 detached worktree 中运行测试。用户工作目录仍位于 `12f631b50`;两者差异为 #179 的 federation 修复,不涉及本次复制元数据代码。
+
+**结论:问题真实,当前主干仍未修完,应保持打开。原方案的 A/B 核心必要,但不能照搬 8 月的实施清单;已有基础设施可以复用,同时必须补上批量复制入口和相同内容的时间戳同步。**
+
+本次是分析与复现,没有修改产品代码、提交、发布或修改 GitHub Issue。下述测试是本地 ObjectLayer 与进程内 HTTP/RPC 复现,不是线上多站点验收。
+
+## 1. 当前状态
+
+GitHub API 实时返回 #77 为 `OPEN`,未分配负责人或 milestone,最后更新为 `2026-09-08T14:45:10Z`。核验时仓库没有打开的 PR。
+
+| 范围 | 当前事实 | 是否仍属遗留项 |
+| --- | --- | --- |
+| #77-C,各站点统计 | [#91](https://github.com/pgsty/silo/pull/91) 于 8 月 29 日合并;本次现有统计回归测试通过 | 否,不应重复实现 |
+| #77-A,heal 选源与输入 | 仍先用 map 首项初始化,再跳过创建默认值;Tag 远端 heal 仍不携带时间戳 | 是 |
+| #77-B,源时间与删除状态 | 六类专用处理函数仍调用到达时间写入路径;部分删除状态不导出 | 是 |
+| #77-D,持续不一致诊断 | 已有损坏配置日志;旧事件静默跳过、默认状态无法选源等诊断未完成 | 是 |
+| Object Lock 错误 wire 字段、接管桶覆盖已有配置 | [#76](https://github.com/pgsty/silo/issues/76)、[#78](https://github.com/pgsty/silo/issues/78) 已关闭,对应 #89、#90 已合并;相关测试本次通过 | 否 |
+| 元数据整记录并发写、删除后重建 | [#103](https://github.com/pgsty/silo/pull/103)、[#156](https://github.com/pgsty/silo/pull/156) 已提供桶锁和保存前物理桶检查;删除后排队写测试本次通过 | 基础已具备,但不能代替同字段时间排序 |
+| 评论提及的 MRF 丢弃观测、delete-marker purge | [#152](https://github.com/pgsty/silo/issues/152)、[#153](https://github.com/pgsty/silo/issues/153) 已于 9 月 9 日随 [#162](https://github.com/pgsty/silo/pull/162) 关闭 | 不再列为 #77 的待实现项 |
+
+#162 明确保留“405 表示 marker 仍存在”的语义,没有采用报告人建议的“405 一律判定永久删除完成”;它也未声称复现外部报告的请求量或持续 405 风暴。本次只核验其合并/关闭状态与提交说明,没有重跑那组故障实验。
+
+最新公开 Release 仍是 `RELEASE.2026-09-03T13-18-01Z`。#77 的状态不能用“已有统计修复”或“已有桶锁”推导为已修复,也不能用较晚 PR 的合并推导为已发布。
+
+## 2. 已复现的真实问题
+
+最直接的例子:源端 10:00 PUT 配置,10:01 DELETE;目标端积压到 10:05 才接收 PUT,并把配置时间写成 10:05。随后接收源时间为 10:01 的 DELETE,判断它比本地旧,返回 HTTP 200,却保留配置。这个例子不需要机器时钟偏差,只需要事件延迟。
+
+根因在 [updateAndParse](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/bucket-metadata-sys.go#L129):它在锁内统一使用 `UTCNow()`;专用 peer handler 在调用它之前,用 getter 返回的时间做比较。
+
+| 配置类型 | peer PUT 保留源时间 | 较新源 DELETE | 本地删除后旧 PUT | 删除时间在元数据导出中可见 |
+| --- | --- | --- | --- | --- |
+| Policy | 否 | HTTP 200,但未删除 | 会复活 | 是 |
+| Tags | 否 | HTTP 200,但未删除 | 会复活 | 否 |
+| SSE | 否 | HTTP 200,但未删除 | 会复活 | 否 |
+| Quota | 否 | HTTP 200,但未删除 | 本次未复活,getter 保留删除时间 | 否 |
+| Versioning | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
+| Object Lock | 否 | nil 正确地不修改配置 | 不适用 | 不应套用可删除配置规则 |
+
+矩阵每项均在 `ErasureSD` 与 16 盘 `Erasure` 两个本地后端执行。六类专用事件通过实际签名 admin 路由调用,检查落盘状态;导出调用实际 `SiteReplicationMetaInfo`。不能把 Quota 与 Policy/Tags/SSE 的 getter 行为写成完全相同。
+
+另外复现了以下路径:
+
+1. **检查与写入不在同一临界区。** 阻塞旧 Tag 事件的锁获取,在锁内提交较新 Tag,再释放旧事件;旧事件仍覆盖新内容。#103 解决跨字段丢更新,没有解决 handler 的 getter → Update 竞态。
+2. **批量元数据入口绕过逐字段排序。** 已持久化较新 Tag 后,发送较旧 bulk 事件,实际 admin 路由返回 HTTP 200,Tag 内容和时间都倒退。该入口被 [桶元数据导入](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/admin-bucket-handlers.go#L1106) 使用;[PeerBucketMetadataUpdateHandler](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L1608) 只检查桶创建时间,非 CORS 字段缺少与已有字段时间的比较。这是旧实施清单漏掉的入口。
+3. **默认值能够成为错误的 heal 来源。** 两站点状态中,一个有真实 Policy,另一个只有更晚的 `CreatedAt == PolicyUpdatedAt` 默认值。最后一次复现分别有 5/32、2/32 轮错误删除有效 Policy,取决于 Go map 遍历顺序。该计数只是复现样本,不能推断生产发生率。
+4. **远端 Tag heal 丢失源时间。** 实际 HTTP 捕获的 `UpdatedAt` 为零,见 [发送字段](https://github.com/pgsty/silo/blob/5c576581631561c446f30ae5b566f0aa793adc1c/cmd/site-replication.go#L5011)。
+5. **同内容、不同时间戳没有同步。** 即便强制 `TagMismatch=true`,内容比较仍使 heal 跳过,较旧排序时间保持不变。两站点看似内容相同,之后却可能对同一个延迟事件做出不同决定。旧方案只统一写入时间、保留全部 heal 跳过条件,仍不完整。
+6. **Quota 的 nil heal 留下缓存。** 向现有 heal 传入显式较新删除状态,磁盘 `QuotaConfigJSON` 被清空,但 `GetQuotaConfig` 仍返回 1024 字节的旧硬配额。这里显式构造了删除状态,因为当前 exporter 正在隐藏它;这是新增 tombstone 导出不能直接交给旧 heal 的具体证据。
+
+从当前 `git blame` 和基线比对看,核心错误继承自 MinIO:heal 初始化逻辑来自 2022 年 `3a64580663`,专用 handler 的旧事件检查来自 2022 年 `7cc9286e0f`,bulk 入口来自 2023 年 `0cde37be50`;0806 基线已包含相关逻辑。此次没有发现其由 SILO 最近的 CORS 或统计修改引入。
+
+对于使用 site replication 的部署,建议按 P1 正确性问题处理:撤销的桶策略可能保留或复活,默认加密和配额也可能与源端不一致。没有启用站点复制的正常单站点请求不触发这些复制路径;本次没有证明对象数据本体丢失。
+
+## 3. 最小且必要的修复范围
+
+**第一部分:收束 A 的选源和输入修复。** 提取一个小的内部选择函数,在初始化候选之前排除零时间和创建默认值;无可信候选时不写入。六类 heal 使用它,补齐 Tag 的源 `UpdatedAt`。Versioning/Object Lock 遇到 nil 保持 no-op,Quota 删除使用正确的删除路径。不能重新做 #76/#78 或把 Lifecycle、CORS 一并重构。
+
+**第二部分:收束 B 的状态更新,包含本次发现的入口遗漏。** 在既有 `metadata.lock` 内完成读取原始字段时间、判旧、写入源时间和保存;普通本地写入保留现有契约。复用 `updateAndParse` 的类型分支及现有 `saveMetadata`,不再从公开 getter 获取删除时间,也不改变 getter 的 S3 错误语义。peer 更新和删除保留各自解析规则,内部返回是否应用,重复且相同的状态不重复保存。
+
+本地 heal 必须使用同一排序路径,不能选好源后调用普通 `Update/Delete` 再生成到达时间。批量复制入口也必须在其现有整记录锁内逐字段判旧;bulk 的 nil 继续表示“未提供此字段”,不能改成批量删除。heal 需要同步较新的时间戳,即便内容已经一致;这不要求重写 #91 的计数或增加公开 API。
+
+当前 `saveMetadata` 已在锁内检查物理桶存在,桶删除也使用同一把锁。8 月方案里另加一套 `peer-require-existing` 防幽灵桶机制已没有必要,应保留并复用现有保护。确有 `Created == 0` 的历史桶如何补齐创建时间仍应覆盖,但不能退化当前所有写入都执行的存在性检查。
+
+**第三部分:单独启用删除时间导出,补足轻量诊断。** Policy 已导出时间;实际新增的是 Tags/SSE/Quota 的 nil payload 时间。先使接收与 heal 能正确处理删除,再开放这些状态。混合旧版 SILO 的风险已经有 Quota 缓存复现支撑;这是产品自身的滚动升级问题,不是要求兼容未经修改的上游 MinIO。
+
+可以使用仅针对这项导出的明确 opt-in:默认关闭新增导出,全站点升级到具备 A/B 修复的版本后启用。若项目选择自动能力协商,可后续单独实现;不必为了它阻塞没有新增 wire 字段的选源与接收端修复,也不需要为本 Issue 建一个通用能力框架。具体开关尚未实现,不应把这里的建议当成现有配置。
+
+诊断只需解释“过旧事件”“早于桶创建”“只有默认/未知候选”等跳过原因,使用现有有界去重/限频设施,保持 RPC 的既有成功语义。不需要另建重试队列、每轮日志或大型监控系统。
+
+有三项边界必须在实现中明确:
+
+- 旧版 Tag heal 会发送零时间。新实现不能无说明地全部拒绝;可保留明确的兼容降级路径,在旧节点存在时不承诺完整排序收敛。
+- 相同时间戳、不同内容的冲突:只给 heal 增加 deployment ID 平局规则,并不能让到达顺序不同的 peer apply 本身确定。若承诺此类冲突也收敛,apply/heal 应共用一个类型内比较规则;可参考 CORS 的删除优先和载荷排序,不需要新增持久化源站 ID。若不纳入本轮,必须列为剩余边界,不能称全量收敛审计完成。
+- 旧版本已经写坏的到达时间无法从现有记录还原。升级不会自动恢复历史操作顺序;应由操作者确认权威状态,并在升级和时钟检查后重新提交相关配置/删除,再验证各站点。
+
+因此,建议交付为有限的选源修复、统一源时间更新、删除导出与诊断三个可审核部分。已有锁、物理桶保护和 C 的计数修复直接复用。预期产品修改集中在 Server 的 site-replication 与 bucket-metadata 路径,无须为核心修复升级 Console/mcli/silo-pkg、改存储格式或重写复制架构。
+
+## 4. 验证证据与关闭条件
+
+本次最后一轮执行:
+
+```text
+GOWORK=off go test -tags kqueue,dev ./cmd \
+ -run 'TestIssue77Current|TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig|TestPeerBucketObjectLockMetadata|TestPeerBucketAdoption|TestQueuedMetadataUpdateAfterDelete' \
+ -count=1 -timeout=5m -v
+```
+
+四组审计复现测试包含 24 个后端/场景子测试,均暴露预期的现存缺陷;八个现有回归测试通过。总命令退出码为 1,原因是上述针对期望正确行为的断言失败,不代表修复验收通过。未运行全量测试、race suite、真实多站点中断/重启或滚动升级实验。
+
+保留的证据:
+
+- [完整运行日志](issue-77/current-tests.log)
+- [源时间、删除和 bulk 审计测试](issue-77/issue77_review_test.go.txt)
+- [heal 与锁竞态审计测试](issue-77/issue77_heal_review_test.go.txt)
+
+测试以 `.go.txt` 保存,避免将故意失败的审计用例加入正常 Go 测试集。可在上述 SHA 的独立 worktree 中复制为 `cmd/issue77_review_test.go` 和 `cmd/issue77_heal_review_test.go` 后重跑。创建默认值测试使用重复 map 遍历来观测缺陷,正式回归应在抽出选择函数后改成确定性用例。
+
+关闭 #77 前需要:上述失败场景转为正确行为;分别覆盖六类配置、落盘重载、漏发/重复/乱序、同字段竞态、同内容新时间、批量导入入口、legacy 桶、删除后的桶及混合版本。全站点升级后的实际断线重连/重启验证应保留独立证据。当前结论是“缺陷与实施范围已核实”,不是“修复完成”。
diff --git a/docs/investigations/issue-77-plan.md b/docs/investigations/issue-77-plan.md
new file mode 100644
index 000000000..dab248f42
--- /dev/null
+++ b/docs/investigations/issue-77-plan.md
@@ -0,0 +1,123 @@
+# Issue #77 最小充分修复计划
+
+> 历史快照(2026-09-12,实施前)。其中的 Issue 状态、待办和方案约束只描述当时情况;最终实现、修正与验收以 [归档总记录](issue-77.md) 为准。
+
+版本:v4 定稿,2026-09-12。状态:作者复核完成,Claude Code `claude-opus-5 --effort max` 四轮实际评审后,最终结论为 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**。非阻断说明已落实到本计划,见 [最终评审汇总](issue-77/review/final-review.md)。
+
+基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](issue-77-current.md),意见处置见 [首轮处置](issue-77/review/decisions-v2.md)、[第二轮处置](issue-77/review/decisions-v3.md)、[接管边界补查](issue-77/review/decisions-v4.md)。
+
+## 目标与边界
+
+修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
+
+在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
+
+不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
+
+## 行为契约
+
+### 1. 明确事件、缺省值与删除
+
+| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
+| --- | --- | --- | --- |
+| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
+| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
+| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
+| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
+
+“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
+
+合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
+
+**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
+
+### 2. 统一状态与排序
+
+内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
+
+- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
+- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
+- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
+- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
+- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。deployment ID 不参与上述比较,也不作为新字段保存。
+- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
+- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
+
+### 3. 先得到有效状态,再比较与提交
+
+原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
+
+- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
+- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
+- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
+- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
+- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
+
+## 三个实现提交
+
+### 提交 1:原子 apply、发送一致性与本地时间
+
+主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
+
+1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
+2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
+3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
+ 接管已有桶时保留原 Created,再执行现有 `SetCreatedAt`;如果 Created 改变,仅将这六类中原本为零或等于原 Created 的默认字段时间调整到新 Created,随后再做既有 versioning/lock bootstrap。原本晚于旧 Created 的真实修改/删除时间及其配置保持不变。不能仅凭 payload 为 nil 判断默认值。该小分支防止 Created 前移时默认值变成假墓碑、后移时历史初值被误判为无效,复用已有接管锁,不重做 #78 的配置保护。代码注释明确限定六类的原因;本轮不改新比较器未读取的其它配置时间。
+4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
+5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
+
+完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
+
+### 提交 2:heal 选源与应用同规则
+
+主要文件:`cmd/site-replication.go`。
+
+1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
+2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
+3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
+4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
+5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
+
+完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
+
+### 提交 3:新增删除传播与有界诊断
+
+只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
+
+| 行为 | off:升级阶段 | on:所有参与节点修复后 |
+| --- | --- | --- |
+| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
+| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
+| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
+| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
+| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
+
+开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
+
+开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
+
+日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
+
+Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
+
+## 最小验收矩阵
+
+| 组 | 必须覆盖 | 证据方式 |
+| --- | --- | --- |
+| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
+| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
+| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
+| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
+| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
+| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
+| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
+| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间等于 Created 的桶经初次同步、一轮 heal 后一致,第二轮无写入/广播;接管 Created 前移/后移/不变时默认时间仍是 baseline,真实 PUT/DELETE 时间不变;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
+| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
+
+固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
+
+## 作者复核结论
+
+三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
+
+Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,第三轮确认历史桶初始化边界,第四轮确认接管默认时间修正必要、充分且未扩大范围;最后两轮均为 0 阻断。四轮原文、版本快照、逐项处置与模型调用证据均保留。方案可以进入实现;当前只完成方案和诊断,仍须通过上述实现期验证,不能据此认定问题已修复或可关闭。
diff --git a/docs/investigations/issue-77.md b/docs/investigations/issue-77.md
new file mode 100644
index 000000000..7ee9c1aa1
--- /dev/null
+++ b/docs/investigations/issue-77.md
@@ -0,0 +1,85 @@
+# #77:桶配置复制修复与验收归档
+
+归档日期:2026-09-12。对象为 [SILO #77](https://github.com/pgsty/silo/issues/77)。问题真实;来源时间、删除状态和 heal 选源共同决定是否收敛,不能只补一个删除分支。最终实际 Claude Code Opus 5 Max 实现审查结论为 **GO_WITH_NONBLOCKING_NOTES,阻断 0**,完整 cmd 和最终 lint 随后通过。
+
+本文固定研究与验收时的事实。合并状态以 Issue 关联 PR 为准;主干包含代码不等于镜像、软件包或生产部署已经更新。研究叙述见伴生站的[中文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.zh.md)和[英文设计记录](https://github.com/pgsty/silo.pgsty.com/blob/main/content/blog/design/bucket-metadata-convergence.md),操作契约见 [Server site-replication README](../site-replication/README.md)。
+
+## 实现为什么最小、必要且足够
+
+源端 10:00 PUT、10:01 DELETE,目标到 10:05 才收到 PUT。旧实现把字段时间写成 10:05,随后把源时间为 10:01 的删除当作旧事件跳过,返回成功但保留配置。删除后没有导出时间的字段,在漏发后还会被对端旧值恢复。另有锁外判旧、批量入口绕过排序、默认值抢占来源、Tags heal 丢失时间和同内容不更新时间等独立入口。
+
+| 必要改动 | 少了它会发生什么 | 复用的边界 |
+| --- | --- | --- |
+| 在现有整桶锁内读取、比较并保存来源状态 | 锁外判旧仍可覆盖并发的新状态;到达时间继续污染排序 | 既有 `.metadata.bin`、`metadata.lock` 和物理桶存在检查 |
+| Policy、Tags、SSE、Quota、Versioning、Object Lock 共用确定性比较 | 等时冲突仍依赖到达顺序;heal 与接收端可能选出不同结果 | 既有载荷、字段时间及 Created,不增加 wire 或持久化字段 |
+| 专用事件、bulk、导入、本地写和 heal 都使用提交后的状态 | 只改一个 handler 会留下旁路;归一化后的 Versioning/Quota 与出站事件可能不同 | 既有解析、保存与复制钩子,bulk 仍是一次原子保存 |
+| 删除导出开关默认关闭 | 直接对旧版节点导出新增删除状态有已复现的 Quota 缓存风险 | 全部参与节点升级后统一启用,不引入能力协商框架 |
+| 物理 Created 恢复与初次同步传播 | 历史无创建时间的真实桶会失去六类配置写入能力 | 现有物理探测;目录 mtime 只是近似值,不推断真实桶世代 |
+| Policy 状态键与 heal 一致;异常按原因去重 | 永久假 mismatch 无法修复;正常空基线噪声或无来源异常静默 | 既有每站点统计与 logger,不增加后台协调系统 |
+
+真实状态优先于创建基线;真实状态按来源时间、等时删除优先、规范内容键排序。历史 `live@Created` 可以初始化空目标,但空基线不能删除真实配置。本地纠正时间在锁内取 `max(now, Created+1ns, fieldTime+1ns)`;带时间 peer 事件保留来源时间。Versioning/Object Lock 的 nil 保持 no-op,Quota `null`/`{}` 保持零配额配置,空 Policy 归一为删除。
+
+Policy 编码器保留在 GET/export/peer 是必要的:既有解析器可以保存的负集合策略必须能读回和复制。PUT/import 继续使用同一编码器,避免额外表示分支。没有为了本轮重写 CORS、Lifecycle、对象复制、IAM 或 #91 已完成的计数,也没有修改 SDK、模块依赖或升级协议。
+
+充分性限定在同一桶世代、有效且可排序的来源状态、全部参与节点升级并开启删除导出的范围内。历史时间污染、零时间兼容事件和桶身份冲突不属于自动恢复承诺。
+
+## 计划与实际对抗审查
+
+- [实施前核验与失败复现](issue-77-current.md)、[最终 v4 计划](issue-77-plan.md)。这些文件保留历史状态,不能当成当前待办。
+- [四轮计划审查与意见处置](issue-77/review/final-review.md):前两轮 NO_GO,后两轮零阻断;保留各版计划、完整最终意见、调用元数据和必要探针。
+- 三轮实现审查均使用实际 Claude Code `claude-opus-5 --effort max`。模型身份取自实际 assistant 消息,effort 取自显式调用参数。源码在独立快照中审查,审查者读代码和执行结果,没有代为运行这些验收。
+
+| 实现审查 | 固定源码 | 结论与处置 |
+| --- | --- | --- |
+| [首轮完整意见](issue-77/implementation-review/opus5-max-review.md) / [调用记录](issue-77/implementation-review/session.json) | `4089113e3` | GO_WITH_NONBLOCKING_NOTES,条件性阻断 F1;物理 Created、Policy 假 mismatch、诊断和证据缺口随后修复 |
+| [第二轮意见](issue-77/implementation-review/round-2/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-2/session.json) | `62cf066ff` | 条件性和无条件阻断均为 0;修正首轮对 Policy 编码器的过宽质疑,进一步补齐无来源诊断和初次同步回归 |
+| [最终定向意见](issue-77/implementation-review/round-3/opus5-max-review.md) / [调用记录](issue-77/implementation-review/round-3/session.json) | `fcbb93e89` | 阻断 0,确认后续 `461e9a721` 的测试改写等价;读取时尚在运行的 cmd/lint 后续均退出 0 |
+
+最初实现审查 SHA `4089113e3` 补签 DCO 后对应 `1ee64a8d8`,两者树完全相同。生产代码最终固定在 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`;验收源码 HEAD 为 `461e9a721047c63e1a95f54ad4b533a6b89def30`,只在两个测试文件有格式和等价条件改写,见 [tree 对照](issue-77/implementation-review/round-3/final-tree-equivalence.json)与 [patch](issue-77/implementation-review/round-3/test-style.diff)。本次归档不改变生产代码或正式回归测试。
+
+审查原文保留当时的判断,不将后续作者修正倒写成评审者已经观察到的结果。首轮误读 README 中既有的空 Policy / 零 Quota 说明,第二轮修正编码器必要性的判断;最终处置以本文和伴生站的逐项表为准。
+
+## 验收证据
+
+| 检查 | 执行对象与结果 |
+| --- | --- |
+| [完整 cmd](issue-77/implementation-review/round-3/final-cmd.log) / [命令及退出码](issue-77/implementation-review/round-3/final-cmd.json) | `fcbb93e89`,CGO=0,全部通过,包测试 492.776 秒 |
+| [最终目标 race](issue-77/implementation-review/round-3/final-target-race.log) / [命令](issue-77/implementation-review/round-3/final-target-race.json) | `fcbb93e89`,真实创建时间、诊断、初次同步、接管、Policy 状态和全部 CORS 命名用例通过 |
+| [测试改写后的 race](issue-77/implementation-review/round-3/final-style-target-race.log) | `461e9a721`,受影响用例通过 |
+| [build](issue-77/implementation-review/round-3/final-build.json)、[vet](issue-77/implementation-review/round-3/final-vet.json)、[lint](issue-77/implementation-review/round-3/final-lint.json) | 最终生产树 build/vet 通过,`461e9a721` lint 零问题;可选 typos 未安装,按 Makefile 跳过 |
+| internal、S3 Select race、生成文件、兼容检查、六平台编译 | `62cf066ff` 的 `ci-*-after.log` 作为较早阶段的补充证据,不冒充最终树在全部平台运行通过;归档中的空生成日志本身不证明退出状态 |
+| [两个真实站点进程](issue-77/implementation-review/round-3/final-runtime.log) / [执行元数据](issue-77/implementation-review/round-3/final-runtime.json) | 干净 `fcbb93e89` 构建:六类历史配置、真实漏发恢复、乱序、四类删除跨重启、两次各 65 秒零 metadata RPC、异常去重、gate=off 与固定旧版的 PUT/DELETE 冒烟均通过 |
+| [伴生站构建](issue-77/implementation-review/round-3/docs-check.log) | 提交为 `9fa6248` 的文章内容通过 Hugo 严格构建和站内链接检查,EN 1207 / ZH 1219 页;后续合并状态文案单独检查 |
+
+[归档清单](issue-77/archive-manifest.json)保存每份材料的原始和归档后 SHA-256,以及全部改动源码的 SHA-256。[二进制身份](issue-77/implementation-review/round-3/binary-identity.json)保留实际版本、Go 构建身份和摘要:最终运行二进制来自干净 `fcbb93e89`,SHA-256 为 `4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049`;旧版来自干净基线 `5c5765816`。第三轮审查的 cmd/lint 条件由上述已完成的 JSON 关闭。
+
+### 反向复现与排除的证据
+
+- [confirmed-before.log](issue-77/implementation-review/round-2/confirmed-before.log):正式测试叠加 `1ee64a8d8` 的 `erasure-server-pool.go` / `site-replication.go`,真实 ObjectLayer 创建时间恢复及旧 Policy 顺序失败;[修复后](issue-77/implementation-review/round-2/confirmed-after.log)和 [race](issue-77/implementation-review/round-2/confirmed-after-race.log)通过。
+- [confirmed-diagnostics-before.log](issue-77/implementation-review/round-2/confirmed-diagnostics-before.log):叠加旧诊断路径,普通空基线误报。第一处断言已经终止测试,不能声称它同时证明后面的 Warning 级别和去重断言在旧码失败。
+- [no-source-before.log](issue-77/implementation-review/round-2/no-source-before.log):直接运行 `62cf066ff`,三种已有无效状态在没有有效来源时缺少诊断;这一次不是旧码 overlay。
+- [initial-sync-before.log](issue-77/implementation-review/round-2/initial-sync-before.log):保留物理恢复修复,仅叠加 `1ee64a8d8` 的 `site-replication.go`,首次同步出站仍传零 Created。该单测的 peer 只确认 RPC,不证明真实对端或本地 peer 分支已经落盘。
+
+`findings-before.log` / `findings-after-1.log` 是早期夹具失败,不能证明产品缺陷;过渡阶段的旧 helper 遗留引用和测试格式失败也不能当成通过记录。这些草稿不进入本归档的验收证据。另一次 Codex 调用因使用限额失败,不计入完成审查次数。
+
+### 重跑真实双站点验收
+
+[独立 Go 驱动](issue-77/runtime/main.go)与原执行内容相同,仅添加 `ignore` 构建标记及说明,避免进入普通包测试。它只启动回环地址上的一次性实验实例,每站四个数据目录,使用文件内声明的专用实验凭据。给它一个全新目录和自行从固定提交构建的两个二进制:
+
+```sh
+go run docs/investigations/issue-77/runtime/main.go \
+ /absolute/new-lab-directory /absolute/silo-fixed /absolute/silo-before
+```
+
+固定构建分别为 `fcbb93e89` 和 `5c5765816`,可在独立干净 worktree 使用 `CGO_ENABLED=0 go build -trimpath`;记录 `--version`、`go version -m` 和 SHA-256。省略旧版二进制参数会跳过混合版本冒烟,不能将其报告为执行过。最终删除收敛快照见 [converged.json](issue-77/runtime/converged.json),数组顺序由驱动的 `states` 函数定义。
+
+原执行环境为 `go1.27.1 darwin/arm64`。因宿主盘可用空间比例触发存储保留阈值,完整 cmd 和进程实验使用独立 16 GiB APFS 测试卷,没有降低生产阈值。临时卷、审查 worktree 和实例进程均已清理。上述是本地可复核的执行证据,不替代 GitHub Actions、Linux 多节点集群、发布制品或生产验证。
+
+## 剩余边界
+
+1. `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 是默认值。全部站点全部节点升级、配置一致并排空旧请求后,统一开启并重启,才具有新增 Tags/SSE/Quota 删除的漏发自愈能力。普通删除事件与原有 Policy 删除导出仍保留。
+2. 到达时间污染、零时间旧事件和桶世代冲突无法凭现有数据重建。物理 Created 只是目录 mtime 的近似值;较早事件仍跳过,由操作者核对权威状态后重新提交纠正。
+3. 日志按每桶/字段/原因去重,仍可能随桶和有值字段数量增长。本轮不增加全站日志调度。
+4. 最终审查保留三项非阻断改进:解码失败诊断中的时间可能为零;初次同步单测没有证明本地 peer 分支落盘;未来并发后台日志可能需要更强的测试隔离。生产路径已复核,本轮不为这些建议新增 helper 或 hook。
+
+归档保留最终报告与调用身份,不发布原始模型推理流、二进制、实验数据卷或无效夹具日志。工作站路径、历史文档链接与非必要 JSON 字段经过整理;哈希清单区分原始产物与归档副本,不能将整理后的报告哈希冒充原文件哈希。
diff --git a/docs/investigations/issue-77/archive-manifest.json b/docs/investigations/issue-77/archive-manifest.json
new file mode 100644
index 000000000..9d17dbee8
--- /dev/null
+++ b/docs/investigations/issue-77/archive-manifest.json
@@ -0,0 +1,437 @@
+{
+ "archived_at": "2026-09-12",
+ "issue": "https://github.com/pgsty/silo/issues/77",
+ "base": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "tested_source_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
+ "reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "source_sha256": {
+ "buildscripts/rebrand-guard/compat-baseline.json": "7ea6485008dd998373144ebded82483fe2c33ae60712abcf2fba973a7506b3e1",
+ "cmd/admin-bucket-handlers.go": "3aa8fc80cb8f2c4725acb8fbec026d47cd71a60ad58d06f905aa013540b30db7",
+ "cmd/bucket-cors-site-replication_test.go": "2cff52d3857e912526aaae40736b077e6b8bd640e098934b8af0ec76354f3747",
+ "cmd/bucket-metadata-replication.go": "07730f6a19b78a9b51dd9d56696812299b3aedce1a779b23178643e2684ba686",
+ "cmd/bucket-metadata-sys.go": "6d0936f537f104bdae1c460554c5ef6f34e6f057d8eb16569a23f012a9498d60",
+ "cmd/bucket-metadata.go": "2b692e42df36373fb98a74792098e1251ae886f5e2759add91a00909b596d04c",
+ "cmd/bucket-policy-handlers.go": "1ac487ef97f63ab19358212f1d582659b4d6fbd17888f5bb21cd5e5028e783e9",
+ "cmd/bucket-versioning-handler.go": "23ec85d45bacf687ada30ea6a06cb5e6d5d5dd32a81865a24aa52a030759e3ce",
+ "cmd/common-main.go": "c06df6c3051ceb264f7ddbdddea33ebe38a59e0c113b5c4c44906c1c2e08a01b",
+ "cmd/erasure-server-pool.go": "1ebb616ac971a22bd736d1b381845c0011cbc95b18dc273e4a946eec8244c80d",
+ "cmd/site-replication-metadata-gate_test.go": "1f170b84d2d25d67a4386451ce26597a752f96c9215155f3156ac1fd140ead86",
+ "cmd/site-replication-metadata-heal_test.go": "37191261f643dae88fbe5be44da8f08b2c52455245d5c61a2df564ecb7c8bffd",
+ "cmd/site-replication-metadata.go": "5756a5495723a2d9456b1c3ed7875cec789298eefb8230ea57126f9de83c4287",
+ "cmd/site-replication-metadata_test.go": "2898c88d48d9693d1c974ee7fd6846b86fe354f378caa630eac2ad0d9b9edfe8",
+ "cmd/site-replication.go": "5015550411bd4d82119cdb96b7f663fd082e4906baee259b30aedbaa616354c8",
+ "cmd/site-replication_test.go": "d85dd2aa991a6cce1fccfa17205e4a478f4ced00e0585fbe2aee2dbf97884b8a",
+ "docs/site-replication/README.md": "abe39529bf5a864dc7c945fd368b647f3fa2121fb324cea1cf63373eff9683a7"
+ },
+ "original_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
+ "equivalent_signed_commit": "1ee64a8d895f0876580d5e27d0ce6facf41c347e",
+ "reviewed_tree": "0d18c940c6dec380dffc977cb5720b4e2384e46a",
+ "environment": "go version go1.27.1 darwin/arm64",
+ "scope": "Historical analysis and plan, final review reports and invocation metadata, selected executed test logs, source/binary identity, and rerunnable loopback lab. Excludes model reasoning streams, binaries, temporary volumes and invalid fixture logs. Paths, historical document links and trailing console whitespace were normalized; JSON billing fields and build-info dependency lists were omitted. Original and archived hashes distinguish these editorial transformations.",
+ "final_verdict": "GO_WITH_NONBLOCKING_NOTES",
+ "blocking_findings": 0,
+ "records": [
+ {
+ "file": "issue-77-current.md",
+ "source_record": "issue-77-current.md",
+ "original_sha256": "702f9fe93703c8d4b79a3e2c4709e7f49614dc20211ed5221f3adbd5e3809e2d",
+ "archived_sha256": "b22b50e584de73cfe2f101d35bb912d314feb7f0e0ce48018e1b4a48dc4cf5c5"
+ },
+ {
+ "file": "issue-77-plan.md",
+ "source_record": "issue-77-plan.md",
+ "original_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
+ "archived_sha256": "36ae4f99bbc22827cc9a1a437b413713e61f442a568347eeef836b4245c67081"
+ },
+ {
+ "file": "issue-77/current-tests.log",
+ "source_record": "issue-77/current-tests.log",
+ "original_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8",
+ "archived_sha256": "d6ed0ef373a37f8254d387a0757a431c2785b6b9a30c4e06d0034688ca3a41e8"
+ },
+ {
+ "file": "issue-77/issue77_heal_review_test.go.txt",
+ "source_record": "issue-77/issue77_heal_review_test.go.txt",
+ "original_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810",
+ "archived_sha256": "f1b164afff783282253660b8fa5c131b0de946a078d370cdedd95c338fa73810"
+ },
+ {
+ "file": "issue-77/issue77_review_test.go.txt",
+ "source_record": "issue-77/issue77_review_test.go.txt",
+ "original_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa",
+ "archived_sha256": "77e557bd098fad1345085b6bca27ff8a733984092c68c6b26d0dc74e5fca8caa"
+ },
+ {
+ "file": "issue-77/review/adoption-baseline-probe.go.txt",
+ "source_record": "issue-77/review/adoption-baseline-probe.go.txt",
+ "original_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574",
+ "archived_sha256": "4cf7aae6763f1ae736af087244dae01e32af1c546923fa782891759967981574"
+ },
+ {
+ "file": "issue-77/review/adoption-baseline-probe.log",
+ "source_record": "issue-77/review/adoption-baseline-probe.log",
+ "original_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7",
+ "archived_sha256": "d5ad1bfe7d4faafc7bfc6f55800dc47a7d60b7ec7124cc31ec9ed11b0154bde7"
+ },
+ {
+ "file": "issue-77/review/adoption-baseline-probe.metadata.json",
+ "source_record": "issue-77/review/adoption-baseline-probe.metadata.json",
+ "original_sha256": "618e81cb5852b08fbc3029d812647b9d2dbe3560349ee2e44b860356e12cffa9",
+ "archived_sha256": "a462ad6b10cb7e2737330eed5d5e0a5f98ac0d664aa6fcf40a1f5f2f073709f6"
+ },
+ {
+ "file": "issue-77/review/author-followup-findings.md",
+ "source_record": "issue-77/review/author-followup-findings.md",
+ "original_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4",
+ "archived_sha256": "b6d60d813702db300313430325046aa89e45071d3cdc20083d82f061b75ab2c4"
+ },
+ {
+ "file": "issue-77/review/decisions-v2.md",
+ "source_record": "issue-77/review/decisions-v2.md",
+ "original_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1",
+ "archived_sha256": "52c85f0b45073471e96abe86aa5a182858ff00a5dfe00f290f570f2aad86c5a1"
+ },
+ {
+ "file": "issue-77/review/decisions-v3.md",
+ "source_record": "issue-77/review/decisions-v3.md",
+ "original_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85",
+ "archived_sha256": "7325864c0a3e93689989612da0427b88f890ef8baf197f609c570636b16a2b85"
+ },
+ {
+ "file": "issue-77/review/decisions-v4.md",
+ "source_record": "issue-77/review/decisions-v4.md",
+ "original_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16",
+ "archived_sha256": "982b2e802aed883f2b5e211f0e1677388571ccde8e32af878a35b4192c1f4d16"
+ },
+ {
+ "file": "issue-77/review/final-review.md",
+ "source_record": "issue-77/review/final-review.md",
+ "original_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea",
+ "archived_sha256": "2410f7b92e2c74b235d54bc155b822675c6b5f9ab0b7226fccca42dc38b0a7ea"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v1.md",
+ "source_record": "issue-77/review/opus5-max-v1.md",
+ "original_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
+ "archived_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v1.metadata.json",
+ "source_record": "issue-77/review/opus5-max-v1.metadata.json",
+ "original_sha256": "959a9eac3a8449ef27027fbc65aca55a32b1f46063175d7187922296ecd0a957",
+ "archived_sha256": "d5e3be5c5377adeb88fdcb444ad5b6ee338af91d095027e1c9502298eda51d6d"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v2.md",
+ "source_record": "issue-77/review/opus5-max-v2.md",
+ "original_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
+ "archived_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v2.metadata.json",
+ "source_record": "issue-77/review/opus5-max-v2.metadata.json",
+ "original_sha256": "ff63843a993830ea48e1a44987390b94613dea99a796453bc91bebca2f4e119f",
+ "archived_sha256": "3cd83d43f461fb1e13bbc4f4163769fa6daf4d151d8442d8615a3e813da38853"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v3.md",
+ "source_record": "issue-77/review/opus5-max-v3.md",
+ "original_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
+ "archived_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v3.metadata.json",
+ "source_record": "issue-77/review/opus5-max-v3.metadata.json",
+ "original_sha256": "ad488156f42e33e47ed5d0a267ee69ee82efff69881d0e6400e88cf88a09ef60",
+ "archived_sha256": "1c84da615bccc2f1d7a2aa917ef7af751b0943cc7e413b39f59164a65acc2c98"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v4.md",
+ "source_record": "issue-77/review/opus5-max-v4.md",
+ "original_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
+ "archived_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682"
+ },
+ {
+ "file": "issue-77/review/opus5-max-v4.metadata.json",
+ "source_record": "issue-77/review/opus5-max-v4.metadata.json",
+ "original_sha256": "95bae4427322fc42974a9d94ed5dd6d272a46b6eb669f95669a2fe1ef6ee4d42",
+ "archived_sha256": "60d68b2db11557ee09d60b67748844a56d7b1f323803163ef03d420ad1cc8c42"
+ },
+ {
+ "file": "issue-77/review/plan-v1.md",
+ "source_record": "issue-77/review/plan-v1.md",
+ "original_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
+ "archived_sha256": "97d88bbd010cbd19744c2e2fee0fed8d3713ca553b5d0f06161299772ea8104b"
+ },
+ {
+ "file": "issue-77/review/plan-v2.md",
+ "source_record": "issue-77/review/plan-v2.md",
+ "original_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
+ "archived_sha256": "b193ee6e47b24576f3f4d06608f1bcd0f7f2b0c27ef782ea4f74ee3706f7aa4d"
+ },
+ {
+ "file": "issue-77/review/plan-v3.md",
+ "source_record": "issue-77/review/plan-v3.md",
+ "original_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
+ "archived_sha256": "fb3b37ab9dd2f4ab12b8050293603ed589645a730f1e23c81e2f7162ca46f7d2"
+ },
+ {
+ "file": "issue-77/review/plan-v4.md",
+ "source_record": "issue-77/review/plan-v4.md",
+ "original_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
+ "archived_sha256": "8624e9b86d3cb6daa497d43ac8cb50318ebabdd7eb4bb7c605e0720522a73bd9"
+ },
+ {
+ "file": "issue-77/review/policy-encoding-probe.go.txt",
+ "source_record": "issue-77/review/policy-encoding-probe.go.txt",
+ "original_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537",
+ "archived_sha256": "4e07cba70bdc329a0003ca0b6711886a6de22323293e8de3625cdc838ccaf537"
+ },
+ {
+ "file": "issue-77/review/wire-state-probe.go.txt",
+ "source_record": "issue-77/review/wire-state-probe.go.txt",
+ "original_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c",
+ "archived_sha256": "cafc6e2d482cc8e178ae3e17651410e3070049cd60009f0edd30fbac29e1309c"
+ },
+ {
+ "file": "issue-77/review/wire-state-probe.log",
+ "source_record": "issue-77/review/wire-state-probe.log",
+ "original_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1",
+ "archived_sha256": "5077cd264a50cc05fe9dd628a6e98a3b0aaf94e04b65b712563191e11e4ca9f1"
+ },
+ {
+ "file": "issue-77/implementation-review/opus5-max-review.md",
+ "source_record": "implementation-review/opus5-max-review.md",
+ "original_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
+ "archived_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118"
+ },
+ {
+ "file": "issue-77/implementation-review/session.json",
+ "source_record": "implementation-review/session.json",
+ "original_sha256": "c347259a0a501242eb787319d43fe16c117d021ea4e418ce74b09b6c9c33ad08",
+ "archived_sha256": "d26e9c3a7f3d0ba3d34de54e95c64e5939b8d5da1d190bb94d80423b6ca79205"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/opus5-max-review.md",
+ "source_record": "implementation-review/round-2/opus5-max-review.md",
+ "original_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
+ "archived_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/session.json",
+ "source_record": "implementation-review/round-2/session.json",
+ "original_sha256": "64113634f4e8d0525ed17dd74d092c74419d172aa94b501187daae8ebc27e279",
+ "archived_sha256": "4f4dfe9c50b43b64f15fad830c39c116a296c302655d2c718475249521a68f19"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/opus5-max-review.md",
+ "source_record": "implementation-review/round-3/opus5-max-review.md",
+ "original_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
+ "archived_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/session.json",
+ "source_record": "implementation-review/round-3/session.json",
+ "original_sha256": "28944efc7a7b2c5451beedf0a05202ffc23b29fad2d9bd04cc89fad3d72184b9",
+ "archived_sha256": "bf31d2dbd8009f0a966c0e820dfdbd5a2820e8b36e71e615f783932fd4ecc9de"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/confirmed-before.log",
+ "source_record": "implementation-review/round-2/confirmed-before.log",
+ "original_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10",
+ "archived_sha256": "af2df1c296c3377edb6aae45caa9fafd5e620ffb5a09438c15ca50e3d2cb1c10"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/confirmed-after.log",
+ "source_record": "implementation-review/round-2/confirmed-after.log",
+ "original_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc",
+ "archived_sha256": "0c8be7325817049afe2bd2d3ed668e61cbde2887a443b988b8defbf12304b5bc"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/confirmed-after-race.log",
+ "source_record": "implementation-review/round-2/confirmed-after-race.log",
+ "original_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55",
+ "archived_sha256": "da2725133b6c1b41a50a92700ff6adaf6dae7e9ac9d6b5812844715fd2b70e55"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/confirmed-diagnostics-before.log",
+ "source_record": "implementation-review/round-2/confirmed-diagnostics-before.log",
+ "original_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8",
+ "archived_sha256": "3d2c1325f1612c9ce79172b148d1ae02cf5443087270e9caeb46046fec1ea2c8"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/no-source-before.log",
+ "source_record": "implementation-review/round-2/no-source-before.log",
+ "original_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185",
+ "archived_sha256": "e6bac0d117eb195b0ff67ef55eaff302c5f7ee36abf236fdcb69486330b31185"
+ },
+ {
+ "file": "issue-77/implementation-review/round-2/initial-sync-before.log",
+ "source_record": "implementation-review/round-2/initial-sync-before.log",
+ "original_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427",
+ "archived_sha256": "f200f9b961baf20e48f22b50cebd82525dd4647ea42203179d140b5474a28427"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/docs-check.json",
+ "source_record": "implementation-review/round-3/docs-check.json",
+ "original_sha256": "76747f4d197d250e6bd3aaa1eba5a7013e56b655f10d143b984faed9d22b503b",
+ "archived_sha256": "322f206ca48816c415ba29f9c67935c0eba038823eddfac8fb7e298ff50bda4a"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/docs-check.log",
+ "source_record": "implementation-review/round-3/docs-check.log",
+ "original_sha256": "af8752f3d8a072954dc6de2b8c72a4054cffbe3583aeae225a8d6623fc95e9fa",
+ "archived_sha256": "a1d67bf392e45da04af9d819a5d76cec98006d1fc78c1a4cce486eb305bdc3ac"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-build.json",
+ "source_record": "implementation-review/round-3/final-build.json",
+ "original_sha256": "924df2a034af81acfe8961489664fa213791096db637e962ced6b78ba7ea8388",
+ "archived_sha256": "c5de13eceee16b23886290fbe351a5269735a549723ed400ddb3e5f0bf7c88e5"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-build.log",
+ "source_record": "implementation-review/round-3/final-build.log",
+ "original_sha256": "d5d78e4d1a5b0dc5bc6042ae94ad64aaf2c500ff3e9091ea1dc9dbdfff04b263",
+ "archived_sha256": "b212590ec72fc2489ea00c36458b3ce900b5b23543c7b141d211ac7b4b06f07b"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-cmd.json",
+ "source_record": "implementation-review/round-3/final-cmd.json",
+ "original_sha256": "431f663b748e81b9c311fa1b931483fe0b1ed09dec796b0894bb5d4a37f5f6b9",
+ "archived_sha256": "39a02d590c9615721fbded8e95cc3b04ebacb32ba5c943d8e9a6f55d912c80c7"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-cmd.log",
+ "source_record": "implementation-review/round-3/final-cmd.log",
+ "original_sha256": "681576adac6d627140a791a22085fc520b61f4a6e04172bc7fb5d0f3967e9c40",
+ "archived_sha256": "67723bedd3472d22a2e3f043e48fdab9bc5e8a212d932c513d44d61cc13eb09d"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-lint.json",
+ "source_record": "implementation-review/round-3/final-lint.json",
+ "original_sha256": "1dc2b38de9c4f37983e45e11ad5f416810f0c96e2cfeeff64b25846544918751",
+ "archived_sha256": "35229aa6e418c54072abf141b4296be491a43cdba87c2f56f853076d78d66559"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-lint.log",
+ "source_record": "implementation-review/round-3/final-lint.log",
+ "original_sha256": "40eb5ec64ae64b73489fc7b6e145aabfea6554e0c73fa22be7b4a8201bb63d18",
+ "archived_sha256": "d4b9455691ff7bcce76a0d886f603eda2179e7fa4e408b8e3bb987d693987c3a"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-runtime.json",
+ "source_record": "implementation-review/round-3/final-runtime.json",
+ "original_sha256": "6f76d83dbf9749f9dde0d6386f0b8a6b52cda6677c17ef9d739f05ea9379c52f",
+ "archived_sha256": "b57c593e1023a0226e7950c142d5bd9a231832a9e7aefc7d9d584075b70b9aca"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-runtime.log",
+ "source_record": "implementation-review/round-3/final-runtime.log",
+ "original_sha256": "4c0137bbe554426026e1d4c8baf48e28368e2450e886c506e0055e0591cd5e5e",
+ "archived_sha256": "2b6228f3c6003e896193be17a7c94970992c1fbcbaba013b1984348bf772bc1f"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-style-target-race.json",
+ "source_record": "implementation-review/round-3/final-style-target-race.json",
+ "original_sha256": "09b77aeb0d720de59ea5b1c31f017e8bf32e89c8dc0d4ecc3f35704f21bc0863",
+ "archived_sha256": "075c8d7393164e5b43364d25c163f5da8675f5b69c67e7523a579943a710aaca"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-style-target-race.log",
+ "source_record": "implementation-review/round-3/final-style-target-race.log",
+ "original_sha256": "3089ad9a1c7283a23853a137311728598a7e1ae3bdbbf16e5f000e1149a7e12f",
+ "archived_sha256": "a2609e9a889bcc68828e58c15967bb04820a92462c3f94de86ea584e33a6660a"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-target-race.json",
+ "source_record": "implementation-review/round-3/final-target-race.json",
+ "original_sha256": "68dfcad5fc63d5981065f86fb00482cc466c46f275c4c340095c26ad719f079c",
+ "archived_sha256": "5fb25fa150e66478b2d170faeed3374d71325311f7e625996fe1653cfc957b66"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-target-race.log",
+ "source_record": "implementation-review/round-3/final-target-race.log",
+ "original_sha256": "b437b206ebd12fff304f9cdb59b39f25fd0ecf54e9f510aca98c444b0b516c89",
+ "archived_sha256": "a5b6b6337ebbcf0eca02c2ca15bb1fa562d39ab6fe29f41cd304dd2979976f59"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-tree-equivalence.json",
+ "source_record": "implementation-review/round-3/final-tree-equivalence.json",
+ "original_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f",
+ "archived_sha256": "f7d1563d2f675ae282b4bec0bcedcc9809b0a0c985332d862c61ff4b8c75a59f"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-vet.json",
+ "source_record": "implementation-review/round-3/final-vet.json",
+ "original_sha256": "934ae2812aef8b8635517f838a07cc81dc4780603d621770f9a150bf00d43561",
+ "archived_sha256": "be1b653811da4f6503f329f7e7c8b321b3426107548f0079c8560b177bc0c37f"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/final-vet.log",
+ "source_record": "implementation-review/round-3/final-vet.log",
+ "original_sha256": "f8166088e218c7c8afa25988c02cee77ec58b552f531121a07959b31bd97ebbb",
+ "archived_sha256": "012d45737551ed5ffdccc3b3535ce0be152a9e9f869f50779d5da04e965a9936"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/test-style.diff",
+ "source_record": "implementation-review/round-3/test-style.diff",
+ "original_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025",
+ "archived_sha256": "69a7f7efbb17d8dcdd2a0d7f91098569a48d7f1aeeecb6d2aefe04b6047a9025"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-crosscompile-after.log",
+ "source_record": "implementation-review/ci-crosscompile-after.log",
+ "original_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f",
+ "archived_sha256": "68ef16136fabb16babd8fa26d2e3fcf6ffddac30002f874f7b9dfdd61961e82f"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-gen-after.log",
+ "source_record": "implementation-review/ci-gen-after.log",
+ "original_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ "archived_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-internal-after.log",
+ "source_record": "implementation-review/ci-internal-after.log",
+ "original_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4",
+ "archived_sha256": "775e5b2b35fda828ffd69f47902e065f58aa2e2ecd915ace9e50f4085aa2d0c4"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-lint-after.log",
+ "source_record": "implementation-review/ci-lint-after.log",
+ "original_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82",
+ "archived_sha256": "cd29668fa61e94079dda874e10920623279083c491798fa9cbdb903ebeee4d82"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-s3select-after.log",
+ "source_record": "implementation-review/ci-s3select-after.log",
+ "original_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9",
+ "archived_sha256": "30fc4888ba394ef0daa6c76fa497c28086b7307ec30d195fe4f2b725badc59c9"
+ },
+ {
+ "file": "issue-77/implementation-review/ci-verify-after.log",
+ "source_record": "implementation-review/ci-verify-after.log",
+ "original_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3",
+ "archived_sha256": "2b6860f20bdfd453bcc299f8b57a117cd698bab77d6a474913e31794c3937bd3"
+ },
+ {
+ "file": "issue-77/implementation-review/round-3/binary-identity.json",
+ "source_record": "implementation-review/round-3/binary-identity.json",
+ "original_sha256": "ffdb768a1931388f174eaca0980cc840015a8e0995b5509ef68718d10044f5e5",
+ "archived_sha256": "d4ea7abc1f419e834746820b5b2d2f1b3264e42114161e7c6803f4e952fcf17d"
+ },
+ {
+ "file": "issue-77/runtime/converged.json",
+ "source_record": "runtime-reviewed/converged.json",
+ "original_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f",
+ "archived_sha256": "d6d97bc01a12fd624af37432e9558356255b5cd9d4333eaa616e39998c2ce94f"
+ },
+ {
+ "file": "issue-77/runtime/main.go",
+ "source_record": "twosite/main.go",
+ "original_sha256": "ec63a5e2553907a826727af416145057215adabfb9fa2839861f0c05b7cc2a8f",
+ "archived_sha256": "023459a3873753d5a373c2ba6c078d690c9a2a18c31efa98ecd5f4976ecaba4a"
+ }
+ ]
+}
diff --git a/docs/investigations/issue-77/current-tests.log b/docs/investigations/issue-77/current-tests.log
new file mode 100644
index 000000000..51cfe5819
--- /dev/null
+++ b/docs/investigations/issue-77/current-tests.log
@@ -0,0 +1,129 @@
+=== RUN TestQueuedMetadataUpdateAfterDelete
+=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=false
+=== RUN TestQueuedMetadataUpdateAfterDelete/expiry=true
+--- PASS: TestQueuedMetadataUpdateAfterDelete (0.43s)
+ --- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=false (0.22s)
+ --- PASS: TestQueuedMetadataUpdateAfterDelete/expiry=true (0.21s)
+=== RUN TestIssue77CurrentHealInputs
+=== RUN TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache
+ issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
+=== RUN TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier
+ issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.50105 +0800 CST, latest is 2026-09-11 23:00:55.50105 +0000 UTC
+=== RUN TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection
+ issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 5/32 heal rounds
+=== RUN TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time
+ issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.50105 +0000 UTC
+=== RUN TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache
+ issue77_heal_review_test.go:58: QUOTA_CACHE: disk has no quota, cache still enforces 1024
+=== RUN TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier
+ issue77_heal_review_test.go:79: BARRIER_NOT_HEALED: same payload remains at 2026-09-12 06:59:55.71847 +0800 CST, latest is 2026-09-11 23:00:55.71847 +0000 UTC
+=== RUN TestIssue77CurrentHealInputs/Erasure/creation-default-selection
+ issue77_heal_review_test.go:105: DEFAULT_SELECTED: creation-default erased valid policy in 2/32 heal rounds
+=== RUN TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time
+ issue77_heal_review_test.go:144: TAG_WIRE_TIME: sent 0001-01-01 00:00:00 +0000 UTC, want 2026-09-11 23:00:55.71847 +0000 UTC
+--- FAIL: TestIssue77CurrentHealInputs (1.21s)
+ --- FAIL: TestIssue77CurrentHealInputs/ErasureSD/quota-tombstone-cache (0.01s)
+ --- FAIL: TestIssue77CurrentHealInputs/ErasureSD/same-payload-time-barrier (0.00s)
+ --- FAIL: TestIssue77CurrentHealInputs/ErasureSD/creation-default-selection (0.08s)
+ --- FAIL: TestIssue77CurrentHealInputs/ErasureSD/tag-remote-source-time (0.00s)
+ --- FAIL: TestIssue77CurrentHealInputs/Erasure/quota-tombstone-cache (0.04s)
+ --- FAIL: TestIssue77CurrentHealInputs/Erasure/same-payload-time-barrier (0.02s)
+ --- FAIL: TestIssue77CurrentHealInputs/Erasure/creation-default-selection (0.83s)
+ --- FAIL: TestIssue77CurrentHealInputs/Erasure/tag-remote-source-time (0.02s)
+=== RUN TestIssue77CurrentPeerCheckBeforeLock
+=== RUN TestIssue77CurrentPeerCheckBeforeLock/ErasureSD
+ issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "keyolder"
+=== RUN TestIssue77CurrentPeerCheckBeforeLock/Erasure
+ issue77_heal_review_test.go:209: CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with "keyolder"
+--- FAIL: TestIssue77CurrentPeerCheckBeforeLock (0.30s)
+ --- FAIL: TestIssue77CurrentPeerCheckBeforeLock/ErasureSD (0.01s)
+ --- FAIL: TestIssue77CurrentPeerCheckBeforeLock/Erasure (0.07s)
+=== RUN TestIssue77CurrentSourceTimeAndDeletion
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.017159 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.029759 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.03401 +0800 CST
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.04395 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.048028 +0800 CST
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.057746 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.061453 +0800 CST
+ issue77_review_test.go:112: older source PUT did not resurrect config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.069105 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:87: nil payload is correctly a no-op
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.077601 +0800 CST, want source 2026-09-11 22:59:57.012479 +0000 UTC
+ issue77_review_test.go:87: nil payload is correctly a no-op
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.247526 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.369557 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.394147 +0800 CST
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.466111 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.489771 +0800 CST
+ issue77_review_test.go:110: STALE_RESURRECTION: older source PUT resurrected a locally deleted config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.562629 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:91: NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE
+ issue77_review_test.go:105: TOMBSTONE_EXPORT: got 0001-01-01 00:00:00 +0000 UTC, want 2026-09-12 07:49:57.590096 +0800 CST
+ issue77_review_test.go:112: older source PUT did not resurrect config
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.640941 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:87: nil payload is correctly a no-op
+=== RUN TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock
+ issue77_review_test.go:79: SOURCE_TIME: persisted 2026-09-12 07:49:57.690024 +0800 CST, want source 2026-09-11 22:59:57.221624 +0000 UTC
+ issue77_review_test.go:87: nil payload is correctly a no-op
+--- FAIL: TestIssue77CurrentSourceTimeAndDeletion (0.78s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/policy (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/tags (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/sse (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/quota (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/versioning (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/ErasureSD/objectlock (0.01s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/policy (0.12s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/tags (0.10s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/sse (0.10s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/quota (0.08s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/versioning (0.05s)
+ --- FAIL: TestIssue77CurrentSourceTimeAndDeletion/Erasure/objectlock (0.05s)
+=== RUN TestIssue77CurrentBulkApplyOrdering
+=== RUN TestIssue77CurrentBulkApplyOrdering/ErasureSD
+ issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="keyold" time=2026-09-12 08:48:57.792608 +0800 CST
+=== RUN TestIssue77CurrentBulkApplyOrdering/Erasure
+ issue77_review_test.go:143: BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value="keyold" time=2026-09-12 08:48:57.880379 +0800 CST
+--- FAIL: TestIssue77CurrentBulkApplyOrdering (0.27s)
+ --- FAIL: TestIssue77CurrentBulkApplyOrdering/ErasureSD (0.01s)
+ --- FAIL: TestIssue77CurrentBulkApplyOrdering/Erasure (0.05s)
+=== RUN TestPeerBucketAdoptionPreservesLockAndVersioningConfigs
+--- PASS: TestPeerBucketAdoptionPreservesLockAndVersioningConfigs (0.30s)
+=== RUN TestPeerBucketAdoptionBootstrapsMissingConfigs
+--- PASS: TestPeerBucketAdoptionBootstrapsMissingConfigs (0.24s)
+=== RUN TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock
+--- PASS: TestPeerBucketAdoptionNormalizesVersioningWhenEnablingLock (0.28s)
+=== RUN TestPeerBucketAdoptionEnablesSuspendedVersioning
+--- PASS: TestPeerBucketAdoptionEnablesSuspendedVersioning (0.27s)
+=== RUN TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads
+--- PASS: TestPeerBucketObjectLockMetadataCurrentAndLegacyPayloads (0.30s)
+=== RUN TestPeerBucketObjectLockMetadataWithoutLockEnabled
+--- PASS: TestPeerBucketObjectLockMetadataWithoutLockEnabled (0.24s)
+=== RUN TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig
+--- PASS: TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig (0.52s)
+FAIL
+FAIL github.com/minio/minio/cmd 7.167s
+FAIL
diff --git a/docs/investigations/issue-77/implementation-review/ci-crosscompile-after.log b/docs/investigations/issue-77/implementation-review/ci-crosscompile-after.log
new file mode 100644
index 000000000..c4d4f5319
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/ci-crosscompile-after.log
@@ -0,0 +1,7 @@
+Testing builds for OS/Arch: linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64 windows/arm64
+--> linux/amd64:github.com/minio/minio
+--> linux/arm64:github.com/minio/minio
+--> darwin/amd64:github.com/minio/minio
+--> darwin/arm64:github.com/minio/minio
+--> windows/amd64:github.com/minio/minio
+--> windows/arm64:github.com/minio/minio
diff --git a/docs/investigations/issue-77/implementation-review/ci-gen-after.log b/docs/investigations/issue-77/implementation-review/ci-gen-after.log
new file mode 100644
index 000000000..e69de29bb
diff --git a/docs/investigations/issue-77/implementation-review/ci-internal-after.log b/docs/investigations/issue-77/implementation-review/ci-internal-after.log
new file mode 100644
index 000000000..e4d93c556
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/ci-internal-after.log
@@ -0,0 +1,78 @@
+ok github.com/minio/minio/internal/amztime 5.149s
+ok github.com/minio/minio/internal/arn 13.830s
+ok github.com/minio/minio/internal/auth 5.472s
+ok github.com/minio/minio/internal/bpool 13.244s
+ok github.com/minio/minio/internal/bucket/bandwidth 12.671s
+ok github.com/minio/minio/internal/bucket/cors 2.705s
+ok github.com/minio/minio/internal/bucket/encryption 12.228s
+ok github.com/minio/minio/internal/bucket/lifecycle 3.223s
+ok github.com/minio/minio/internal/bucket/object/lock 11.599s
+ok github.com/minio/minio/internal/bucket/replication 12.817s
+ok github.com/minio/minio/internal/bucket/versioning 13.157s
+ok github.com/minio/minio/internal/cachevalue 18.526s
+? github.com/minio/minio/internal/color [no test files]
+ok github.com/minio/minio/internal/config 14.365s
+? github.com/minio/minio/internal/config/api [no test files]
+? github.com/minio/minio/internal/config/batch [no test files]
+? github.com/minio/minio/internal/config/browser [no test files]
+? github.com/minio/minio/internal/config/callhome [no test files]
+ok github.com/minio/minio/internal/config/compress 15.754s
+ok github.com/minio/minio/internal/config/dns 17.351s
+? github.com/minio/minio/internal/config/drive [no test files]
+ok github.com/minio/minio/internal/config/etcd 18.460s
+? github.com/minio/minio/internal/config/heal [no test files]
+ok github.com/minio/minio/internal/config/identity/ldap 19.132s
+ok github.com/minio/minio/internal/config/identity/openid 19.829s
+? github.com/minio/minio/internal/config/identity/openid/provider [no test files]
+? github.com/minio/minio/internal/config/identity/plugin [no test files]
+? github.com/minio/minio/internal/config/identity/tls [no test files]
+ok github.com/minio/minio/internal/config/ilm 19.256s
+? github.com/minio/minio/internal/config/lambda [no test files]
+ok github.com/minio/minio/internal/config/lambda/event 18.225s
+? github.com/minio/minio/internal/config/lambda/target [no test files]
+ok github.com/minio/minio/internal/config/notify 17.465s
+? github.com/minio/minio/internal/config/policy/opa [no test files]
+? github.com/minio/minio/internal/config/policy/plugin [no test files]
+? github.com/minio/minio/internal/config/scanner [no test files]
+ok github.com/minio/minio/internal/config/storageclass 17.289s
+ok github.com/minio/minio/internal/config/subnet 17.168s
+ok github.com/minio/minio/internal/crypto 18.081s
+ok github.com/minio/minio/internal/deadlineconn 21.703s
+ok github.com/minio/minio/internal/disk 17.514s
+ok github.com/minio/minio/internal/dsync 154.956s
+ok github.com/minio/minio/internal/etag 17.755s
+ok github.com/minio/minio/internal/event 17.598s
+ok github.com/minio/minio/internal/event/target 16.108s
+ok github.com/minio/minio/internal/grid 22.027s
+ok github.com/minio/minio/internal/handlers 14.030s
+ok github.com/minio/minio/internal/hash 14.016s
+? github.com/minio/minio/internal/hash/sha256 [no test files]
+ok github.com/minio/minio/internal/http 13.661s
+? github.com/minio/minio/internal/init [no test files]
+ok github.com/minio/minio/internal/ioutil 14.637s
+ok github.com/minio/minio/internal/jwt 13.120s
+ok github.com/minio/minio/internal/kms 12.536s
+ok github.com/minio/minio/internal/lock 13.029s
+ok github.com/minio/minio/internal/logger 12.231s
+? github.com/minio/minio/internal/logger/message/audit [no test files]
+? github.com/minio/minio/internal/logger/target/console [no test files]
+? github.com/minio/minio/internal/logger/target/http [no test files]
+? github.com/minio/minio/internal/logger/target/kafka [no test files]
+? github.com/minio/minio/internal/logger/target/loggertypes [no test files]
+? github.com/minio/minio/internal/logger/target/testlogger [no test files]
+ok github.com/minio/minio/internal/lsync 22.092s
+? github.com/minio/minio/internal/mcontext [no test files]
+? github.com/minio/minio/internal/mountinfo [no test files]
+? github.com/minio/minio/internal/net [no test files]
+? github.com/minio/minio/internal/once [no test files]
+ok github.com/minio/minio/internal/pubsub 11.507s
+ok github.com/minio/minio/internal/rest 10.318s
+ok github.com/minio/minio/internal/ringbuffer 10.927s
+ok github.com/minio/minio/internal/s3select 8.665s
+ok github.com/minio/minio/internal/s3select/csv 8.862s
+ok github.com/minio/minio/internal/s3select/json 8.547s
+ok github.com/minio/minio/internal/s3select/jstream 8.936s
+? github.com/minio/minio/internal/s3select/parquet [no test files]
+? github.com/minio/minio/internal/s3select/simdj [no test files]
+ok github.com/minio/minio/internal/s3select/sql 8.637s
+ok github.com/minio/minio/internal/store 9.594s
diff --git a/docs/investigations/issue-77/implementation-review/ci-lint-after.log b/docs/investigations/issue-77/implementation-review/ci-lint-after.log
new file mode 100644
index 000000000..35e4a25b7
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/ci-lint-after.log
@@ -0,0 +1,13 @@
+Installing golangci-lint v2.13.1
+golangci/golangci-lint info checking GitHub for tag 'v2.13.1'
+golangci/golangci-lint info found version: 2.13.1 for v2.13.1/darwin/arm64
+golangci/golangci-lint info installed .bin/golangci/v2.13.1/golangci-lint
+Running lint check
+0 issues.
+typos binary is not found.. skipping..
+LINT_EXIT=0
+compatibility manifest: imports=119 env=438 metrics=19 headers=87 routes=223 roots=1 grid=3 storage=15 policy=58 brand=181 sha256=db0d00c26360412a5388be1eb87c2dd763633ae243c558015f7b3ae86c80baaf
+Silo rebrand compatibility baseline is unchanged
+Silo delivery and runtime rebrand checks passed
+docker entrypoint argv compatibility tests passed
+REBRAND_EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/ci-s3select-after.log b/docs/investigations/issue-77/implementation-review/ci-s3select-after.log
new file mode 100644
index 000000000..1c8ac99fd
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/ci-s3select-after.log
@@ -0,0 +1,7 @@
+ok github.com/minio/minio/internal/s3select 2.150s
+ok github.com/minio/minio/internal/s3select/csv 3.612s
+ok github.com/minio/minio/internal/s3select/json 2.105s
+ok github.com/minio/minio/internal/s3select/jstream 2.937s
+? github.com/minio/minio/internal/s3select/parquet [no test files]
+? github.com/minio/minio/internal/s3select/simdj [no test files]
+ok github.com/minio/minio/internal/s3select/sql 2.620s
diff --git a/docs/investigations/issue-77/implementation-review/ci-verify-after.log b/docs/investigations/issue-77/implementation-review/ci-verify-after.log
new file mode 100644
index 000000000..3df87efa1
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/ci-verify-after.log
@@ -0,0 +1,2 @@
+Silo delivery and runtime rebrand checks passed
+docker entrypoint argv compatibility tests passed
diff --git a/docs/investigations/issue-77/implementation-review/opus5-max-review.md b/docs/investigations/issue-77/implementation-review/opus5-max-review.md
new file mode 100644
index 000000000..dc0f48f49
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/opus5-max-review.md
@@ -0,0 +1,292 @@
+# SILO #77 实现深度对抗性代码审查报告
+
+审查对象 `4089113e3`(基线 `5c5765816`)。只读 worktree,全部结论来自直接阅读 `cmd/` 生产代码、`silo-pkg`/`madmin-go` 依赖源码与调用链,并与证据目录交叉核对。测试 PASS 仅当作"已覆盖的观察",不作正确性证明。
+
+> 说明:本会话的 Write/ExitPlanMode 工具不可用,报告直接输出于此,未落盘。
+
+---
+
+## 0. Verdict
+
+**GO_WITH_NONBLOCKING_NOTES**
+
+- **无条件阻断项:0**
+- **条件性阻断项:1**(F1)——条件:支持矩阵中存在 `.metadata.bin` 缺失或 `Created == 0` 的桶
+- 其余:F2 (S2)、F3 (S3)、F4 (S3)、F5(最小性)、F6(nit)、F7(范围说明)、F8(证据不足)、F9/F10(残留边界与覆盖缺口)
+
+我逐条读了六类配置在全部入口的生产路径,**没有**找到在"同一桶世代、Created 已知、合法带源时间事件"范围内会发生回退、删除复活、锁外判旧或重复广播的反例。核心机制是正确的。缺陷集中在两处边缘:零 Created 历史桶被新代码判为不可写(F1,且其自带测试用 stub 掩盖了生产行为),以及 Policy 规范编码排序与既有公开 mismatch 统计口径冲突(F2)。
+
+---
+
+## 1. Findings
+
+### F1 — 零 Created / 无 metadata.bin 的桶:六类配置写入全部硬失败;设计中的"物理桶 Created 补齐"在生产中不可达;配套测试用 stub 掩盖
+
+**严重度 S1(条件性阻断)/ 否则 S2 · 已证实(源码级链路完整),需运行定量确认**
+
+位置:
+- `cmd/bucket-metadata-replication.go:265-278` `ensureBucketMetadataCreated`
+- `cmd/bucket-metadata-sys.go:181-185`(六类分支入口,失败即 `return err`)
+- `cmd/erasure-server-pool.go:2280-2285` —— **`GetBucketInfo` 无条件用缓存 `meta.Created` 覆盖物理卷 Created**
+- `cmd/admin-bucket-handlers.go:1092`(import)、`cmd/site-replication.go:1671`(bulk peer apply)
+- `cmd/site-replication.go:2156-2166` + `cmd/site-replication-metadata.go:45-55`(初次同步静默跳过)
+- `cmd/site-replication-metadata-gate_test.go:145-159`(stub)
+
+最短触发链:
+1. 桶存在,但 `.minio.sys/buckets//.metadata.bin` 不存在且无 legacy 配置文件 → `loadBucketMetadataParse` 走 `errConfigNotFound` 分支并**返回 nil error 且 `Created == 0`**(`cmd/bucket-metadata.go:230-297`;注意 `defaultTimestamps()` 只在 `err == nil` 时调用)。
+2. 启动时 `concurrentLoad`(`cmd/bucket-metadata-sys.go:737-763`)把这个 `Created == 0` 的 meta 写进 `metadataMap`。
+3. `PUT ?tagging`(或 policy/sse/quota/versioning/object-lock)→ `updateAndParseMetadata` → `ensureBucketMetadataCreated` → `objAPI.GetBucketInfo(..., NoMetadata:true)` → `erasureServerPools.GetBucketInfo` 先取到物理卷时间,**随后被缓存里的 0 覆盖** → `info.Created.IsZero()` → `errors.New("bucket metadata creation time is unknown")`。
+4. 客户端收到错误。基线版本此处成功(直接赋值 + `saveMetadata`)。
+
+注:`getAllLegacyConfigs` 在 `cmd/bucket-metadata.go:468` 会用 `info.ModTime` 填 `Created`,所以**有** legacy 配置文件的桶反而没事;**从未设置过任何桶配置**的老桶才是高发场景。
+
+用户影响:
+- 六类配置 PUT/DELETE 全部返回错误,**无运维恢复路径**;`mc admin bucket import` 对该桶整体失败(`rpt.SetStatus(bucket, "", err)`);peer bulk apply 返回错误导致该桶复制持续报错。
+- `syncToAllPeers` 对这类桶**静默跳过全部五类配置**(`state.candidate()` 要求 `valid`,而 `valid = !created.IsZero() && ...`),且**无任何诊断日志** —— 基线版本会发送。
+
+最小修复(推荐 a):
+- **(a)** `cmd/erasure-server-pool.go:2282` 改为 `if !meta.Created.IsZero() { bucketInfo.Created = meta.Created }`。一行;恢复计划中"物理桶 Created 补齐路径"的本意;顺带修掉"无 metadata.bin 的桶在 `mc ls` 里创建时间为零"这一既有瑕疵。需复核 `bucketExists`、`hasBucket = !bi.CreatedAt.IsZero()` 等消费点 —— Created 由零变非零对它们都是变好。
+- **(b)** 另外在 `syncToAllPeers`(`cmd/site-replication.go:2138-2156`)把已在手的 `bucketInfo.Created` 兜底进 `meta.Created`,或在"有内容但 send==false"时打一条 indeterminate,避免静默漏发。
+
+应补验证:
+- 把 `TestPeerBucketMetadataUnknownCreated` 的 `"physical-created"` 子用例**去掉 stub**:用真实 ObjectLayer,把 `newBucketMetadata(bucket)`(Created 为零)存盘并让缓存持有它,断言 `globalBucketMetadataSys.Update(ctx, bucket, bucketTaggingConfig, tagXML)` 成功且 `Created` 被补齐为物理时间。**当前实现会失败。**
+- 一个 `syncToAllPeers` 用例:`Created == 0` 且 Tags 非空的桶,断言初次同步仍发 Tags 事件(或至少有诊断)。
+
+---
+
+### F2 — Policy 规范编码对 `Statement` 数组排序,与既有公开统计的顺序敏感 `Equals` 冲突:永久假 mismatch,heal 永不修复
+
+**严重度 S2(公开状态/可观测性,非数据损失)· 已证实(源码级),需运行确认**
+
+位置:
+- `cmd/bucket-metadata-replication.go:110` —— `sort.Slice` 作用于**每个**数组,含顶层 `Statement`
+- `cmd/bucket-metadata-replication.go:124-161` `canonicalBucketPolicy`
+- `cmd/site-replication.go:3796` `isBktPolicyReplicated` → `prev.Equals(*p)`(`silo-pkg .../policy/bucket-policy.go:190-194` 按 `Statements[i]` **下标**逐一比较,顺序敏感)
+- `cmd/site-replication-metadata.go:174` vs `:179` —— 本地 heal 写**源站原始字节**,远端 heal 经 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` 写**规范字节**
+
+最短反例:
+1. 站点 A 存在升级前写入的桶策略,statement 顺序不是规范字节序(极常见,例如 Deny 在 Allow 之前)。
+2. 加入新站点 B:`syncToAllPeers` → `initialBucketConfigReplicationEvent` 发送 **A 的原始字节**,`UpdatedAt = A.PolicyConfigUpdatedAt`。
+3. B 的 admin 入口解析后调 `PeerBucketPolicyHandler` → `canonicalBucketPolicy` → 存**排序后**字节。
+4. 此后 A/B 的比较键(canonical)完全相同、时间相同 → `compareBucketConfigStates == 0` → heal 永不写、永不发 RPC;而 `Equals` 因 statement 顺序不同返回 false → `mc admin replicate status` **永久**报 bucket policy mismatch,`ReplicatedBucketPolicies` 少计。
+
+同机制第二条路径:同一轮 heal 中本地目标拿原始字节、远端目标拿规范字节,三站点集群会出现持久分叉。
+
+为何是新问题:基线 `PeerBucketPolicyHandler` 用 `json.Marshal(policy)`,集合(Action/Resource/Principal)顺序随机但 **statement 切片顺序被 marshal/unmarshal 保持**,`Equals` 一直成立。
+
+最小修复(三选一,推荐 a 或 b):
+- (a) `canonicalBucketPolicyJSON` 不对顶层 `Statement` 数组排序(只排集合数组)——顺序在全链路被保留,两站点独立解析同一文档仍得相同键。
+- (b) `isBktPolicyReplicated` 改用 `canonicalBucketPolicy` 的键比较,而不是 `Equals`。
+- (c) `healBucketConfig` 本地分支改写 `incoming.data`(与远端同一编码)——只修本地/远端分叉,**不**修 A 与新站点的分叉。
+
+应补验证:双站点用例——A 侧直接把 legacy 顺序的策略字节写盘(绕过 canonical 编码器),join B,跑两轮 heal,断言 `SiteReplicationStatus` 不报 policy mismatch。
+
+---
+
+### F3 — heal 诊断以 ERROR 级、按"桶 × 字段"输出;正常瞬态也报警,且单一 key 会吞掉真实 RPC 失败
+
+**严重度 S3 · 已证实**
+
+位置:`cmd/site-replication-metadata.go:134-145`(两个诊断循环)、`:182-186`(peer 错误)、`cmd/logging.go:19-21`(`replLogOnceIf` 无 errKind ⇒ **ErrorKind**)
+
+1. 第二个循环对 `!state.valid` 的目标打 `indeterminate / unusable peer`。`valid` 要求对端 `CreatedAt != 0` —— 而**对端还没有这个桶**时 `CreatedAt` 就是 0(`cmd/site-replication.go:3096-3103` 给缺桶站点填零值 `SRBucketInfo`)。于是"桶刚建、尚未传播"这种完全正常的瞬态,对每个桶产生 6 条 **ERROR**。
+2. 第一个循环在对端 `RemoteTargetConnectionErr`(`cmd/site-replication.go:3020-3026` 填空 ID)时,对**每个本地桶 × 6 字段**打 `missing peer`。一个站点掉线 ≈ 每小时 6×桶数 条 ERROR。
+3. 四种完全不同的情况(缺 peer / peer 不可用 / peer RPC 失败 / Created 未知)共用同一个 key `"bucket-metadata///indeterminate"` **且错误正文相同**,而 `logOnceIf` 只按 key+正文去重(`internal/logger/logonce.go:100-119`)。结果:**真正的 heal RPC 失败可能被同桶同字段的"缺 peer"消息顶掉而完全不打印**;基线版本这些失败走 `replLogIf`,必然打印。
+
+最小修复:`target.CreatedAt.IsZero()`(桶不在对端)与 `found == false` 时不打诊断;把 4 种情况拆成不同 reason(`unreachable`/`peer-error`/`unknown-created`)避免 key 撞车;给 `logBucketConfigReplication` 传 `logger.WarningKind`。
+
+---
+
+### F4 — 三处用户可见语义变更未写入文档
+
+**严重度 S3(文档/兼容)· 已证实(逐条比对 `docs/site-replication/README.md:65-118`)**
+
+1. **空 Bucket Policy PUT 现在立即按删除处理**:`cmd/bucket-policy-handlers.go:102` + `cmd/bucket-metadata-replication.go:176-180` → `canonicalBucketPolicy` 对 `IsEmpty()` 返回 nil → 落盘 nil → `GetBucketPolicy` 从"200 + 空策略文档"变为 **404 NotFound**。计划 §1 明确写了"需要写入兼容说明",文档里没有。
+2. **零 Quota (`{}`) 在对端从"被删除"变为"保留 live 文档"**:`cmd/admin-bucket-handlers.go:79-96` 删除了出站 `bucketMeta.Quota = nil` 改写。这是正确的对齐(本地原本就保留 `{}`),但对端 `GetBucketQuotaConfig` 的结果会变。
+3. **`GET ?policy` 与 `mc admin bucket export` 的 JSON 形态改变**:`cmd/bucket-policy-handlers.go:202`、`cmd/admin-bucket-handlers.go:439` 改用 `canonicalBucketPolicy` → 对象键按字母序、集合数组与 **Statement 数组被排序**。语义等价(S3 策略求值与 statement 顺序无关),但字节级比对输出的工具会看到变化。
+
+---
+
+### F5 — 最小性:Policy 规范编码器接入 PUT / GET / export / peer 落盘,并非 #77 不变量所必需
+
+**非缺陷,最小性判断 · 已证实**
+
+比较键在 `bucketConfigPayload`(`cmd/bucket-metadata-replication.go:172-180`)里**从已解析策略现算**,与落盘字节无关。即使 PUT/peer 仍用 `json.Marshal`(字节不确定),两站点的键依旧相同,收敛性完全不受影响。
+
+因此这部分改动带来的是两件**额外**的事:(a) 让原本被 `ActionSet.MarshalJSON`(`silo-pkg .../policy/actionset.go:144-148`,空集合报错)挡掉的 `NotAction`/`NotResource` 策略首次可以写入 —— 这是**功能新增**;(b) 直接导致 F2。
+
+判断:如果作者**有意**支持负集合策略,应作为独立特性声明并单独记录/测试(目前只有 `site-replication-metadata_test.go:530-541` 一个用例);如果只为 #77,最小做法是 `canonicalBucketPolicy` 只用于比较键。我**不**主张必须删除——它确实修掉了"能存不能读"的潜在坑——但必须承认这是范围外的行为扩张,且未在文档中声明。
+
+其它可删复杂度(都很小,不影响不变量):
+- `cmd/bucket-metadata-sys.go:151` 在零值结构体上用 `replicatedBucketConfig(&result.meta, configFile)` 做"是不是这六类"的判断,语义晦涩;一个 `isReplicatedBucketConfig(file) bool` 更清楚。
+- 修掉 F3 后,`healBucketConfig` 的第二个诊断循环(`:139-145`)可并入主循环。
+
+---
+
+### F6 — Nit
+
+- `isBucketMetadataEqual`(`cmd/site-replication.go:5164`)现在只被测试引用,生产死代码。
+- `cmd/bucket-metadata-replication.go:36-38` 注释"Object Lock is applied before Versioning"对 bulk apply 循环成立,但 `healBuckets`(`cmd/site-replication.go:4745-4746`)先 heal Versioning 再 heal Object Lock。我推演过仍收敛(最多 2 个周期、无写入环),但注释与 heal 顺序不一致,建议补一句。
+
+---
+
+### F7 — 范围说明(非缺陷,但必须进入决策)
+
+**默认 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` 时,Tags / SSE / Quota 的"漏发删除"不会通过 heal 收敛。** 只有 Policy 墓碑默认导出(`cmd/site-replication.go:3953-3954` 无条件导出;另外三类在 `:3960 / :3980 / :3988` 被 gate 挡住)。
+
+我完整推演了 off 模式,结论与文档一致:本地真实墓碑在锁内比较时**不会**被旧 PUT 复活(`applyBucketConfig` 用真实落盘状态比较);但持有旧数据的对端会每 30 秒发一次过期 RPC 被拒绝,**状态永不收敛**,直到运维在权威站点重新提交删除,或全站点升级后统一开启开关。
+
+也就是说:**默认配置下交付的是"顺序正确 + 不复活 + Policy 删除可 heal",不是"四类删除都能自愈"。** 这是 v4 计划的既定取舍(计划 §提交 3 表格),实现与文档都如实写了;我在此只是确保批准时看到这一点。
+
+---
+
+### F8 — 证据不足项
+
+1. **F2 对双站点实验不可见**:`twosite/main.go:141-144` 的所有策略都经 `SRPeerReplicateBucketMeta` → `canonicalBucketPolicy` 写入,两侧都是规范字节,`states()`(`:160-164`)的字节比较自然通过。**从未构造过"升级前旧编码字节"的策略。**
+2. **F1 被 stub 掩盖**:`cmd/site-replication-metadata-gate_test.go:151-159` 自定义 `GetBucketInfo`,在 `opts.NoMetadata` 时直接返回物理 Created,绕过了 `erasureServerPools.GetBucketInfo` 的缓存覆盖。`"physical-created"` 子用例证明的是一个**生产中不会发生**的行为。
+3. **"稳态 0 metadata RPC"的边界**:`twosite/main.go:182-190` 只统计 `event.Bucket == bucket` 的单桶、双站点、**gate=on**。结论有效但窄;gate=off 的稳态不为零(文档已声明),多桶/多站点未观测。
+4. **二进制身份**:`manifest.json` 给了 `silo-final` 的 sha256,但目录里没有 `--version`/build-info 输出(驱动用 `--quiet --json` 启动),`runtime-final.log` 也没有版本行。验收自述"编译信息为 `c8f264f79 + dirty`"是**诚实的**,且日志里的 gate 行为(`gate=on/off`、`off exporter exposed new tombstone`)只能来自第三个提交的生产代码,所以**没有夸大**;但也**不能从证据目录内独立复核**。补一份 `silo-final --version` 或 `go build` 复现即可闭环。
+5. **baseline.log 行号与最终测试文件不一致**(日志 93/105/117 vs 现文件 96/97/109/121):说明"修复前复现"跑的是测试文件的早期版本。可接受,但严格讲不是同一份用例。
+
+---
+
+### F9 — 残留边界:桶世代冲突(计划内已声明,非本轮回归)
+
+各站点 `Created` 不同时,A 的 baseline-live(`at == A.Created`)在 B 上会被重算为 `real`(`newBucketConfigState` 用**目标**的 `created` 判定,`cmd/bucket-metadata-replication.go:215-219`),可能压过 B 的真实墓碑。计划明确排除在收敛承诺外,且 `AddPeerClusters`(`cmd/site-replication.go:458-466`,"only one cluster may have data")封死了最常见入口。剩余入口:分区期间两站点各自建同名桶,或 #78 接管。基线版本在同场景下是**不确定**的(seed 首个 map 项),所以不算回归。
+
+---
+
+### F10 — 覆盖缺口:接管时 `Created` 前移越过真实修改时间
+
+`rebaseBucketConfigDefaults`(`cmd/bucket-metadata-replication.go:306-318`)只调整"零/等于旧 Created"的默认时间。若 `opts.CreatedAt` 晚于某个**真实**字段时间,该字段变成 `at < Created` ⇒ `valid == false`:作为 heal 源 `candidate()` 为 false;作为 heal 目标 `incoming.valid > current.valid` ⇒ **必被覆盖**,本地真实配置被丢弃。
+
+在"远端世代胜出"的语义下可以论证这是对的,但 `TestPeerBucketAdoptionRebasesOnlyDefaults`(`cmd/site-replication-metadata_test.go:394-425`)用的 shift 是 ±1h 而真实时间在 `created+2h`,**恰好没有覆盖这一情形**。建议加一个 `shift = +3h` 的子用例,把期望行为固定下来。
+
+---
+
+## 2. 六类配置 × 各入口 覆盖判断
+
+| 配置 | 本地写 | typed peer | bulk | import | initial sync | heal | 接管 | 结论 |
+|---|---|---|---|---|---|---|---|---|
+| Policy | ✅ `bucket-policy-handlers.go:108/152`,hook 用 `result.meta`+`result.updatedAt` | ✅ `site-replication.go:1716-1730` | ✅ `len(item.Policy)!=0` 判"已提供" | ✅ 共同 `commitAt` + 空策略另发专用 nil 事件(`admin-bucket-handlers.go:1150-1155`) | ✅ gate 控墓碑 | ✅ | ✅ rebase | 通过(F2/F4 为附带) |
+| Tags | ✅ `bucket-handlers.go:1940/2021` | ✅ `:1733-1747` | ✅ `*string != nil` | ✅ | ✅ gate | ✅ 补齐 `UpdatedAt`(旧版 heal 缺此字段) | ✅ | 通过 |
+| SSE | ✅ `bucket-encryption-handlers.go:106/199` | ✅ `:1787-1801` | ✅ | ✅ | ✅ gate | ✅ | ✅ | 通过 |
+| Quota | ✅ `admin-bucket-handlers.go:85-99`(删掉零值改写) | ✅ `:2032-2047` | ✅ `len(item.Quota)!=0` | ✅ | ✅ gate | ✅ 含缓存清除(`parse=false` 修复) | ✅ | 通过 |
+| Versioning | ✅ `bucket-versioning-handler.go:100-116`,广播用归一后的 `result.meta` | ✅ 空=no-op | ✅ 空=no-op | ✅ 归一后落盘并广播 | ⛔ 不发(由 MakeBucketHook bootstrap + heal 对齐,与基线一致) | ✅ 按**目标** Lock 状态归一后比较 | ✅ `enablePeerBucketVersioning` 用 `localBucketConfigUpdatedAt` | 通过 |
+| Object Lock | ✅ `bucket-handlers.go:1841-1852` | ✅ 空=no-op,保留 `item.Tags` legacy 回退 | ✅ 空=no-op | ✅ | ✅ | ✅ | ✅ | 通过 |
+
+逐条核对结论:
+
+- **来源时间不会变成本地 now**:六个 typed handler 全部删除了锁外 `GetXConfig()` 判旧,统一走 `updateAndParseMetadata(..., &updatedAt)`;非零 `sourceTime` 直接 `sourceTime.UTC()`(`bucket-metadata-sys.go:191-193`)。只有 `sourceTime == nil`(本地写)或为零(legacy 兼容)才分配本地单调时间。✔
+- **本地写严格单调**:`localBucketConfigUpdatedAt`(`:255-263`)保证 `> Created` 且 `> 当前字段时间`,涵盖"已有未来时间"。✔
+- **重复/乱序不保存不广播**:`applyBucketConfig` 在 `compare <= 0` 返回 `changed=false`,据此跳过 `saveMetadata` 与 `LoadBucketMetadata`;bulk 用 `changed` 汇总后一次保存。✔
+ 我特别核对了所有使用 `result.meta`/`result.updatedAt` 的本地 handler:在这些路径上 `changed` 恒为 true(`localBucketConfigUpdatedAt` 使 `incoming.at` 严格更大,且 `incoming.candidate()` 恒真),所以**不会**出现"零时间 + 空载荷"被当成删除广播出去。这是一个隐式依赖,建议加一行注释或断言固定住。
+- **整桶 `.metadata.bin` 读-比较-写全在既有分布式锁内**:`updateAndParseMetadata`、`PeerBucketMetadataUpdateHandler`、import 最终提交、`PeerBucketMakeWithVersioningHandler`、CORS 路径,我逐个确认锁的获取在读之前、释放在 `saveMetadata` 之后、fan-out 在释放之后。✔
+- **无关字段 / CORS / lifecycle 不被覆盖**:所有写路径都是锁内**重新加载**后只改目标字段;import 用 `applyImportedBucketMetadata` 只按 `fields` 拷贝并 `bytes.Clone`。✔
+- **缓存快照正确**:`saveMetadata` 改收 `*BucketMetadata`,`meta.Save` 内部 `parseAllConfigs` 回写归一化结果,`sys.Set(name, *meta)` 发布的是提交后快照;调用方拿到的 `result.meta` 是同一份值拷贝,不会原地修改已发布引用。✔
+- **`parse` 被强制为 false 对缓存无害**:`Save()` 先 `parseAllConfigs`,发布到 `metadataMap` 的解析字段是新的;而"删除后 quota 解析残留"正是靠 `parse=false` 的新加载对象修掉的(`parseAllConfigs` 对空 `QuotaConfigJSON` **不会**把 `quotaConfig` 置 nil,`bucket-metadata.go:400-405`)。✔
+- **锁/返回值/错误处理**:`unlock()` + `locked=false` 模式保留;`updateAndParse`、`Update`、`Delete` 对外签名不变。✔
+
+---
+
+## 3. 六字段 state 比较:传递性、交换序无关、幂等收敛
+
+`compareBucketConfigStates`(`bucket-metadata-replication.go:227-253`)实际是按 `(valid, real, real?at:—, real?isTombstone:—, key)` 的字典序全序。
+
+- **传递 / 反对称**:是。`real == false` 时不比较 `at`(只比 key),但空 baseline 的 key 为 nil,`bytes.Compare` 使其恒最小,效果等同"空 baseline 永不获胜"。✔
+- **交换顺序无关**:`latestBucketConfig` 取全序最大值,与 map 遍历顺序无关;`applyBucketConfig` 同样只依赖全序。`TestLatestBucketConfigCandidates` 跑了全部 6 种排列。✔
+- **幂等收敛**:写入后目标状态等于源状态 ⇒ 下一轮 `compare == 0` ⇒ 不写不发。我另外手工推演了 Object Lock/Versioning 的"Enabled vs Suspended 同时间"场景,归一后双侧都不再写,稳定无振荡。✔
+- **baseline Created 与真修改的区分**:`real = valid && at.After(created)`,零字段时间先回填为 `created`(与 `defaultTimestamps()` 一致)。✔
+- **同时间删除胜出**:`:244-250`,仅在 `a.real` 分支内生效,空 baseline 不会借此删配置。✔
+- **等时不同载荷的稳定键**:
+ - **Policy**:递归排序的规范 JSON + `json.RawMessage` 保留整数精度(测试 `:308-311` 验证 `9007199254740993` 不失真)。我对照了 `silo-pkg .../policy/bucket-policy-statement.go:27-36`,`BPStatement` 的 8 个字段(SID/Effect/Principal/Actions/NotActions/Resources/NotResources/Conditions)**全部覆盖,无字段静默丢失**;`ParseBucketPolicyConfig` 先做 `Validate` 保证 `Principal.MarshalJSON` 不会失败。✔ 但顶层 Statement 排序引出 F2。
+ - **Quota**:`json.Marshal(parseBucketQuota(...))`。零 quota 仍是 live 文档(`candidate()` 靠 `real`,不靠 `len(data)`)。`{}` 与对端重编码字节不同但键相同,不产生 heal 环;`isBktQuotaCfgReplicated` 按解析值比较,不会误报。✔
+ - **Tags / SSE / Object Lock**:有效文档字节,保留大小写与实际内容。✔
+ - **Versioning**:先按**该站点**的 Object Lock 归一(`effectiveBucketVersioning`),键即归一后的文档,避免"保存阶段隐式改写"造成的空转。✔
+- **空策略**:`bucketConfigPayload` 对 `cfg.IsEmpty()` 返回 `(nil, nil, nil)`,与 peer 侧既有"空策略=删除"解释统一。✔(副作用见 F4-1)
+- **Object Lock 强制 Versioning 后的持久化 vs 广播**:本地只广播 Object Lock 事件,派生的 Versioning 由对端在 `Save → parseAllConfigs` 自行推导,**内容一致、时间各自保留**;heal 用归一后的有效文档比较,最多一次写入即收敛。与计划一致。✔
+
+---
+
+## 4. Created / 时间的异常输入
+
+| 输入 | 行为 | 判断 |
+|---|---|---|
+| Created 为零 / metadata.bin 缺失 | 六类写入报错;初次同步静默跳过 | **F1,回归** |
+| 字段时间为零 | 回填为 Created ⇒ baseline | ✔ 与 `defaultTimestamps()` 一致 |
+| 事件时间 == Created | 可更新仍为 baseline 的字段,时间保存为 Created;同时间 nil/空只算空 baseline,不删除 | ✔ 有用例 |
+| 事件时间 < Created | `before-created`,跳过 + 单条日志;heal 对该目标 `continue` | ✔ |
+| 未来时间 | 接受为真实状态;后续本地写用 `max(now, at+1ns)` 压过 | ✔ 有用例 |
+| 各站点 Created 不同 | 见 F9(计划外,已声明) | 残留 |
+| 空 deployment ID | `latestBucketConfig` / `healBucketConfig` 均 `id == "" \|\| !known` 跳过 | ✔ 有用例 |
+| peer 报错 / 缺元数据 | `candidate()` 为 false ⇒ 不参与选源;作为目标 `CreatedAt.IsZero()` ⇒ `continue`。**不会被当成删除** | ✔ |
+| 单个 peer 不可达 | 记录后继续其它目标(`site-replication-metadata.go:182-186`),不因 map 顺序放弃健康站点 | ✔ 有用例(`"broken"`) |
+
+**"已知历史不可恢复"与"新实现回归"的区分**:
+- 历史不可恢复(可接受):旧版到达时间污染、legacy-zero 事件产生的新本地时间、桶世代分歧。
+- 新实现回归(应修):F1 的零 Created 硬失败与初次同步静默漏发;F2 的永久假 mismatch;F3 的 ERROR 噪声与日志互相顶替。
+
+---
+
+## 5. 兼容路径与开关
+
+- **typed 零时间兼容**:`sourceTime != nil && IsZero()` ⇒ 分配本地时间 + 一条 `legacy-zero`(`bucket-metadata-sys.go:186-190`)。不受 gate 影响,不在源时间排序保证内。✔
+- **bulk 零时间拒绝**:`PeerBucketMetadataUpdateHandler:1610-1612` ⇒ `errInvalidArgument`,与现状一致;import hook 恒带非零 `commitAt`。✔
+- **省略 vs 显式 null / 空串**:`madmin.SRBucketMeta` 全字段 `omitempty`(`madmin-go v3.0.110 cluster-commands.go:501-538`),因此
+ - `json.RawMessage`(Policy/Quota):省略 ⇒ nil;显式 `null` ⇒ `[]byte("null")`,走 `len(...)!=0` 判为"已提供",再交既有解析器(Policy `null` ⇒ 语义空 ⇒ 删除;Quota `null` ⇒ 零值文档 ⇒ live)。✔ 与计划 §1 完全一致。
+ - `*string`:省略/`null` ⇒ nil ⇒ 不动;空串 ⇒ 已提供且内容为空 ⇒ 四类删除、两类 no-op。✔
+ - 这些由 `TestPeerBucketMetadataWireAtomicity` 通过**真实 admin 路由 + 真实 JSON 编解码**覆盖(`applySRBucketMetaViaAdmin` 走 `registerAdminRouter`),不是结构体直传。这是这批测试里质量最高的一块。
+- **direct hook 与 heal 的删除传播**:普通 DELETE 事件在 gate off 下照常复制;heal 依赖导出可见性 ⇒ 见 F7。
+- **初次同步**:保留原五类范围(不含 Versioning),gate 控制是否发送真实墓碑。✔
+- **gate 覆盖点是否遗漏**:我检查了所有导出/初次发送点 —— `SiteReplicationMetaInfo` 的 Tags/Quota/SSE 三处(`:3960/:3980/:3988`)+ `initialBucketConfigReplicationEvent:51`。Policy 墓碑**故意**不受 gate 控制(原本已导出);Versioning/Object Lock 无墓碑概念。**未发现遗漏的开关覆盖点。** ✔
+- **滚动升级/降级**:off 时新增墓碑信息不导出,旧节点不会收到它无法正确处理的 Quota heal 墓碑(文档点名了"旧版会留下已解析 quota 残留"这一实证依据)。✔ 但 off **不等于**与旧版同构:修复端的**接收**行为已经变了(锁内排序、空 baseline 不删除、重复不写),这对旧版发来的事件是更安全的方向,混版冒烟也验证了普通 PUT/DELETE 互通。降级路径文档要求先关开关再滚降;唯一可补的一句是"墓碑时间字段本来就在 schema 内,旧版只是不导出,降级不会产生解析错误"。
+
+---
+
+## 6. 三个独立判断
+
+**最小?—— 基本是,有一处可争议的扩张。**
+生产 Go 代码净增约 37 行,六份重复的 heal / peer apply 被一套 helper 替代;没有新 schema、没有新 wire 字段、没有新锁、没有能力协商、没有迁移系统。唯一超出必要的是 **Policy 规范编码器接入写/读/导出路径**(F5)——它不是收敛所必需的,并且直接导致 F2。其余(`bucketMetadataUpdate` 提交快照、`ensureBucketMetadataCreated`、`rebaseBucketConfigDefaults`、gate)我都能各自对应到一个已证实的缺陷或本次修复直接触及的路径,**没有**可以无损删除的部分。
+
+**充分?—— 对"已声明的范围"是;对"缺陷标题"不是。**
+在同一桶世代、Created 已知、gate=on 的前提下,六类配置的源时间、锁范围、删除状态参与 heal、等时冲突裁决、重复/乱序抑制都成立。但两个口子要明说:(1) 默认 gate=off ⇒ Tags/SSE/Quota 的漏发删除不收敛(F7,计划内取舍);(2) Created 未知的桶从"能写"变成"不能写"(F1,计划外回归)。
+
+**必要?—— 是。**
+每一项改动都能对应到 `baseline.log` 里的实测失败(SOURCE_TIME / NEWER_DELETE / STALE_RESURRECTION / BULK_STALE_OVERWRITE / CHECK_OUTSIDE_LOCK)或计划中论证过的路径。`rebaseBucketConfigDefaults` 这种看起来最"多余"的小分支,实际是 `Created` 一旦参与 baseline/tombstone 判定后的必然补丁(不加则接管时默认值会变成假墓碑)。
+
+---
+
+## 7. 新增设计记录应准确保留的内容
+
+**关键决策**
+1. baseline / live / tombstone 三态由 `(Created, 字段时间, 载荷空否)` 推导,**不新增 schema 字段**;`at == Created` 定义为 baseline。代价:必须知道 Created(⇒ F1 的根因)。
+2. 全序:`valid > real > at > 同时间墓碑胜 > 稳定内容键`。deployment ID **不**参与比较,也不落盘。
+3. 比较键是"已解析配置的纯函数":Policy 走递归排序的规范 JSON,Quota 走解析后 `json.Marshal`,XML 走有效文档字节,Versioning 先按本站点 Object Lock 归一。
+4. Versioning / Object Lock 为 update-only:空事件恒为 no-op,空值不是候选。
+5. 读-比较-写整体在**既有** `metadata.lock` 内;`saveMetadata` 收指针以便调用方拿到提交后快照,保证"落盘状态 / 出站事件 / 源时间"三元组一致。
+6. 新增删除信息的**导出**用启动开关 gate,默认 off;开关不探测远端能力,启用条件是全站点全节点已升级且旧请求排空。
+7. 专用 peer 事件的零时间保留兼容例外(分配本地时间 + 限频 `legacy-zero`);bulk 零时间仍按现状拒绝。
+
+**被拒绝的方案**(记录以免复议)
+- 新增 HLC / 向量时钟 / 新 wire 字段 / 能力协商 / 通用复制框架 / 新锁或重试系统。
+- 用 `len(payload)==0 ⇒ 删除` 统一处理 bulk(会把"省略"误判为删除)。
+- 把 `BucketPolicy.Equals`(忽略 Sid、对 Statement 顺序敏感)叠加为第二套判等规则。
+- 把混版长期测试作为提交门槛。
+- 把未修改上游 MinIO 的兼容性当作必需门禁(AGENTS.md:正式支持 PGSTY 栈,上游兼容为尽力而为)。
+
+**限制**
+- gate=off 期间 Tags/SSE/Quota 墓碑不可见,不承诺删除收敛,且存在被拒绝的周期性 RPC。
+- 旧版到达时间污染、legacy-zero 产生的新本地时间、桶创建世代分歧**无法自动反推**,需运维在权威站点重新提交。
+- `Created` 未知的桶不在收敛承诺内(并且按当前实现直接不可写,见 F1)。
+- "比较键相同但落盘字节不同"(Quota 原始 JSON、Policy 旧编码)是允许的稳定状态,公开 mismatch 统计必须与比较键口径一致(见 F2)。
+
+---
+
+## 8. 建议处置顺序
+
+1. 判定 F1 的条件(支持矩阵里有没有 `Created == 0` / 无 `.metadata.bin` 的桶)。有 ⇒ 先修再合。
+2. 修 F2(推荐:不排序顶层 `Statement`,或让 `isBktPolicyReplicated` 改用规范键)。
+3. 修 F3(ERROR→Warning、缺桶不报、reason 拆分)。
+4. 补 F4 的三条文档。
+5. 补测试:F10 的接管用例、F8-1 的 legacy 策略字节双站点用例、F8-2 去 stub 的 Created 用例、F8-4 的二进制身份记录。
+6. F5 / F6 由作者判断,可留作后续。
diff --git a/docs/investigations/issue-77/implementation-review/round-2/confirmed-after-race.log b/docs/investigations/issue-77/implementation-review/round-2/confirmed-after-race.log
new file mode 100644
index 000000000..da9907b66
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/confirmed-after-race.log
@@ -0,0 +1 @@
+ok github.com/minio/minio/cmd 6.864s
diff --git a/docs/investigations/issue-77/implementation-review/round-2/confirmed-after.log b/docs/investigations/issue-77/implementation-review/round-2/confirmed-after.log
new file mode 100644
index 000000000..96eff9cb0
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/confirmed-after.log
@@ -0,0 +1 @@
+ok github.com/minio/minio/cmd 20.694s
diff --git a/docs/investigations/issue-77/implementation-review/round-2/confirmed-before.log b/docs/investigations/issue-77/implementation-review/round-2/confirmed-before.log
new file mode 100644
index 000000000..59e27bb00
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/confirmed-before.log
@@ -0,0 +1,57 @@
+--- FAIL: TestBucketMetadataPhysicalCreatedRecovery (0.57s)
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=false (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/object-lock.xml/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/versioning.xml/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/policy.json/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/tagging.xml/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/bucket-encryption.xml/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/ErasureSD/quota.json/missing=true (0.00s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=false (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=false (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=false (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=false (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=false (0.06s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=false (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/object-lock.xml/missing=true (0.04s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update object-lock.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/versioning.xml/missing=true (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update versioning.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/policy.json/missing=true (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update policy.json: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/tagging.xml/missing=true (0.01s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update tagging.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/bucket-encryption.xml/missing=true (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update bucket-encryption.xml: bucket metadata creation time is unknown
+ --- FAIL: TestBucketMetadataPhysicalCreatedRecovery/Erasure/quota.json/missing=true (0.02s)
+ site-replication-metadata-gate_test.go:227: bucket without a recorded creation time cannot update quota.json: bucket metadata creation time is unknown
+--- FAIL: TestBucketPolicyReplicationStatusLegacyOrder (0.31s)
+ --- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/ErasureSD (0.01s)
+ site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
+ --- FAIL: TestBucketPolicyReplicationStatusLegacyOrder/Erasure (0.06s)
+ site-replication-metadata_test.go:348: equivalent legacy and received policy reported as permanently mismatched
+FAIL
+FAIL github.com/minio/minio/cmd 2.940s
+FAIL
diff --git a/docs/investigations/issue-77/implementation-review/round-2/confirmed-diagnostics-before.log b/docs/investigations/issue-77/implementation-review/round-2/confirmed-diagnostics-before.log
new file mode 100644
index 000000000..64db824f0
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/confirmed-diagnostics-before.log
@@ -0,0 +1,8 @@
+--- FAIL: TestHealBucketConfigDiagnostics (0.29s)
+ --- FAIL: TestHealBucketConfigDiagnostics/ErasureSD (0.00s)
+ site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
+ --- FAIL: TestHealBucketConfigDiagnostics/Erasure (0.01s)
+ site-replication-metadata-heal_test.go:80: empty baselines produced diagnostics
+FAIL
+FAIL github.com/minio/minio/cmd 2.721s
+FAIL
diff --git a/docs/investigations/issue-77/implementation-review/round-2/initial-sync-before.log b/docs/investigations/issue-77/implementation-review/round-2/initial-sync-before.log
new file mode 100644
index 000000000..8ff175164
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/initial-sync-before.log
@@ -0,0 +1,8 @@
+--- FAIL: TestBucketMetadataInitialSyncPhysicalCreated (0.47s)
+ --- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/ErasureSD (0.04s)
+ site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
+ --- FAIL: TestBucketMetadataInitialSyncPhysicalCreated/Erasure (0.08s)
+ site-replication-metadata-gate_test.go:289: peer creation time "0001-01-01T00:00:00Z", want physical time 2026-09-12 06:03:09 +0000 UTC
+FAIL
+FAIL github.com/minio/minio/cmd 2.576s
+FAIL
diff --git a/docs/investigations/issue-77/implementation-review/round-2/no-source-before.log b/docs/investigations/issue-77/implementation-review/round-2/no-source-before.log
new file mode 100644
index 000000000..dc965a221
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/no-source-before.log
@@ -0,0 +1,18 @@
+--- FAIL: TestHealBucketConfigWithoutSourceDiagnostics (0.36s)
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD (0.00s)
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/unknown-created (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/malformed (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/ErasureSD/before-created (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure (0.01s)
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/unknown-created (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/malformed (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+ --- FAIL: TestHealBucketConfigWithoutSourceDiagnostics/Erasure/before-created (0.00s)
+ site-replication-metadata-heal_test.go:155: got 0 diagnostics, want 1: []
+FAIL
+FAIL github.com/minio/minio/cmd 2.430s
+FAIL
diff --git a/docs/investigations/issue-77/implementation-review/round-2/opus5-max-review.md b/docs/investigations/issue-77/implementation-review/round-2/opus5-max-review.md
new file mode 100644
index 000000000..9afb1a34e
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/opus5-max-review.md
@@ -0,0 +1,174 @@
+# SILO #77 修正提交独立复审(固定 `62cf066ff529c7d281703daa365f555cebba717a`)
+
+审查方式:只读。以 worktree 生产源码为准(不只读 diff),交叉核对 `silo-pkg v3.13.4-0.20260910091716` 依赖源码、证据目录与设计记录。上一轮报告只作为待验证的命题,不作为已确认结论。
+
+---
+
+## 0. 结论
+
+**GO_WITH_NONBLOCKING_NOTES**
+
+- **阻断项:0**(无条件 0,条件性 0)
+- 上一轮的条件性阻断 F1 与 S2 级 F2 均已在生产路径上真正关闭,并有"最终测试 + 旧生产代码覆盖"的复现证据(`round-2/confirmed-before.log`)。
+- 新增 8 项非阻断发现(NB-1 ~ NB-8),其中 3 项是行为残留(S3/S4),5 项是证据与文档表述准确性问题。
+- 一处需要纠正上一轮的**判断**(不是纠正实现):F5 中"Policy 规范编码器接入 GET/export/peer 属于超出必要的扩张"这一说法不成立——这三个调用点持有的是**已解析策略**,必须有编码器,而旧编码器对解析器已支持的负集合策略**必然报错**。真正"可选"的只有 PUT/import 两处,且因 `DisallowUnknownFields` 而无损。
+
+---
+
+## 1. F1–F10 关闭状态
+
+| 项 | 上轮判定 | 本轮核实 | 状态 |
+|---|---|---|---|
+| F1 零 Created 桶六类配置写入硬失败、初次同步静默漏发 | S1 条件阻断 | 生产路径已修复并有真实 ObjectLayer + 旧码覆盖复现 | **关闭**(残留见 NB-1) |
+| F2 Policy 状态口径与收敛键冲突导致永久假 mismatch | S2 | `isBktPolicyReplicated` 改用同一 canonical 键,per-site presence 不变,无 SID/ID/Condition/未知值回归 | **关闭** |
+| F3 heal 诊断 ERROR 级、key 互相顶替、正常瞬态误报 | S3 | ERROR→Warning、四类原因各自持 key、缺桶不报 = 已关闭;"全无有效来源即完全静默"与"按桶×字段扇出"未处理 | **部分关闭**(NB-2、NB-3) |
+| F4 三处语义变更未文档化 | S3 | 上轮前两项系误读(原提交 README 已有,见 diff 中为 context 行);第三项已补入 `docs/site-replication/README.md:129-134` | **关闭 + 原误读已纠正** |
+| F5 Policy 规范编码器接入面 | 最小性争议 | 判断更新:GET/export/peer 非可选;PUT/import 可选但无损。建议保留现状 | **关闭(判断修正)** |
+| F6 死代码与过时注释 | nit | 注释已修(`bucket-metadata-replication.go:35-37`);`isBucketMetadataEqual` 仍为测试专用 | **关闭(保留 nit,见 NB-5)** |
+| F7 gate=off 不承诺四类删除自愈 | 范围说明 | README:75-89、设计记录 §rollout/§limits 均如实声明,未说成已自动修复 | **保持(如实)** |
+| F8 证据缺口(stub / 旧顺序策略 / 二进制身份) | 证据不足 | stub 已去除、旧顺序用例已补、二进制身份已闭环(`--version` 含 commit-id + 对照基线二进制) | **关闭**(但 NB-4:两份旧日志不可用作证据) |
+| F9 桶世代冲突 | 范围外 | 未被声称已修;设计记录 §limits-3 明确 | **保持(如实)** |
+| F10 接管 Created 越过真实字段时间 | 覆盖缺口 | 补 `shift=3h` 子用例,钉住"旧世代状态不得升级为有效来源" | **关闭(半边,见 NB-7)** |
+
+---
+
+## 2. 本轮重点逐项核实
+
+### F1 —— 真正修好了,机制比上轮建议更完整
+
+**`NoMetadata=true` 返回物理探测**(`cmd/erasure-server-pool.go:2279-2283`)。我枚举了全仓库 `NoMetadata` 的全部生产调用点,只有三处:
+
+- `cmd/bucket-metadata-sys.go:272`(`saveMetadata` 存在性重查,只看 error)
+- `cmd/bucket-metadata.go:306`(迁移锁内存在性重查,只看 error)
+- `cmd/bucket-metadata-replication.go:281`(新增的 Created 恢复,正是要物理值)
+
+其余全部是 `BucketOptions{}`(`NoMetadata=false`),行为不变。被跳过的 `globalBucketMetadataSys.Get()`(`bucket-metadata-sys.go:410-424`)是**纯缓存读、无副作用、不触发加载**,因此跳过它不会改变任何缓存预热或懒加载行为。`Versioning`/`ObjectLocking` 两个字段也只在非 NoMetadata 分支填,与 `ListBuckets`(`:2402/2422` 只在 `!NoMetadata` 时覆盖 Created)语义一致。**无调用者回归。**
+
+**世代一致性链路完整**(这一点比上轮提出的方案更强,值得记录):
+`syncToAllPeers`(`site-replication.go:2145-2153`)恢复 `meta.Created` → 传入 `MakeBucketOptions.CreatedAt` → `MakeBucketHook`(`:821-826`)在缓存值为零时回落到 `opts.CreatedAt`(此时本地磁盘仍是 0,所以这个回落是必需的)→ **本地**分支 `PeerBucketMakeWithVersioningHandler`(`:943-966`)在 `metadata.lock` 内 `SetCreatedAt(opts.CreatedAt)` + `rebaseBucketConfigDefaults` + `saveMetadata`,**远端**分支通过 `optsMap["createdAt"]` 收到同一个值。也就是说本地并非"只在内存里补一下",而是与全部对端落盘同一个世代值。这条链路 diff 上看不出来,必须读 `MakeBucketHook` 的 `concDo` 第一个闭包才能确认。
+
+四个恢复点的锁位置也确认无误:写路径(`bucket-metadata-sys.go:186`,锁内)、bulk(`site-replication.go:1674`,锁内)、import(`admin-bucket-handlers.go:1092`,锁内)、初次同步(锁外探测但不落盘,落盘由上面的钩子在锁内完成)。
+
+**"无法恢复时显式错误"是否正确:正确。** 静默发明 `UTCNow()` 会造出一个不可撤销、且会向全站点传播的假世代;而显式失败保留原数据、可重试、并留一条有界 `indeterminate`。`syncToAllPeers` 因此让整个 `AddPeerClusters` 失败——这在真实文件系统上基本不可达(`StatVol` 用目录 `ModTime`,存在即非零),且 `AddPeerClusters` 是可重试的交互式管理操作,代价可接受。
+
+**真实用例是否覆盖生产路径:是。** `TestBucketMetadataPhysicalCreatedRecovery`(`site-replication-metadata-gate_test.go:206-237`)通过 `globalBucketMetadataSys.save` 让**缓存持有 Created=0**,再走 `Update` → `updateAndParseMetadata` → `ensureBucketMetadataCreated` → 真实 `erasureServerPools.GetBucketInfo`;`ExecObjectLayerAPITest`(`test-utils_test.go:1766/1795`)两个后端都是真实 `erasureServerPools`,因此缓存覆盖这一段是真跑到的。`setPhysicalBucketCreated` 直接 `os.Chtimes` 每块盘的桶目录,正是 `xl-storage.go:1022-1028` 取值的来源。六类配置 × `missing=true/false` 全覆盖。
+
+### F2 —— 关闭,且口径现在六类全部自洽
+
+`site-replication.go:3782-3809`。状态侧的 `policies[i]` 来自 `:3436` 对**各站点原始字节的解析**,与 heal 的 `bucketConfigPayload`→`canonicalBucketPolicy`(`bucket-metadata-replication.go:191`)是同一个纯函数,因此 peer 重排 Statement 后的永久假 mismatch 被彻底关闭。`numPolicies != total` 的 per-site presence 计数原样保留(`:3783-3792`),并有断言覆盖。
+
+回归面逐条查过:
+
+- **SID**:旧 `BPStatement.Equals`(silo-pkg `bucket-policy-statement.go:148-171`)**不比较 SID**,新键包含 `Sid`。这是方向正确的收紧——heal 的收敛键同样含 SID,所以状态报出的 mismatch 是**真实且会被 heal 自动消除**的,不是永久噪声。用例已钉住。
+- **ID / Version**:旧 `Equals` 比较二者,新键在 `ID != ""` 时包含、`Version` 始终包含,等价。
+- **Condition**:两侧都走同一 `condition.Functions` 编码再递归排序,顺序无关。
+- **未知值**:`ParseBucketPolicyConfig` 用 `decoder.DisallowUnknownFields()`(`bucket-policy.go:173`),未知字段**根本无法通过解析**,因此不存在"键把未建模字段静默吃掉"的风险。
+- `isIAMPolicyReplicated`(`:3665-3681`)仍用 `Equals`,那是 IAM 策略、不同子系统,**不应**一并改动。正确地没动。
+
+新旧行为是"收紧而非放宽",与收敛契约一致,判断合理。
+
+### F3 —— 关闭了会吃日志的部分,但静默面仍偏大
+
+已关闭:四种原因各自持 key(`site-replication-metadata.go:145/158/164/189`),真实 heal RPC 失败不再被"缺 peer"顶掉;`logger.WarningKind`(`:42`);对端"尚未拥有该桶"(`CreatedAt=0` 且载荷空、时间零)不再报。`siteReplicationStatus` 对不可达 peer 填 `DeploymentID=""`(`:3029`)落进 `BucketStats[bucket][""]`,而循环 1 按 `info.Sites` 的真实 ID 判缺失、循环 2 跳过 `id==""`,两条路径正交,语义正确。README:112-119 与实现完全一致。
+
+未关闭的两点见 NB-2、NB-3。另外,"仅报告确实存在、但仍无法排序的状态"(`:157`)这个条件写得准确:`!current.valid && (len(data)!=0 || !at.IsZero())` 恰好把 F9/F10 的世代冲突暴露出来,这是意外的正收益。
+
+### F5 —— 上一轮的最小性判断需要修正
+
+`canonicalBucketPolicy` 的六个生产接入点应分成三类,而不是笼统的"超出必要":
+
+| 接入点 | 性质 |
+|---|---|
+| `bucket-metadata-replication.go:191`(比较键) | **必需**,#77 的核心 |
+| `site-replication.go:3802`(状态键) | **必需**,F2 的修复本体 |
+| `bucket-policy-handlers.go:202`(GET)、`admin-bucket-handlers.go:438`(export)、`site-replication.go:1723`(peer apply) | **非可选**:这三处拿到的都是 `*policy.BucketPolicy`(`globalPolicySys.Get` / `GetBucketPolicy` / `admin-handlers-site-replication.go:228` 解析后传入),必须有编码器;而旧的 `json.Marshal` 对 `NotAction`/`NotResource` 语句**必然失败**——`BPStatement.Actions` 无 `omitempty`(`bucket-policy-statement.go:31`)+ `ActionSet.MarshalJSON` 空集报错(`actionset.go:144-148`)。bulk/import/peer 都能把这类策略落盘,所以缩回任何一处都会重新制造"写得进、读不出/复制不出" |
+| `bucket-policy-handlers.go:102`(PUT)、`admin-bucket-handlers.go:922`(import) | **可选的规范化**:比较键不依赖落盘字节。但因为解析器 `DisallowUnknownFields`,规范化**无信息丢失**(唯一差别是重复语句在盘上也被去重,语义等价),而所有读路径都已重新编码,用户观察不到"原样字节"。删掉它只换来与客户端提交字节的一致性,且会让本地写与 peer 写产生两种盘上形态 |
+
+**结论:不建议缩回任何一处。** 真正可删的只有 PUT/import 两处,收益为零、改动风险非零。上一轮把这部分列为"范围外扩张"的说法应当被本轮判断取代;它是一个被同一改动暴露出来的真实读回缺陷的修复,且已在 README:129-134 声明。
+
+---
+
+## 3. 新发现(全部非阻断)
+
+### NB-1(S3)恢复出的物理 Created 不确定,且可能晚于对端事件的来源时间
+
+- **位置**:`cmd/bucket-metadata-replication.go:277-290`;取值链 `cmd/peer-s3-client.go:324-328`(取第一个不报错的 peer)→ `cmd/peer-s3-server.go:207-258`(`cloneDrives` 遍历 **Go map**,取第一个不报错的盘)→ `cmd/xl-storage.go:1022-1028`(`Created = st.ModTime()`)。
+- **最短触发**:桶无 `.metadata.bin`;其顶层目录 mtime 因近期对象写入被推进到接近 now;对端发来源时间早于该 mtime 的事件 → `bucket-metadata-sys.go:198-201` 判 `before-created` → **返回 nil、不写、不报错**,仅一条每小时去重的 warning,直到有人在源站重新提交。
+- **影响**:(a) 恢复值不是桶的稳定属性(目录 mtime 随顶层条目增删前进,且逐盘不同);(b) 两个站点各自独立恢复时会落进不同世代。二者都落在已声明的 F9 限制内,且相对基线(基线给每个站点各自 `UTCNow()`)是改善;本地侧因 `metadata.lock` 串行化 + 首写落盘,站内不会分裂。真正新的只有"首个跨站事件可能被静默跳过"。
+- **最小修复(可选)**:让 `ensureBucketMetadataCreated` 返回"本次是否为恢复值",当 `sourceTime` 非零且早于恢复值时取 `min(recovered, sourceTime)`——"在时刻 T 已持有配置的桶必然在 T 已存在",约 4 行,不新增字段。或退一步:只在 README 里把恢复值明确为"物理近似值,可能晚于真实创建"。
+- **需补证据**:一个用例——缓存 `Created=0`、`os.Chtimes` 把桶目录设为 now、通过 admin 路由投递 `UpdatedAt = now-1h` 的 peer 事件,断言期望行为(写入或明确跳过),把当前语义钉死。
+
+### NB-2(S3)`!found` 时对畸形/不可排序状态完全静默
+
+- **位置**:`cmd/site-replication-metadata.go:134-139`。
+- **最短触发**:全部站点的同一字段都无法成为候选(例如各站点都存着一份 base64 可解但内容非法的 XML,或全部世代未知)。`latestBucketConfig`(`:117-120`)对 `err != nil` 静默 `continue`,`healBucketConfig` 直接 `return nil`——**没有任何诊断**。而状态导出侧的 `logInvalid`(`site-replication.go:3375-3378`)只覆盖 base64 失败和 policy/quota/replication 的解析失败,XML 类内容非法不在其中,于是这种状态在两条路径上都无声。
+- **影响**:字段永久不可 heal 且无信号。范围窄(要求所有站点同时不可用),但属于"必要诊断"缺口。
+- **最小修复**:把每目标的 `bucketConfigStateFromInfo` 结果算一遍并缓存(当前每周期每目标实际解码两次:`latestBucketConfig` 一次、主循环一次),把 `currentErr != nil` 的 `indeterminate` 移到 `if !found` 之前。既补诊断又减一次解码,不引入新框架。
+- **需补证据**:`healBucketConfig` 单测——两个站点都给非法载荷,断言返回 nil 且产生一条 `indeterminate`(可用现有 `srStatusInfo` 夹具,参照 `site-replication-metadata-heal_test.go:136-143`)。
+
+### NB-3(S4)`unreachable` 诊断按"桶 × 字段"扇出
+
+- **位置**:`cmd/site-replication-metadata.go:143-147`,被 `cmd/site-replication.go:4750-4760` 每桶调用六次。
+- **最短触发**:一个站点掉线。每桶产生 6 个不同 key → 10k 桶集群每小时约 6 万条 warning。
+- **影响**:只是噪声(`logOnceIf` 按小时清理,仍算"有界"),但一个站点级事实被放大成桶×字段级输出。
+- **最小修复**:把循环 1 上提到 `healBuckets` 里、每桶调用一次(key 去掉 `file` 段),或干脆每周期一次。改动 <15 行。
+- **需补证据**:不需要新证据,行数/键数可直接核对。
+
+### NB-4(S4,证据)两份旧日志是草稿测试产物,不能作为产品失败证据;设计记录"三个回归测试"应为两个
+
+- **位置**:`findings-before.log:3`、`findings-after-1.log:3` 均为 `site-replication-metadata-gate_test.go:230: real physical creation time unavailable`。该消息在最终测试文件中**不存在**,最终断言在 **227** 行、消息是 `bucket without a recorded creation time cannot update %s: %v`。同理 `findings-before.log:52` 失败在 SID 断言(`:353`),而最终文件 SID 断言在 `:355`、`:353` 是 `t.Fatal(err)`。
+- **判断**:这两份日志跑的是早期草稿;其中 "real physical creation time unavailable" 更像**测试自身的前置检查**失败,不能用来证明产品缺陷。真正成立的是 `round-2/confirmed-before.log`:它用**最终测试**叠加 `before-erasure-server-pool.go` / `before-site-replication.go`(我已核对这两份确实是修正前版本:无 `NoMetadata` 早返回、`MakeBucketHook` 无回落、`syncToAllPeers` 仍用 `bucketInfo.Created`、`isBktPolicyReplicated` 仍是 `prev.Equals`),行号 227/348 与最终文件精确吻合,失败文本是真实产品错误 `bucket metadata creation time is unknown` 与 `equivalent legacy and received policy reported as permanently mismatched`,两后端 × 六类 × missing 双态全覆盖;`confirmed-after.log` 通过。
+- **另一项**:设计记录第 166 行"三个回归测试在未修复代码上失败、修复后通过"不准确。仓库中只有**两个**这样的测试;F3(heal 诊断)**没有任何自动化回归**(我在全部 `*_test.go` 中检索 `unreachable`/`peer-error`/`unusable peer`/`logBucketConfigReplication`,无命中)。F3 的唯一证据是 `runtime-after-review.log:20` 的驱动自述"40 exceptional events produced exactly one log per reason"。
+- **最小修复**:把该行改为"两个回归测试 + 一次运行时观察",并在证据索引里把 `findings-before.log` / `findings-after-1.log` 标注为 superseded 草稿。
+- **需补证据(可选)**:给 F3 补一个日志断言用例(注入 logger target 或计数 hook),否则 F3 的修复在回归套件里是无保护的。
+
+### NB-5(S4)不要为死代码创造兼容理由
+
+`isBucketMetadataEqual`(`cmd/site-replication.go:5172-5181`)仅被 `site-replication_test.go:156`、`bucket-cors-site-replication_test.go:484` 引用。代码里没有编造理由(很好),但设计记录第 186 行写"作为上游血缘保留"是一个不成立的说法。**最小修复**:改成"目前仅测试引用,暂不删除以缩小 diff",或连同两处测试一并删除。
+
+### NB-6(S4)README 对恢复发生位置的描述偏保守
+
+`docs/site-replication/README.md:106-110` 说"That recovery happens on the write path … 第一次配置写入(本地或复制而来)会记录物理时间"。实际上 `AddPeerClusters` 的初次同步(`site-replication.go:2145` → `MakeBucketHook` → 本地 `PeerBucketMakeWithVersioningHandler`)也会在本地落盘该时间。这是**低估**不是夸大,但会误导运维判断"何时脱离跳过状态"。**最小修复**:补半句"或在把该桶纳入站点复制时"。
+
+### NB-7(S4)F10 只钉住了"来源"半边
+
+`cmd/site-replication-metadata_test.go:473-483` 断言接管把 Created 调晚后,越过的真实字段**不能成为来源**(`!state.candidate()`)。但上一轮指出的另一半——该字段作为 **heal 目标**时,`current.valid=false` 使任何合法 incoming 无条件胜出(`site-replication-metadata.go:174` + `bucket-metadata-replication.go:239-245`)——没有钉住。我确认这半边行为存在且在"远端世代胜出"语义下是自洽的,本地用户 PUT 也总能覆盖回来(`localBucketConfigUpdatedAt` 保证 `> Created`),**不是缺陷**,但期望值应当被固定。**最小修复**:同一子用例再加一次 `applyBucketConfig(&got, file, <远端载荷>, created+shift+1m)`,断言返回 `changed=true`。
+
+### NB-8(S4)恢复失败时的诊断丢掉了事件来源时间
+
+`cmd/bucket-metadata-sys.go:187` 传 `time.Time{}` 作为 `at`,即使本次是携带 `sourceTime` 的 peer 事件,日志里也显示 `0001-01-01`。**最小修复**:`sourceTime != nil` 时传 `*sourceTime`(一行)。
+
+---
+
+## 4. 证据分级
+
+| 证据 | 评价 |
+|---|---|
+| `round-2/confirmed-before.log` + `confirmed-after.log` + `before-*.go` 覆盖文件 | **足够**。最终测试 × 旧生产代码,行号与失败文本可逐条核对,F1/F2 为已证实的产品失败 |
+| `binary-identity-after-review.json` | **足够**。`silo-final --version` 含 `commit-id=62cf066ff…`,并附基线 `5c5765816` 对照二进制与双方 sha256,上一轮 F8-4 的缺口已闭合 |
+| `final-cmd-after-review.log`(cmd 全包 622s, EXIT=0)、`ci-lint-after.log`(0 issues, 品牌基线未变)、`ci-verify-after.log`、`ci-gen/internal/s3select/crosscompile-after.log` | **足够**作为"未引入回归"的门禁证据(只截尾行,可接受) |
+| `findings-before.log`、`findings-after-1.log` | **不可用**。草稿测试产物,行号与失败文本均与最终测试不符(见 NB-4) |
+| `findings-after.log` | 仅"ok",无信息量,被 `confirmed-after.log` 覆盖 |
+| `runtime-after-review.log` | **作者自述为主**。日志由作者的双站点驱动产生,断言("six field states equal""zero metadata RPCs""exactly one log per reason")都由驱动自己判定。时间戳 06:01:50 与最终二进制构建时间 06-01-35Z 吻合,身份链成立;但它是端到端观察,不是独立验证。F3 目前**只有**这一条证据 |
+
+---
+
+## 5. 设计记录(`bucket-metadata-convergence.zh.md`)的准确性
+
+总体克制,未发现把限制说成已修复的地方:§rollout(gate=off 不承诺四类删除收敛)、§limits-1/2/3(历史污染、零时间事件、桶世代冲突)都如实写成限制;第 20 行明确"不能用来证明现有下载包或线上实例已具备这些能力";第 161 行主动声明交叉编译 ≠ 六平台运行验收。需要修正的只有三处,均已在上面列出:
+
+1. 第 166 行"三个回归测试" → 两个回归测试 + 一次运行时观察(NB-4)。
+2. 第 186 行 `isBucketMetadataEqual`"上游血缘保留"是编造的理由(NB-5)。
+3. 第 181 行"与 `ListBuckets` 一致"成立(`ListBuckets` 在 `NoMetadata` 时同样不覆盖 Created),但第 185 行把 F5 记为"有意扩张,不是缺陷"仍沿用了上一轮的定性——按本轮判断,GET/export/peer 三处属于**必需**,建议改写,否则会给未来的人留下"这块可以缩回"的错误暗示。
+
+---
+
+## 6. 最小性 / 充分性 / 必要性(独立评价)
+
+**最小性:是。** 本轮修正净增很小,且每一处都能对应到一个已复现的失败或一次被证实的误读:`NoMetadata` 早返回 5 行、`MakeBucketHook` 回落 3 行、`syncToAllPeers` 恢复 4 行、`isBktPolicyReplicated` 换键约 10 行、heal 诊断重排约 15 行、`isReplicatedBucketConfig` 具名化(消除"用一次性零值结构体探测类型"的晦涩写法)、README 两段。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分——唯一"可删"的 `canonicalBucketPolicy` PUT/import 接入是上一轮遗留,删掉反而制造两种盘上形态。
+
+**充分性:对已声明契约充分,对两个边缘不充分且已如实声明。** 在"同一桶世代、Created 已知或可恢复、gate=on"范围内,六类配置的来源时间、锁范围、删除参与 heal、等时裁决、重复/乱序抑制、状态口径,我没有找到反例。不充分的两处都写进了文档:gate=off 下 Tags/SSE/Quota 漏发删除不收敛(F7);跨站点桶世代分歧不自动合并(F9,NB-1 属于它的子集)。F3 的静默面(NB-2)是本轮唯一一个"文档声明了、但我认为声明本身偏宽"的地方——"没有任何站点可供传播的字段就不产生诊断"对畸形配置而言不该成立。
+
+**必要性:是。** F1 与 F2 都有"最终测试 + 修正前生产代码"的复现(`confirmed-before.log`),是确证的产品失败,不是理论推演;F3 的必要性来自 `logOnceIf` 按 key+正文去重这一可验证机制,虽无自动化回归但代码级推理成立;F4/F8/F10 是文档与证据补齐,成本近零。F5 的编码器接入经本轮重新论证后,从"可争议扩张"变为"其中三处必需、两处无损",必要性判断上调。
+
+**处置建议(均非阻断,可合并后处理)**:NB-4 与 NB-5 是文字修正,建议合并前一并改掉(它们会影响后人对证据强度的判断);NB-2、NB-8 可合成一个约 20 行的提交;NB-1、NB-3、NB-6、NB-7 可进 backlog。
diff --git a/docs/investigations/issue-77/implementation-review/round-2/session.json b/docs/investigations/issue-77/implementation-review/round-2/session.json
new file mode 100644
index 000000000..65abff9b0
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-2/session.json
@@ -0,0 +1,49 @@
+{
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "head": "62cf066ff529c7d281703daa365f555cebba717a",
+ "prior_review_head": "4089113e3edbd21a29be8e6af74662462bcc22af",
+ "started_at": "2026-09-12T08:46:55.400524+00:00",
+ "status": "completed",
+ "completed_at": "2026-09-12T09:07:16.313535+00:00",
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 973153,
+ "num_turns": 71,
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "used_tools": {
+ "Read": 41,
+ "Grep": 24,
+ "Glob": 5
+ },
+ "stream_sha256": "63046ccfab9fb158178f43b09c75a9809a13ed1ff2da5a626e9e3c3e96c13d37",
+ "review_sha256": "306a856010f7f9bd1cbec39aba83236978f5a811c98f7fc3f8a08217f72de111",
+ "auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
+ "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",
+ "$LOCAL_EVIDENCE",
+ "--add-dir",
+ "$DOCS_WORKTREE/content/blog/design",
+ "--output-format",
+ "stream-json",
+ "--verbose"
+ ],
+ "command_note": "Same explicit CLI model/effort/read-only options as recorded in the tool invocation; round-2 had its own pinned worktree and prompt.",
+ "session_id": "8743aaa3-5c06-43db-8eba-08302d504c4e",
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/binary-identity.json b/docs/investigations/issue-77/implementation-review/round-3/binary-identity.json
new file mode 100644
index 000000000..87c82429b
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/binary-identity.json
@@ -0,0 +1,49 @@
+{
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "source_clean": true,
+ "built_at": "2026-09-12T09:12:38.105913+00:00",
+ "binaries": {
+ "silo-reviewed": {
+ "sha256": "4825a801ce0ac48d636d9428ce4cd5c17a20b6cfec0b569de70a124c9c005049",
+ "version": "silo-reviewed version DEVELOPMENT.2026-09-12T09-09-34Z (commit-id=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
+ "build_info_sha256": "91778f5f2844ada1234ed6a97d97fce7d87dc6334fdd9ef839fbeb7ebdb995e3",
+ "build_info_identity_lines": [
+ "\tpath\tgithub.com/minio/minio",
+ "\tmod\tgithub.com/minio/minio\tv0.0.0-20260912090934-fcbb93e89572\t",
+ "\tbuild\t-buildmode=exe",
+ "\tbuild\t-compiler=gc",
+ "\tbuild\t-trimpath=true",
+ "\tbuild\tCGO_ENABLED=0",
+ "\tbuild\tGOARCH=arm64",
+ "\tbuild\tGOOS=darwin",
+ "\tbuild\tGOARM64=v8.0",
+ "\tbuild\tvcs=git",
+ "\tbuild\tvcs.revision=fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "\tbuild\tvcs.time=2026-09-12T09:09:34Z",
+ "\tbuild\tvcs.modified=false"
+ ]
+ },
+ "silo-before": {
+ "sha256": "3b07b9604f2ca9f68f33b69f7807f57e0cc9b1efb0310bbf0aa7b3997e4483b9",
+ "version": "silo-before version DEVELOPMENT.2026-09-11T18-00-57Z (commit-id=5c576581631561c446f30ae5b566f0aa793adc1c)\nRuntime: go1.27.1 darwin/arm64\nLicense: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html\nCopyright: 2015-2025 MinIO, Inc.\nModifications: Copyright 2025-2026 PGSTY\nSource compatibility: based on MinIO technology",
+ "build_info_sha256": "84fb4540758d7dd56367069e3843a97d1e2c3528905db11d490b9654a2d6205d",
+ "build_info_identity_lines": [
+ "\tpath\tgithub.com/minio/minio",
+ "\tmod\tgithub.com/minio/minio\tv0.0.0-20260911180057-5c5765816315\t",
+ "\tbuild\t-buildmode=exe",
+ "\tbuild\t-compiler=gc",
+ "\tbuild\t-tags=kqueue",
+ "\tbuild\t-trimpath=true",
+ "\tbuild\tCGO_ENABLED=0",
+ "\tbuild\tGOARCH=arm64",
+ "\tbuild\tGOOS=darwin",
+ "\tbuild\tGOARM64=v8.0",
+ "\tbuild\tvcs=git",
+ "\tbuild\tvcs.revision=5c576581631561c446f30ae5b566f0aa793adc1c",
+ "\tbuild\tvcs.time=2026-09-11T18:00:57Z",
+ "\tbuild\tvcs.modified=false"
+ ]
+ }
+ },
+ "archive_note": "Dependency listing and machine paths omitted. Actual version, module identity, build settings and original build-info hash retained. Binaries are not included."
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/docs-check.json b/docs/investigations/issue-77/implementation-review/round-3/docs-check.json
new file mode 100644
index 000000000..d26a6a248
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/docs-check.json
@@ -0,0 +1,24 @@
+{
+ "command": [
+ "make",
+ "check"
+ ],
+ "cwd": "$DOCS_WORKTREE",
+ "exit_code": 0,
+ "checked_content_commit": "9fa6248a0323838df5aed678b42c4679a7a6f8a4",
+ "committed_after_check": true,
+ "recorded_at": "2026-09-12T09:30:37.223220+00:00",
+ "files_sha256": {
+ "content/blog/design/bucket-metadata-convergence.zh.md": "26807bf8db555c3f0feef5c933b3d5a2da48fc7fd5a65da00d2c9b93ce7b4b21",
+ "content/blog/design/bucket-metadata-convergence.md": "36bc5c7f2310d96447b8130db0f34a99e900d8f7fc6f33d90630b89085769eec"
+ },
+ "rendered_pages": {
+ "en": 1207,
+ "zh": 1219
+ },
+ "checks": [
+ "go mod verify",
+ "strict Hugo build",
+ "internal links"
+ ]
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/docs-check.log b/docs/investigations/issue-77/implementation-review/round-3/docs-check.log
new file mode 100644
index 000000000..b750ca341
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/docs-check.log
@@ -0,0 +1,20 @@
+go mod verify
+all modules verified
+hugo build --minify --cleanDestinationDir --printPathWarnings --printI18nWarnings --panicOnWarning
+Start building sites …
+hugo v0.165.0+extended+withdeploy darwin/arm64 BuildDate=2026-08-12T14:26:28Z VendorInfo=Homebrew
+
+
+ │ EN │ ZH
+──────────────────┼──────┼──────
+ Pages │ 1207 │ 1219
+ Paginator pages │ 2 │ 2
+ Non-page files │ 26 │ 0
+ Static files │ 740 │ 740
+ Processed images │ 8 │ 0
+ Aliases │ 45 │ 45
+ Cleaned │ 0 │ 0
+
+Total in 19619 ms
+python3 bin/check_internal_links.py public
+internal link check passed: 485471 rendered internal references across 1429 HTML files
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-build.json b/docs/investigations/issue-77/implementation-review/round-3/final-build.json
new file mode 100644
index 000000000..8afa00430
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-build.json
@@ -0,0 +1,16 @@
+{
+ "command": [
+ "go",
+ "build",
+ "./..."
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "started_at": "2026-09-12T09:11:30.915932+00:00",
+ "environment": {
+ "TMPDIR": "$TMPDIR",
+ "CGO_ENABLED": "0"
+ },
+ "exit_code": 0,
+ "duration_seconds": 24.193
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-build.log b/docs/investigations/issue-77/implementation-review/round-3/final-build.log
new file mode 100644
index 000000000..730877411
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-build.log
@@ -0,0 +1,3 @@
+{"command": ["go", "build", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.915932+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-cmd.json b/docs/investigations/issue-77/implementation-review/round-3/final-cmd.json
new file mode 100644
index 000000000..995dd285c
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-cmd.json
@@ -0,0 +1,20 @@
+{
+ "command": [
+ "go",
+ "test",
+ "./cmd/",
+ "-count=1",
+ "-timeout",
+ "30m"
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "started_at": "2026-09-12T09:11:30.892660+00:00",
+ "environment": {
+ "TMPDIR": "$LAB_VOLUME/tmp",
+ "CGO_ENABLED": "0",
+ "MINIO_API_REQUESTS_MAX": "10000"
+ },
+ "exit_code": 0,
+ "duration_seconds": 518.124
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-cmd.log b/docs/investigations/issue-77/implementation-review/round-3/final-cmd.log
new file mode 100644
index 000000000..0809947d7
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-cmd.log
@@ -0,0 +1,4 @@
+{"command": ["go", "test", "./cmd/", "-count=1", "-timeout", "30m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.892660+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "0", "MINIO_API_REQUESTS_MAX": "10000"}}
+ok github.com/minio/minio/cmd 492.776s
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-lint.json b/docs/investigations/issue-77/implementation-review/round-3/final-lint.json
new file mode 100644
index 000000000..3aa2f7b4e
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-lint.json
@@ -0,0 +1,14 @@
+{
+ "command": [
+ "make",
+ "lint"
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
+ "started_at": "2026-09-12T09:17:19.334284+00:00",
+ "environment": {
+ "TMPDIR": "$TMPDIR"
+ },
+ "exit_code": 0,
+ "duration_seconds": 79.064
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-lint.log b/docs/investigations/issue-77/implementation-review/round-3/final-lint.log
new file mode 100644
index 000000000..380862df5
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-lint.log
@@ -0,0 +1,6 @@
+{"command": ["make", "lint"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:17:19.334284+00:00", "environment": {"TMPDIR": "$TMPDIR"}}
+Running lint check
+0 issues.
+typos binary is not found.. skipping..
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-runtime.json b/docs/investigations/issue-77/implementation-review/round-3/final-runtime.json
new file mode 100644
index 000000000..3a81a6bfb
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-runtime.json
@@ -0,0 +1,18 @@
+{
+ "command": [
+ "go",
+ "run",
+ ".issue77-evidence/twosite/main.go",
+ "$LAB_VOLUME/runtime-reviewed",
+ ".issue77-evidence/silo-reviewed",
+ ".issue77-evidence/silo-before"
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "started_at": "2026-09-12T09:12:41.365111+00:00",
+ "environment": {
+ "TMPDIR": "$LAB_VOLUME/tmp"
+ },
+ "exit_code": 0,
+ "duration_seconds": 190.476
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-runtime.log b/docs/investigations/issue-77/implementation-review/round-3/final-runtime.log
new file mode 100644
index 000000000..08274f707
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-runtime.log
@@ -0,0 +1,28 @@
+{"command": ["go", "run", ".issue77-evidence/twosite/main.go", "$LAB_VOLUME/runtime-reviewed", ".issue77-evidence/silo-reviewed", ".issue77-evidence/silo-before"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:12:41.365111+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp"}}
+2026-09-12T09:12:42Z site-a ready gate=on
+2026-09-12T09:12:43Z site-b ready gate=on
+2026-09-12T09:12:44Z historical bucket prepared: six live fields at Created
+2026-09-12T09:12:44Z baseline-initial-sync: waiting for ordinary 30-second heal
+2026-09-12T09:12:44Z baseline-initial-sync: six field states equal
+2026-09-12T09:12:44Z initial sync preserved six historical Created timestamps
+2026-09-12T09:12:44Z real local PUT outgoing replication RPC dropped by partition
+2026-09-12T09:12:45Z live-reconnect: waiting for ordinary 30-second heal
+2026-09-12T09:13:13Z live-reconnect: six field states equal
+2026-09-12T09:13:13Z live-steady: observing two heal intervals
+2026-09-12T09:14:18Z live-steady: zero metadata RPCs across 65 seconds
+2026-09-12T09:14:18Z six duplicate/out-of-order events rejected without clock changes
+2026-09-12T09:14:19Z real local DELETE outgoing replication RPC dropped by partition
+2026-09-12T09:14:20Z site-a ready gate=on
+2026-09-12T09:14:20Z four tombstones survived process restart
+2026-09-12T09:14:20Z delete-reconnect: waiting for ordinary 30-second heal
+2026-09-12T09:14:44Z delete-reconnect: six field states equal
+2026-09-12T09:14:44Z delete-steady: observing two heal intervals
+2026-09-12T09:15:49Z delete-steady: zero metadata RPCs across 65 seconds
+2026-09-12T09:15:49Z 40 exceptional events produced exactly one log per reason
+2026-09-12T09:15:49Z starting fixed/previous-server rolling-upgrade smoke with gate off
+2026-09-12T09:15:50Z site-a ready gate=off
+2026-09-12T09:15:51Z site-b ready gate=off
+2026-09-12T09:15:51Z mixed-version ordinary PUT/DELETE and off tombstone visibility passed
+2026-09-12T09:15:51Z PASS: isolated two-site implementation acceptance
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.json b/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.json
new file mode 100644
index 000000000..6c006ad15
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.json
@@ -0,0 +1,22 @@
+{
+ "command": [
+ "go",
+ "test",
+ "-race",
+ "./cmd/",
+ "-run",
+ "^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$",
+ "-count=1",
+ "-timeout",
+ "10m"
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
+ "started_at": "2026-09-12T09:18:02.407600+00:00",
+ "environment": {
+ "TMPDIR": "$LAB_VOLUME/tmp",
+ "CGO_ENABLED": "1"
+ },
+ "exit_code": 0,
+ "duration_seconds": 45.681
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.log b/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.log
new file mode 100644
index 000000000..9f4ac4ed5
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-style-target-race.log
@@ -0,0 +1,4 @@
+{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics)$", "-count=1", "-timeout", "10m"], "cwd": "$SILO_WORKTREE", "head": "461e9a721047c63e1a95f54ad4b533a6b89def30", "started_at": "2026-09-12T09:18:02.407600+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
+ok github.com/minio/minio/cmd 4.540s
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-target-race.json b/docs/investigations/issue-77/implementation-review/round-3/final-target-race.json
new file mode 100644
index 000000000..c1c51d15e
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-target-race.json
@@ -0,0 +1,22 @@
+{
+ "command": [
+ "go",
+ "test",
+ "-race",
+ "./cmd/",
+ "-run",
+ "^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$",
+ "-count=1",
+ "-timeout",
+ "15m"
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "started_at": "2026-09-12T09:11:30.873027+00:00",
+ "environment": {
+ "TMPDIR": "$LAB_VOLUME/tmp",
+ "CGO_ENABLED": "1"
+ },
+ "exit_code": 0,
+ "duration_seconds": 90.202
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-target-race.log b/docs/investigations/issue-77/implementation-review/round-3/final-target-race.log
new file mode 100644
index 000000000..121837f13
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-target-race.log
@@ -0,0 +1,4 @@
+{"command": ["go", "test", "-race", "./cmd/", "-run", "^(TestBucketMetadataInitialSyncPhysicalCreated|TestPeerBucketMetadataPhysicalCreatedBoundary|TestPeerBucketMetadataUnknownCreated|TestPeerBucketAdoptionRebasesOnlyDefaults|TestHealBucketConfigDiagnostics|TestHealBucketConfigWithoutSourceDiagnostics|TestBucketMetadataPhysicalCreatedRecovery|TestBucketPolicyReplicationStatusLegacyOrder|TestHealBucketConfigSourceAndQuiescence|TestLatestBucketConfigCandidates|Test.*Cors.*|Test.*CORS.*)$", "-count=1", "-timeout", "15m"], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:30.873027+00:00", "environment": {"TMPDIR": "$LAB_VOLUME/tmp", "CGO_ENABLED": "1"}}
+ok github.com/minio/minio/cmd 20.903s
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-tree-equivalence.json b/docs/investigations/issue-77/implementation-review/round-3/final-tree-equivalence.json
new file mode 100644
index 000000000..d66daf8ae
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-tree-equivalence.json
@@ -0,0 +1,10 @@
+{
+ "reviewed_production_head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "final_head": "461e9a721047c63e1a95f54ad4b533a6b89def30",
+ "changed_files": [
+ "cmd/site-replication-metadata-gate_test.go",
+ "cmd/site-replication-metadata-heal_test.go"
+ ],
+ "difference": "Test-only gofumpt formatting and equivalent if-chain to switch; no production or server documentation changes",
+ "patch": "test-style.diff"
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-vet.json b/docs/investigations/issue-77/implementation-review/round-3/final-vet.json
new file mode 100644
index 000000000..61bc18876
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-vet.json
@@ -0,0 +1,16 @@
+{
+ "command": [
+ "go",
+ "vet",
+ "./..."
+ ],
+ "cwd": "$SILO_WORKTREE",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "started_at": "2026-09-12T09:11:55.197305+00:00",
+ "environment": {
+ "TMPDIR": "$TMPDIR",
+ "CGO_ENABLED": "0"
+ },
+ "exit_code": 0,
+ "duration_seconds": 4.024
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/final-vet.log b/docs/investigations/issue-77/implementation-review/round-3/final-vet.log
new file mode 100644
index 000000000..b36016764
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/final-vet.log
@@ -0,0 +1,3 @@
+{"command": ["go", "vet", "./..."], "cwd": "$SILO_WORKTREE", "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025", "started_at": "2026-09-12T09:11:55.197305+00:00", "environment": {"TMPDIR": "$TMPDIR", "CGO_ENABLED": "0"}}
+
+EXIT=0
diff --git a/docs/investigations/issue-77/implementation-review/round-3/opus5-max-review.md b/docs/investigations/issue-77/implementation-review/round-3/opus5-max-review.md
new file mode 100644
index 000000000..5d71c50d7
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/opus5-max-review.md
@@ -0,0 +1,111 @@
+已完成定向核对。以下为最终报告。
+
+---
+
+# SILO #77 第三轮独立对抗性验收(固定 `fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025`)
+
+**结论:GO_WITH_NONBLOCKING_NOTES — 阻断数 0**
+
+范围限定本轮差异(`round-3/change.diff`:2 个生产文件共约 12 行、5 个测试文件、1 个 README 段落)。未重复前两轮已完成的全量审查。
+
+---
+
+## 一、逐条 NB 判定
+
+### NB-2 — 关闭(三个边界经代码级核对成立)
+
+生产改动在 `cmd/site-replication-metadata.go:134-160`。逐边界挑战结果:
+
+1. **`unreachable` 仅在存在候选时输出 —— 行为字节级不变**。改前 `if !found { return nil }` 就在 unreachable 循环之前,因此该日志从来只在 `found==true` 时产生;把它包进 `if found {}`(`:138-144`)是纯重构,没有新增也没有删除任何一条 unreachable。这一点上一轮报告与提交说明都没讲清,值得记录:**这不是行为修改**。
+2. **空基线/缺桶安静 —— 由 `newBucketConfigState` 的取值规则保证**(`cmd/bucket-metadata-replication.go:224-227`):`at.IsZero() → at = created`。因此"字段时间为零但 `CreatedAt` 非零"的合法基线必然 `valid=true`,不触发 `:154`;缺桶(全零)`data` 空且 `at` 零,也不触发。只有三类会响:`created==0 且 at/data 非空`、`at < created`、解码/解析报错。与新测试四个子用例一一对应,无第四种漏网情形。
+3. **无有效来源不发 RPC、不落盘 —— 成立**。`!found` 在 `:159` 即 `continue`,位于 `:177`(本地 `updateAndParseMetadata`)与 `:182`(`SRPeerReplicateBucketMeta`)之前。测试用 `len(events()) != 0` 从对端侧反证(`heal_test.go:162-164`)。
+4. **去重 —— 成立**。`logOnceIf`(`internal/logger/logonce.go:95-120`)按 `id` **且**错误正文相同才抑制;key 为 `bucket-metadata///`,正文只含 reason,时间/peer 全在 ReqInfo 属性里(`site-replication-metadata.go:33-43`)。所以多 peer 同因塌缩为一条,跨因不互相顶替。测试的 `for range 2` 钉住了这一点。
+
+**必要性证据成立**:`round-2/no-source-before.log` 用同一份最终测试叠加旧生产码,`unknown-created`/`malformed`/`before-created` 三个子用例在 `heal_test.go:155` 报 `got 0 diagnostics, want 1`,`empty-baseline` 两侧都安静。行号与当前文件精确吻合,是真实的前后对照,不是草稿产物。
+
+**接受的边界(非阻断,已成文)**:在全站 `Created==0` 的遗留桶上(滚动升级的典型形态),每个"有值字段"现在每小时多出一条 `indeterminate`,此前完全静默。这正是 NB-2 要的可行动信号,README:123-125 与设计记录 §limits-5 都写明了。量级与 NB-3 已接受的 `unreachable` 同阶。
+
+**性能挑战结果:不构成问题**。`!found` 时主循环多做一遍 `bucketConfigStateFromInfo`,但空载荷在 `site-replication-metadata.go:75` 与 `bucket-metadata-replication.go:178-180` 两处短路,连 base64 都不解——而空载荷恰好是 `!found` 的主体人群。上一轮建议的"缓存解码结果"因此确实可以不做。
+
+### NB-8 — 关闭,nil/zero/local 三分支均无错误时间与 panic
+
+`cmd/bucket-metadata-sys.go:186-192`:
+
+- `sourceTime == nil` → `at` 为零值,打印 `0001-01-01`,与改前一致,无解引用;
+- `sourceTime != nil && IsZero()` → 同上,不会把零值当作有效来源时间;
+- `sourceTime` 非零且为本地时区 → `logBucketConfigReplication:38` 统一 `at.UTC().Format(...)`,不产生偏移时间;
+- `meta.Created`:该分支下必为零(`ensureBucketMetadataCreated:278-280` 在非零时提前返回 nil),所以 `created` 属性如实;
+- 去重不受影响:错误正文未变(见上)。
+
+**一处同类残留(本轮之前既有,非本轮引入)**:`site-replication-metadata.go:155` 在 `currentErr != nil` 时 `current` 是零值结构体,于是"解析失败"的日志同样丢掉了对端上报的真实 `at`。最小修复需要一个不经解码就取 `(bucket,file) → at` 的取值函数,约 10 行。可选,不必本轮做。
+
+### NB-4 / NB-5 — 关闭;全仓无漏引用,编译成立;有一项必须如实指出的门禁事实
+
+- **漏引用**:`isBucketMetadataEqual` 在 Go 代码中零引用,仅 `docs/site-replication/CORS-LWW-DESIGN.md:84` 提及,且位于 "Confirmed Failures in the **Pre-Fix** Candidate" 小节,是对既往缺陷的历史陈述,不是对现有代码的断言,无需改。
+- **编译**:两个被删用例所在文件的 `encoding/base64` 导入仍在被使用(`site-replication_test.go:21` → `:80/:106`;`bucket-cors-site-replication_test.go:22` → 20 余处),不存在第二次漏删。`round-3/final-build.log`、`final-vet.log` 在本轮固定 SHA 上 `go build ./...` / `go vet ./...` 均 `EXIT=0`。**当前树完整编译成立**。
+- **真实 CORS 路径保留**:被删的是纯 helper 单测;同一性质(base64 必须按字节严格比较)现由 `TestPeerBucketCorsRejectsNonCanonicalBase64`(`bucket-cors-site-replication_test.go:782-815`)经真实 `PeerBucketCorsConfigHandler` 与 legacy bulk 两条生产路径覆盖,并断言被拒后元数据未变;heal 侧用 `corsReplicationStateFromInfo`/`equalCORSReplicationStates`(`site-replication.go:4903-4904`)而非被删函数。**无覆盖损失。**
+- **logger 全局状态与 race**:`testlogger.T` 是进程级单例但用 atomic(`testlogger.go:49,78-85`),本身无 race;`logger.DisableLog` 是普通全局 bool(`logger.go:407` 读、`test-utils_test.go:105` 默认置 true),两个新用例都 `defer` 还原。`round-3/final-target-race.log` 在本轮固定 SHA 上对包含全部新用例的集合 `-race` 跑通(20.9s,EXIT=0)。**已验证,非阻断。**
+- **两项潜在脆弱点(无阻断、目前未触发,仅备案)**:(a) `DisableLog=false` 期间全局 sink 会捕获进程内**任何**系统日志,而 `heal_test.go:97` 对任何非预期行直接 `Fatalf`——若窗口内有后台 goroutine 输出日志即失败。最小加固:分类前先 `strings.Contains(line, bucket)` 过滤外来日志。(b) `logOnce.IDMap` 是进程级、每小时才清(`logonce.go:123-131`),这两个用例之所以安全,是因为 `ExecObjectLayerAPITest` 对两种后端各自 `getRandomBucketName()`(`test-utils_test.go:1556`,分别在 `:1771`/`:1801` 调用)使日志 key 天然唯一——这是隐式依赖,值得在注释里点一句。
+- **证据精度**:`round-2/confirmed-diagnostics-before.log` 在 `heal_test.go:80`(**第一条**断言)就 Fatal,因此它只证明了 F3 的"空基线必须安静"这一半;WARNING 级别与按因去重(`:99-105`)在旧码上并未被执行到,**没有**对应的失败演示。应如实这么写,不能说成"F3 已被完整回归保护"。
+
+- **必须指出的门禁事实**:`round-3/pre-style-lint.log` 显示本轮固定提交 `fcbb93e8` 的 `make lint` **失败(EXIT=2,3 issues)**,全部落在本轮新增的两个测试文件:`cmd/site-replication-metadata-heal_test.go:92:5`(gocritic ifElseChain)、`cmd/site-replication-metadata-gate_test.go:282:1` 与 `heal_test.go:138:1`(gofumpt)。我通读了修复补丁 `test-style.diff`:switch 分支顺序与函数体同 if-else 完全一致,另两处仅为结构体字面量换行,**语义等价、仅测试文件**,已在后续提交 `461e9a72` 修掉(该提交的 lint 复跑在证据中仍在进行)。结论:不影响行为验收,但"`fcbb93e8` 本身通过全部仓库门禁"这句话不成立,不应这么写。
+
+### NB-6 — 关闭为**出站 wire 证明**;不得称作双站点证明(已如实区分)
+
+`TestBucketMetadataInitialSyncPhysicalCreated`(`gate_test.go:245-303`)确实驱动真实源 ObjectLayer 走完整出站序列:`syncToAllPeers`(`site-replication.go:2141-2153`)→ `ensureBucketMetadataCreated` 从真实盘目录 mtime 恢复 → `MakeBucketHook`(`:821-826` 缓存零值回落)→ wire 上的 `createdAt` 参数;再经 `initialBucketConfigReplicationEvent` 发出 Tags 事件。断言 `createdAt == physical`、Tags 载荷为 base64(XML) 且 `UpdatedAt == physical`(历史字段以 Created 作源时间)。前后对照成立:`round-2/initial-sync-before.log` 在 `gate_test.go:289` 报 `"0001-01-01T00:00:00Z"`,行号与最终文件精确吻合。
+
+**必须如实标注的范围限制(我独立核出,不在提交说明里)**:该用例的 `c.state.Peers` 只含 `"initial-peer"`(`gate_test.go:282-283`),而 `concDo`(`site-replication.go:2497-2515`)只对 `depID == globalDeploymentID()` 的条目执行 `selfActionFn`。该条目不存在,因此真正把 `Created` 落到**本地盘**的 `PeerBucketMakeWithVersioningHandler`(`site-replication.go:954-966` 的 `SetCreatedAt` + `saveMetadata`)**在本用例中根本没有执行**。也就是说:
+
+- README:110-114 "Recovery happens … during initial site sync … records the physical time" 在生产路径上成立(经 `concDo` 自身分支),但**不被这个测试覆盖**;
+- 本用例证明的是出站内容,不是任何一侧的落盘,更不是双站点收敛。
+
+设计记录 §205 已经把这个区分写对了("它证明出站内容…二者不能混称为同一验收"),README 也没有声称测试覆盖,因此**不构成不实陈述**。若要补齐本地落盘那一半:在该 Peers map 中加入 `globalDeploymentID(): {}` 并在末尾加一次 `readBucketMetadata` 断言 `Created==physical`,约 3 行。
+
+### NB-7 — 关闭,两半都钉住了
+
+`site-replication-metadata_test.go:482-490`。沿 `applyBucketConfig`(`bucket-metadata-replication.go:294-316`)+ `compareBucketConfigStates`(`:239-245`)核对:`incoming.valid=true` 对 `local.valid=false` 直接返回 1,因此 `changed=true`,随后断言指针别名上的 `*at == incomingAt`、`*data == incoming`——载荷与时间都断言了。对 `bucketTaggingConfig` 这是"活标签覆盖被保留的旧世代删除记录",语义与注释一致;对 `bucketSSEConfig` 载荷恰好与原值相同,那里真正承载证明的是时间戳断言。两点说明:断言层级是 `applyBucketConfig`(即写路径 `bucket-metadata-sys.go:206` 调用的同一决策函数),属单元级钉桩而非端到端,这是可接受的取舍。
+
+### NB-1 — 判定为**如实保留边界**,本轮不存在必须修的回归
+
+本轮对 NB-1 **没有任何生产代码改动**(仅 README:106-114 + 新用例),因此按构造不可能引入回归。我独立复核了被保留的行为与被拒方案:
+
+- 保留跳过:`bucket-metadata-sys.go:202-205` 在 `updatedAt.Before(meta.Created)` 时记 `before-created` 并 `return nil`(不写、不报错);heal 侧同因在 `site-replication-metadata.go:162-164`。
+- 拒绝 `min(recovered, sourceTime)` 的理由我认为**成立**:单个事件的时间戳无法区分"本世代的较早事件"与"上一个已删除世代的事件",凭一个事件下调 `Created` 会让删除前的状态复活。这里选安全而非活性是对的,且具备自愈性(下一次 ≥ physical 的写入即确立身份)与可观测性(有界 warning)。
+- 新用例 `TestPeerBucketMetadataPhysicalCreatedBoundary`(`gate_test.go:305-335`)用真实盘 mtime 同时钉住两侧:`physical-1h` 的 peer 事件 `err==nil` 且 `bucketConfigWriteCounter` 计数为 **0**(是跳过不是失败);随后本地 `Update` 使盘上 `Created==physical` 且 `at > physical`。断言选得准确。
+- 残留(已成文):唯一持有者的源时间早于恢复值的字段,不经人工介入不会收敛;且跳过路径**不落盘恢复值**(`:204` 早于 `saveMetadata`),每次事件都重新探测 mtime——README:112-113 用 "first **successful** configuration write" 措辞正确地表达了这一点。
+
+### NB-3 — 接受现状,不要求扩展
+
+`site-replication-metadata.go:138-144` 仍按桶×字段×原因输出,`healBuckets` 每桶调用六次(`site-replication.go:4753-4760` → `:4851-4935`)。设计记录 §limits-5 已明确声明该量级会随桶与有值字段增长。未构成阻断,也未破坏任何既定契约,按约定不要求做站点级聚合/限流改造。
+
+**一处文档精度小瑕**:§limits-5 把"日志量随桶与字段增长"这句只挂在"不可达站点"上;本轮之后,遗留零 `Created` 集群里的 `indeterminate` 同样按桶×字段增长。加半句即可,非阻断。
+
+### F5 — 确认无外溢
+
+本轮 diff 中没有任何 `canonicalBucketPolicy` 接入点的增删;只有设计记录 F5 一行表述更新(`bucket-metadata-convergence.md:182` / `.zh.md:185`),与"上轮已修正原判断、不再改代码"一致。
+
+---
+
+## 二、确证问题清单(均非阻断)
+
+| # | 位置 | 触发条件 | 最小修复 |
+|---|---|---|---|
+| 1 | `cmd/site-replication-metadata-heal_test.go:92`、`:138`;`cmd/site-replication-metadata-gate_test.go:282` | 在固定提交 `fcbb93e8` 上执行 `make lint` | 已在 `461e9a72` 修掉(语义等价、仅测试文件);本轮 SHA 需注明 lint 未过 |
+| 2 | `cmd/site-replication-metadata.go:155` | heal 遇到解码/解析失败的对端状态 | 日志属性中 `sourceTime` 恒为 `0001-01-01`;需一个免解码的 `(bucket,file)→at` 取值函数,约 10 行。可选 |
+| 3 | `cmd/site-replication-metadata-heal_test.go:97` | 日志窗口内任一后台 goroutine 输出日志 | 分类前加 `strings.Contains(line, bucket)` 过滤,1 行 |
+| 4 | `cmd/site-replication-metadata-gate_test.go:282-283` | —(覆盖缺口,非缺陷) | 若要覆盖 README 的"初次同步落盘 Created"一半:Peers 加入 `globalDeploymentID()` 并补一次 `readBucketMetadata` 断言,约 3 行 |
+| 5 | 设计记录 §limits-5 | — | "随桶与字段增长"补上 `indeterminate` 这一类,半句 |
+
+**证据状态如实说明**:`round-3/final-cmd.log` 只有头行,**完整 `go test ./cmd/` 尚未产生结果**,不可称作已通过;已完成且可复核的是 `final-build`/`final-vet`(EXIT=0)、`final-target-race`(EXIT=0)、`pre-style-lint`(EXIT=2,见上)。`461e9a72` 的 `make lint` 复跑同样在进行中。
+
+---
+
+## 三、最小性 / 充分性 / 必要性(独立评价)
+
+**最小性:是,且是本系列迄今最克制的一轮。** 生产侧净变化只有两处共约 12 行:`found` 条件位移(未引入解码缓存、未新增结构体、未新增日志原因),以及诊断里一个已有指针的解引用。没有新 schema、新 wire 字段、新锁、新框架。我找不到可以无损删除的部分:删掉 `!found` 分支就退回 NB-2 的静默,删掉 `at` 就退回 NB-8。测试删除项(`isBucketMetadataEqual` 及其两个专属用例)经全仓核对确认无生产调用者、无覆盖损失。
+
+**充分性:对本轮声称的目标充分;两处覆盖如实低于文字。** 新增三个用例都有真实前后对照或真实后端驱动,`-race` 通过。低于文字的两处均已在上文点名:F3 的级别/去重断言在旧码上未被演示失败;初次同步的本地落盘半边未被测试覆盖(生产路径本身成立)。二者都是证据强度问题,不是行为问题,且设计记录没有把它们说过头。
+
+**必要性:是。** NB-2 与 NB-6 各有"最终测试 + 旧生产码"的复现日志且行号可逐条核对(`no-source-before.log`、`initial-sync-before.log`);NB-8 是一行可读性缺陷的直接消除;NB-5 是删除无调用者代码;NB-7 是补一侧期望值;NB-1/NB-3 明确不改代码,只做文档与钉桩。没有一项属于"顺手扩张"。
+
+**放行建议**:以 `461e9a72`(lint 修复后的等价树)为合并基准,并在合入前等 `go test ./cmd/` 与 `make lint` 两条复跑各自出结果;上表 5 项可全部进 backlog。
diff --git a/docs/investigations/issue-77/implementation-review/round-3/session.json b/docs/investigations/issue-77/implementation-review/round-3/session.json
new file mode 100644
index 000000000..50a298555
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/session.json
@@ -0,0 +1,49 @@
+{
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "head": "fcbb93e8957275bfa7ad4e6154e93f4d7b0a7025",
+ "base": "62cf066ff529c7d281703daa365f555cebba717a",
+ "status": "completed",
+ "started_at": "2026-09-12T09:10:48.496423+00:00",
+ "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",
+ "$LOCAL_EVIDENCE",
+ "--add-dir",
+ "$DOCS_WORKTREE/content/blog/design",
+ "--output-format",
+ "stream-json",
+ "--verbose"
+ ],
+ "completed_at": "2026-09-12T09:25:59.617894+00:00",
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 777403,
+ "num_turns": 61,
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "used_tools": {
+ "Read": 33,
+ "Grep": 23,
+ "Glob": 4
+ },
+ "stream_sha256": "a9a8784005abe5f02c1847e6d2a42890a309dbf34604a3756c456f1fc29c612a",
+ "review_sha256": "9106938a7a34cfbc6a11d372dd5ffb71ae7417a77a56d10bbed178f539de8fba",
+ "auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
+ "session_id": "3b2ce46c-226b-4c52-b004-c727303c4a9b",
+ "session_id_source": "system init event in the stream identified by stream_sha256"
+}
diff --git a/docs/investigations/issue-77/implementation-review/round-3/test-style.diff b/docs/investigations/issue-77/implementation-review/round-3/test-style.diff
new file mode 100644
index 000000000..70e5fa43b
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/round-3/test-style.diff
@@ -0,0 +1,53 @@
+diff --git a/cmd/site-replication-metadata-gate_test.go b/cmd/site-replication-metadata-gate_test.go
+index 49818b548..de9a37bb6 100644
+--- a/cmd/site-replication-metadata-gate_test.go
++++ b/cmd/site-replication-metadata-gate_test.go
+@@ -279,8 +279,10 @@ func TestBucketMetadataInitialSyncPhysicalCreated(t *testing.T) {
+ defer peer.Close()
+ // Exercise the complete outgoing sync sequence with real source
+ // storage. This peer acknowledges RPCs; it is not a second ObjectLayer.
+- c := &SiteReplicationSys{enabled: true, state: srState{ServiceAccountAccessKey: serviceCred.AccessKey,
+- Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}}}}
++ c := &SiteReplicationSys{enabled: true, state: srState{
++ ServiceAccountAccessKey: serviceCred.AccessKey,
++ Peers: map[string]madmin.PeerInfo{"initial-peer": {DeploymentID: "initial-peer", Endpoint: peer.URL}},
++ }}
+ if err := c.syncToAllPeers(ctx, madmin.SRAddOptions{}); err != nil {
+ t.Fatal(err)
+ }
+diff --git a/cmd/site-replication-metadata-heal_test.go b/cmd/site-replication-metadata-heal_test.go
+index bedb3ea5e..6c00fb8c1 100644
+--- a/cmd/site-replication-metadata-heal_test.go
++++ b/cmd/site-replication-metadata-heal_test.go
+@@ -89,11 +89,12 @@ func TestHealBucketConfigDiagnostics(t *testing.T) {
+ defer capture.mu.Unlock()
+ var unreachable, peerError int
+ for _, line := range capture.lines {
+- if strings.Contains(line, "bucket metadata replication: unreachable") {
++ switch {
++ case strings.Contains(line, "bucket metadata replication: unreachable"):
+ unreachable++
+- } else if strings.Contains(line, "bucket metadata replication: peer-error") {
++ case strings.Contains(line, "bucket metadata replication: peer-error"):
+ peerError++
+- } else {
++ default:
+ t.Fatalf("unexpected diagnostic: %s", line)
+ }
+ if !strings.HasPrefix(line, "WARNING:") {
+@@ -135,11 +136,13 @@ func TestHealBucketConfigWithoutSourceDiagnostics(t *testing.T) {
+ // and neither local storage nor the recording peer may be written.
+ name := bucket + "-" + tc.name
+ local := globalDeploymentID()
+- info := srStatusInfo{Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
++ info := srStatusInfo{
++ Sites: map[string]madmin.PeerInfo{local: {}, "metadata-peer": {}, "unreachable": {}},
+ BucketStats: map[string]map[string]srBucketStatsSummary{name: {
+ local: bucketConfigTestInfo(name, bucketTaggingConfig, tc.data, tc.at, tc.created),
+ "metadata-peer": {},
+- }}}
++ }},
++ }
+ for range 2 {
+ if err := globalSiteReplicationSys.healBucketConfig(t.Context(), name, bucketTaggingConfig, info); err != nil {
+ t.Fatal(err)
diff --git a/docs/investigations/issue-77/implementation-review/session.json b/docs/investigations/issue-77/implementation-review/session.json
new file mode 100644
index 000000000..6f1d9f115
--- /dev/null
+++ b/docs/investigations/issue-77/implementation-review/session.json
@@ -0,0 +1,40 @@
+{
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "cli_version": "2.1.258",
+ "base": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "head": "4089113e3edbd21a29be8e6af74662462bcc22af",
+ "started_at": "2026-09-12T04:02:26.329601+00:00",
+ "status": "completed",
+ "observed_model": "claude-opus-5",
+ "session_id": "86133939-ee59-4e4f-ad98-61f825c91899",
+ "permission_mode": "plan",
+ "tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "command": "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 $LOCAL_EVIDENCE --output-format stream-json --verbose",
+ "prompt_sha256": "09cea2c1356a2261e8cb9b0d4f2c947d0b683d00386267ebb340d487bdc66b4d",
+ "diff_sha256": "58703d70978fa25ea6b23eb6735ec94a6d369df3a776e5169a13c847f0d6b0eb",
+ "completed_at": "2026-09-12T04:35:09.936197+00:00",
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 1841224,
+ "num_turns": 93,
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "used_tools": {
+ "Read": 55,
+ "Grep": 31,
+ "Glob": 5,
+ "Write": 1
+ },
+ "stream_sha256": "90a262f5cc189575b2e8b458c8e5cd17274fb493261c35135d199bee4895de24",
+ "review_sha256": "e40dad6e1969898e35c82b7fd24517a5ee714d603a54fe3598b9e69c20922118",
+ "auxiliary_model_note": "CLI model_usage may include auxiliary calls. assistant_models identifies the actual review message model. max was explicitly passed in the recorded command.",
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json.",
+ "source_tree_equivalent_commit": "1ee64a8d8",
+ "tool_scope_note": "Configured Read/Grep/Glob. The CLI also attempted a Write to its own plan file; no production source was edited by the reviewer."
+}
diff --git a/docs/investigations/issue-77/issue77_heal_review_test.go.txt b/docs/investigations/issue-77/issue77_heal_review_test.go.txt
new file mode 100644
index 000000000..dbba44208
--- /dev/null
+++ b/docs/investigations/issue-77/issue77_heal_review_test.go.txt
@@ -0,0 +1,213 @@
+package cmd
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+)
+
+func TestIssue77CurrentHealInputs(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77HealInputs})
+}
+
+func review77HealInputs(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ ctx := t.Context()
+ localID, remoteID := globalDeploymentID(), "issue77-remote"
+ created := UTCNow().Add(-time.Hour)
+ at := created.Add(10 * time.Minute)
+ newerAt := at.Add(time.Minute)
+ enc := func(b []byte) *string { s := base64.StdEncoding.EncodeToString(b); return &s }
+ base := newBucketMetadata(bucket)
+ base.SetCreatedAt(created)
+ base.defaultTimestamps()
+ c := &SiteReplicationSys{enabled: true}
+ status := func(local, remote madmin.SRBucketInfo, mismatch madmin.SRBucketStatsSummary) srStatusInfo {
+ local.Bucket, remote.Bucket = bucket, bucket
+ return srStatusInfo{Sites: map[string]madmin.PeerInfo{localID: {Name: "local"}, remoteID: {Name: "remote"}}, BucketStats: map[string]map[string]srBucketStatsSummary{bucket: {localID: {SRBucketStatsSummary: mismatch, meta: srBucketMetaInfo{SRBucketInfo: local, DeploymentID: localID}}, remoteID: {meta: srBucketMetaInfo{SRBucketInfo: remote, DeploymentID: remoteID}}}}}
+ }
+ t.Run(backend+"/quota-tombstone-cache", func(t *testing.T) {
+ quota, _ := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
+ meta := base
+ meta.QuotaConfigJSON, meta.QuotaConfigUpdatedAt = quota, at
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ s := status(madmin.SRBucketInfo{CreatedAt: created, QuotaConfig: enc(quota), QuotaConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, QuotaConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{QuotaCfgMismatch: true})
+ if err := c.healBucketQuotaConfig(ctx, obj, bucket, s); err != nil {
+ t.Fatal(err)
+ }
+ cached, _, err := globalBucketMetadataSys.GetQuotaConfig(ctx, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ disk, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(disk.QuotaConfigJSON) != 0 {
+ t.Fatal("quota tombstone not written to disk")
+ }
+ if cached != nil && cached.Quota != 0 {
+ t.Errorf("QUOTA_CACHE: disk has no quota, cache still enforces %d", cached.Quota)
+ }
+ })
+ t.Run(backend+"/same-payload-time-barrier", func(t *testing.T) {
+ xml := []byte(`keysame`)
+ meta := base
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = xml, at
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ // Even forcing mismatch=true cannot make the existing heal advance a
+ // timestamp when the live payload bytes already match.
+ s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketStatsSummary{TagMismatch: true})
+ if err := c.healTagMetadata(ctx, obj, bucket, s); err != nil {
+ t.Fatal(err)
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !after.TaggingConfigUpdatedAt.Equal(newerAt) {
+ t.Errorf("BARRIER_NOT_HEALED: same payload remains at %s, latest is %s", after.TaggingConfigUpdatedAt, newerAt)
+ }
+ })
+ t.Run(backend+"/creation-default-selection", func(t *testing.T) {
+ policy := []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))
+ meta := base
+ meta.PolicyConfigJSON, meta.PolicyConfigUpdatedAt = policy, at
+ baselineAt := newerAt.Add(time.Hour)
+ s := status(madmin.SRBucketInfo{CreatedAt: created, Policy: policy, PolicyUpdatedAt: at}, madmin.SRBucketInfo{CreatedAt: baselineAt, PolicyUpdatedAt: baselineAt}, madmin.SRBucketStatsSummary{PolicyMismatch: true})
+ wrong := 0
+ for i := 0; i < 32; i++ {
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ if err := c.healBucketPolicies(ctx, obj, bucket, s); err != nil {
+ t.Fatal(err)
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(after.PolicyConfigJSON) == 0 {
+ wrong++
+ }
+ }
+ if wrong > 0 {
+ t.Errorf("DEFAULT_SELECTED: creation-default erased valid policy in %d/32 heal rounds", wrong)
+ }
+ })
+ t.Run(backend+"/tag-remote-source-time", func(t *testing.T) {
+ var got []madmin.SRBucketMeta
+ remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var item madmin.SRBucketMeta
+ if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
+ t.Error(err)
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ got = append(got, item)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer remote.Close()
+ svc, err := auth.CreateCredentials("issue77-heal-service", "issue77-heal-service-secret")
+ if err != nil {
+ t.Fatal(err)
+ }
+ svc.ParentUser = cred.AccessKey
+ if _, err = globalIAMSys.store.AddServiceAccount(ctx, svc); err != nil {
+ t.Fatal(err)
+ }
+ defer globalIAMSys.DeleteServiceAccount(ctx, svc.AccessKey, false)
+ peers := map[string]madmin.PeerInfo{localID: {Name: "local", DeploymentID: localID}, remoteID: {Name: "remote", DeploymentID: remoteID, Endpoint: remote.URL}}
+ remoteClient := &SiteReplicationSys{enabled: true, state: srState{ServiceAccountAccessKey: svc.AccessKey, Peers: peers}}
+ xml := []byte(`keysame`)
+ s := status(madmin.SRBucketInfo{CreatedAt: created, Tags: enc(xml), TagConfigUpdatedAt: newerAt}, madmin.SRBucketInfo{CreatedAt: created}, madmin.SRBucketStatsSummary{})
+ v := s.BucketStats[bucket][remoteID]
+ v.TagMismatch = true
+ s.BucketStats[bucket][remoteID] = v
+ if err := remoteClient.healTagMetadata(ctx, obj, bucket, s); err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("remote received %d events", len(got))
+ }
+ if !got[0].UpdatedAt.Equal(newerAt) {
+ t.Errorf("TAG_WIRE_TIME: sent %s, want %s", got[0].UpdatedAt, newerAt)
+ }
+ })
+}
+
+func TestIssue77CurrentPeerCheckBeforeLock(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, _ auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ oldAt, newAt := meta.Created.Add(time.Hour), meta.Created.Add(2*time.Hour)
+ oldXML := base64.StdEncoding.EncodeToString([]byte(`keyolder`))
+ newXML := []byte(`keynewest`)
+ lockCtx, unlock, err := lockBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ locked := true
+ defer func() {
+ if locked {
+ unlock()
+ }
+ }()
+ ready := make(chan struct{}, 1)
+ hook := func(name string) {
+ if name == bucket {
+ select {
+ case ready <- struct{}{}:
+ default:
+ }
+ }
+ }
+ lockBucketMetadataAcquireHook.Store(&hook)
+ defer lockBucketMetadataAcquireHook.Store(nil)
+ done := make(chan error, 1)
+ go func() { done <- globalSiteReplicationSys.PeerBucketTaggingHandler(ctx, bucket, &oldXML, oldAt) }()
+ select {
+ case <-ready:
+ case <-time.After(5 * time.Second):
+ t.Fatal("older event never reached metadata lock")
+ }
+ // A newer writer commits while holding the existing metadata lock.
+ // The old peer event has already checked the pre-commit cache.
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = newXML, newAt
+ if err := globalBucketMetadataSys.saveMetadata(lockCtx, obj, meta); err != nil {
+ t.Fatal(err)
+ }
+ unlock()
+ locked = false
+ select {
+ case err := <-done:
+ if err != nil {
+ t.Fatal(err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("older event did not finish")
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after.TaggingConfigXML) != string(newXML) {
+ t.Errorf("CHECK_OUTSIDE_LOCK: queued older event replaced newer committed tags with %q", after.TaggingConfigXML)
+ }
+ })
+ }})
+}
diff --git a/docs/investigations/issue-77/issue77_review_test.go.txt b/docs/investigations/issue-77/issue77_review_test.go.txt
new file mode 100644
index 000000000..fd1a0a041
--- /dev/null
+++ b/docs/investigations/issue-77/issue77_review_test.go.txt
@@ -0,0 +1,147 @@
+package cmd
+
+// Review-only tests against the unchanged 2026-09-12 main. Failures are evidence,
+// not a production regression-test submission.
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ "github.com/minio/minio/internal/auth"
+)
+
+func TestIssue77CurrentSourceTimeAndDeletion(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: review77SourceTimeAndDeletion})
+}
+
+func review77SourceTimeAndDeletion(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ ctx := t.Context()
+ created := UTCNow().Add(-time.Hour)
+ putAt := created.Add(10 * time.Minute)
+ delAt := putAt.Add(time.Minute)
+ enc := func(s string) *string { v := base64.StdEncoding.EncodeToString([]byte(s)); return &v }
+ base := newBucketMetadata(bucket)
+ base.SetCreatedAt(created)
+ base.defaultTimestamps()
+ quotaJSON, err := json.Marshal(madmin.BucketQuota{Quota: 1024, Type: madmin.HardQuota})
+ if err != nil {
+ t.Fatal(err)
+ }
+ cases := []struct {
+ name, file string
+ put madmin.SRBucketMeta
+ value func(BucketMetadata) []byte
+ stamp func(BucketMetadata) time.Time
+ exported func(madmin.SRBucketInfo) time.Time
+ deletable bool
+ }{
+ {"policy", bucketPolicyConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypePolicy, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, bucket))}, func(m BucketMetadata) []byte { return m.PolicyConfigJSON }, func(m BucketMetadata) time.Time { return m.PolicyConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.PolicyUpdatedAt }, true},
+ {"tags", bucketTaggingConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeTags, Tags: enc(`keyold`)}, func(m BucketMetadata) []byte { return m.TaggingConfigXML }, func(m BucketMetadata) time.Time { return m.TaggingConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.TagConfigUpdatedAt }, true},
+ {"sse", bucketSSEConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeSSEConfig, SSEConfig: enc(`AES256`)}, func(m BucketMetadata) []byte { return m.EncryptionConfigXML }, func(m BucketMetadata) time.Time { return m.EncryptionConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.SSEConfigUpdatedAt }, true},
+ {"quota", bucketQuotaConfigFile, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeQuotaConfig, Quota: quotaJSON}, func(m BucketMetadata) []byte { return m.QuotaConfigJSON }, func(m BucketMetadata) time.Time { return m.QuotaConfigUpdatedAt }, func(m madmin.SRBucketInfo) time.Time { return m.QuotaConfigUpdatedAt }, true},
+ {"versioning", bucketVersioningConfig, madmin.SRBucketMeta{Type: madmin.SRBucketMetaTypeVersionConfig, Versioning: enc(`Enabled`)}, func(m BucketMetadata) []byte { return m.VersioningConfigXML }, func(m BucketMetadata) time.Time { return m.VersioningConfigUpdatedAt }, nil, false},
+ {"objectlock", objectLockConfig, newSRBucketObjectLockMeta(bucket, enc(`EnabledGOVERNANCE30`), putAt), func(m BucketMetadata) []byte { return m.ObjectLockConfigXML }, func(m BucketMetadata) time.Time { return m.ObjectLockConfigUpdatedAt }, nil, false},
+ }
+ exporter := &SiteReplicationSys{enabled: true}
+ for _, tc := range cases {
+ t.Run(backend+"/"+tc.name, func(t *testing.T) {
+ if err := globalBucketMetadataSys.save(ctx, base); err != nil {
+ t.Fatal(err)
+ }
+ item := tc.put
+ item.Bucket, item.UpdatedAt = bucket, putAt
+ apply := func(item madmin.SRBucketMeta) {
+ t.Helper()
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("admin apply returned %d: %s", rec.Code, rec.Body.String())
+ }
+ }
+ read := func() BucketMetadata {
+ t.Helper()
+ m, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return m
+ }
+ apply(item)
+ put := read()
+ if len(tc.value(put)) == 0 {
+ t.Fatal("PUT did not establish live config")
+ }
+ if !tc.stamp(put).Equal(putAt) {
+ t.Errorf("SOURCE_TIME: persisted %s, want source %s", tc.stamp(put), putAt)
+ }
+ apply(madmin.SRBucketMeta{Type: item.Type, Bucket: bucket, UpdatedAt: delAt})
+ after := read()
+ if !tc.deletable {
+ if !bytes.Equal(tc.value(put), tc.value(after)) || !tc.stamp(put).Equal(tc.stamp(after)) {
+ t.Error("NIL_NOOP: update-only config changed")
+ }
+ t.Log("nil payload is correctly a no-op")
+ return
+ }
+ if len(tc.value(after)) != 0 {
+ t.Error("NEWER_DELETE: HTTP 200, but live config remained after newer source DELETE")
+ }
+ if _, err := globalBucketMetadataSys.Delete(ctx, bucket, tc.file); err != nil {
+ t.Fatal(err)
+ }
+ tombstone := read()
+ if len(tc.value(tombstone)) != 0 {
+ t.Fatal("local DELETE failed to establish tombstone")
+ }
+ info, err := exporter.SiteReplicationMetaInfo(ctx, obj, madmin.SRStatusOptions{Entity: madmin.SRBucketEntity, EntityValue: bucket})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !tc.exported(info.Buckets[bucket]).Equal(tc.stamp(tombstone)) {
+ t.Errorf("TOMBSTONE_EXPORT: got %s, want %s", tc.exported(info.Buckets[bucket]), tc.stamp(tombstone))
+ }
+ apply(item)
+ after = read()
+ if len(tc.value(after)) != 0 {
+ t.Error("STALE_RESURRECTION: older source PUT resurrected a locally deleted config")
+ } else {
+ t.Log("older source PUT did not resurrect config")
+ }
+ })
+ }
+}
+
+func TestIssue77CurrentBulkApplyOrdering(t *testing.T) {
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t: t, objAPITest: func(obj ObjectLayer, backend, bucket string, _ http.Handler, cred auth.Credentials, t *testing.T) {
+ t.Run(backend, func(t *testing.T) {
+ ctx := t.Context()
+ meta, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ newAt := meta.Created.Add(time.Hour)
+ newXML := `keynew`
+ meta.TaggingConfigXML, meta.TaggingConfigUpdatedAt = []byte(newXML), newAt
+ if err := globalBucketMetadataSys.save(ctx, meta); err != nil {
+ t.Fatal(err)
+ }
+ oldXML := base64.StdEncoding.EncodeToString([]byte(`keyold`))
+ item := madmin.SRBucketMeta{Bucket: bucket, Tags: &oldXML, UpdatedAt: newAt.Add(-time.Minute)}
+ rec := applySRBucketMetaViaAdmin(t, cred, item)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("bulk apply returned %d: %s", rec.Code, rec.Body.String())
+ }
+ after, err := loadBucketMetadata(ctx, obj, bucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(after.TaggingConfigXML, []byte(newXML)) || !after.TaggingConfigUpdatedAt.Equal(newAt) {
+ t.Errorf("BULK_STALE_OVERWRITE: older bulk event overwrote newer config, value=%q time=%s", after.TaggingConfigXML, after.TaggingConfigUpdatedAt)
+ }
+ })
+ }})
+}
diff --git a/docs/investigations/issue-77/review/adoption-baseline-probe.go.txt b/docs/investigations/issue-77/review/adoption-baseline-probe.go.txt
new file mode 100644
index 000000000..fb2df8493
--- /dev/null
+++ b/docs/investigations/issue-77/review/adoption-baseline-probe.go.txt
@@ -0,0 +1,20 @@
+package cmd
+import (
+ "net/http"
+ "testing"
+ "time"
+ "github.com/minio/minio/internal/auth"
+)
+func TestIssue77PlanAdoptionBaseline(t *testing.T) {
+ defer DetectTestLeak(t)()
+ ExecObjectLayerAPITest(ExecObjectLayerAPITestArgs{t:t,objAPITest:func(_ ObjectLayer, instanceType,bucket string,_ http.Handler,_ auth.Credentials,t *testing.T){
+ before,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
+ if len(before.PolicyConfigJSON)!=0 || !before.PolicyConfigUpdatedAt.Equal(before.Created) {t.Fatalf("unexpected initial state: %#v",before)}
+ olderCreated:=before.Created.Add(-time.Hour)
+ if err:=globalSiteReplicationSys.PeerBucketMakeWithVersioningHandler(t.Context(),bucket,MakeBucketOptions{CreatedAt:olderCreated});err!=nil{t.Fatal(err)}
+ after,err:=globalBucketMetadataSys.GetConfigFromDisk(t.Context(),bucket);if err!=nil{t.Fatal(err)}
+ falseTombstone:=len(after.PolicyConfigJSON)==0 && after.PolicyConfigUpdatedAt.After(after.Created)
+ t.Logf("%s ADOPTION_BASELINE: previous Created=%s field=%s; after Created=%s field=%s; nil with field>Created=%v",instanceType,before.Created.Format(time.RFC3339Nano),before.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),after.Created.Format(time.RFC3339Nano),after.PolicyConfigUpdatedAt.Format(time.RFC3339Nano),falseTombstone)
+ if !falseTombstone {t.Fatal("expected to reproduce default timestamp becoming an apparent tombstone")}
+ }})
+}
diff --git a/docs/investigations/issue-77/review/adoption-baseline-probe.log b/docs/investigations/issue-77/review/adoption-baseline-probe.log
new file mode 100644
index 000000000..a0c8e1551
--- /dev/null
+++ b/docs/investigations/issue-77/review/adoption-baseline-probe.log
@@ -0,0 +1,6 @@
+=== RUN TestIssue77PlanAdoptionBaseline
+ issue77_plan_adoption_test.go:17: ErasureSD ADOPTION_BASELINE: previous Created=2026-09-12T09:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; after Created=2026-09-12T08:49:46.959131+08:00 field=2026-09-12T09:49:46.959131+08:00; nil with field>Created=true
+ issue77_plan_adoption_test.go:17: Erasure ADOPTION_BASELINE: previous Created=2026-09-12T09:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; after Created=2026-09-12T08:49:47.012956+08:00 field=2026-09-12T09:49:47.012956+08:00; nil with field>Created=true
+--- PASS: TestIssue77PlanAdoptionBaseline (0.13s)
+PASS
+ok github.com/minio/minio/cmd 1.458s
diff --git a/docs/investigations/issue-77/review/adoption-baseline-probe.metadata.json b/docs/investigations/issue-77/review/adoption-baseline-probe.metadata.json
new file mode 100644
index 000000000..c58157569
--- /dev/null
+++ b/docs/investigations/issue-77/review/adoption-baseline-probe.metadata.json
@@ -0,0 +1,10 @@
+{
+ "worktree": "$TMPDIR",
+ "source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "scope": "diagnostic of existing code only; no product implementation",
+ "command": "GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v",
+ "exit_code": 0,
+ "outcome": "Both ObjectLayers reproduced a nil default state becoming an apparent tombstone after Created moved earlier; no product fix implemented.",
+ "temporary_worktree_removed": true,
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/review/author-followup-findings.md b/docs/investigations/issue-77/review/author-followup-findings.md
new file mode 100644
index 000000000..e2d14da05
--- /dev/null
+++ b/docs/investigations/issue-77/review/author-followup-findings.md
@@ -0,0 +1,9 @@
+# 作者补充核验
+
+S1:`internal/logger/logonce.go:95` 按错误正文而非只按 key 去重。动态时间进入正文会绕过去重。修订要求:稳定的原因错误,动态详情放 logger ReqInfo,沿用现有每小时清理机制;不新增日志/限频框架。
+
+S2:当前 silo-pkg 的 ActionSet.MarshalJSON 与 ResourceSet.MarshalJSON 直接枚举 map。实测同一个已解析 BucketPolicy 连续 json.Marshal 100 次,得到 4 种字节序列。源代码探针为 policy-encoding-probe.go.txt;使用项目 go.mod 选择的 pgsty/silo-pkg/v3 v3.13.4-0.20260910091716-2d8fd3cbbf07,命令为 GOWORK=off go run <探针临时路径>。
+
+因此普通 json.Marshal 不能当作稳定排序键。v2 需要一个仅用于 Server 桶策略比较的确定性表示,对已通过现有解析器校验的策略排序集合成员;不能为了这一点修改依赖或持久化 wire 格式。
+
+以上为作者在外部 v1 评审期间做的独立补查,不冒充 Claude 的评审发现,也不代表已经实现修复。
diff --git a/docs/investigations/issue-77/review/decisions-v2.md b/docs/investigations/issue-77/review/decisions-v2.md
new file mode 100644
index 000000000..699419036
--- /dev/null
+++ b/docs/investigations/issue-77/review/decisions-v2.md
@@ -0,0 +1,32 @@
+# v1 对抗评审意见与 v2 处置
+
+评审者:真实 Claude Code 2.1.258,审查消息模型为 `claude-opus-5`,CLI 明确使用 `--effort max`。首轮对 main `5c576581631561c446f30ae5b566f0aa793adc1c` 的冻结 v1 给出 **NO_GO**,耗时约 24 分钟。完整输出见 [首轮评审](opus5-max-v1.md),调用证据见 [元数据](opus5-max-v1.metadata.json)。
+
+本文件记录作者核对源码后的处置,不能代替评审者的后续结论。v2 尚未实现。
+
+| ID | 处置 | v2 中的具体变化与理由 |
+| --- | --- | --- |
+| R1 / P1 | 接受 | 选择其方案 b:保留合法零 quota 文档,取消普通 quota PUT 的出站 nil 改写。补充落盘/事件状态三元组一致性。Policy 保留既有 peer 空策略=删除语义,本地/导入/bulk 也归一成删除;说明空策略本地 GET 行为变化。导入空 Policy 用现有专用 nil 事件,避免 bulk omitempty 漏发。 |
+| R2 / P1 | 接受,缩小实现方式 | 比较有效归一状态,复用已有 Object Lock→Versioning 规则;bulk 按最终接受的 Object Lock 处理 Versioning。saveMetadata 用指针让调用方保留 Save 后快照;公共 Update/Delete 签名保持不变,需要发送归一载荷的本地路径使用内部结果。写明同时间 live 键较大者胜,导入 hook 来自提交快照。 |
+| R3 / P1 | 接受“不得缺省即删除”的要求;纠正部分依据 | RawMessage 的 nil/空切片确实被 omitempty 省略,但显式 JSON null 解码为非 nil 的字节 `null`;非空 JSON 的空策略也可明确表达。实际 madmin-go/Go 编解码探针已验证。因此不采纳“bulk Policy 不可能表达语义删除、只有 Tags/SSE 可以”的绝对结论。v2 列出真实 wire 判定:未提供保留、Policy 提供但语义空则删除、Quota 提供的零值/null 仍为 live,Tags/SSE 显式空字符串删除;T5 经真实 marshal/unmarshal 验证。 |
+| R4 / P2 | 接受 | 选源和 apply 都使用 <= Created 的 baseline 判定;候选不晚于目标 Created 时逐目标跳过并限频诊断。独立创建世代冲突需运维,不纳入 T9 自动收敛。 |
+| R5 / P2 | 接受但保留兼容例外 | 三种内部状态明确化,非零 baseline 事件不覆盖真实配置。零时间专用事件依 O1 保留兼容应用,它是明确例外;修复后的正常发送端不得产生该状态。 |
+| R6 / P2 | 接受 | 比较视图统一把零字段时间回退到 Created,bulk 原始读取与 load/defaultTimestamps 不再得到不同裁决。 |
+| R7 / P2 | 接受 | 六类 heal 的源/目标循环跳过 unknown/空 deployment ID;单个 peer 失败继续健康目标,覆盖可达站点不被占位项阻断。 |
+| R8 / P2 | 接受 | 明确四类清空走现有 parse=false 新对象;bulk 原始读取后保存。不改 Quota getter 或无关解析分支;保留磁盘/缓存/重载回归。 |
+| R9 / P2 | 接受 | 显式保留 Object Lock 载荷在 legacy Tags 字段时的回退与排序测试。 |
+| R10 / P2 | 接受 | 升级文档点名旧 Tag heal 无 UpdatedAt,说明其到达时间/legacy-zero 污染不能自动修复。 |
+| O1 / 缩减 | 接受 | 删除 on 模式拒绝零时间的新行为;两种模式统一兼容。开关改名 METADATA_TOMBSTONES,只控制新增墓碑导出/初次同步。 |
+| O2 / 缩减 | 接受 | 沿用现有 env.Get 的 on/off 写法,不加配置框架。 |
+| O3 / 缩减 | 接受 | 旧/新真实进程只作一次性升级冒烟;长期回归用真实 wire/SRInfo 模拟。保留修复版双站点实验以验证实际最终收敛。 |
+| O4 / 缩减 | 接受 | 明确复用现有 lockBucketMetadataAcquireHook / RMW 屏障,不新增产品测试钩子。 |
+| O5 / 缩减 | 接受 | 删除无行为意义的 deployment ID 日志裁决要求;它不参与持久化排序。 |
+| O6 / 缩减 | 部分接受 | 删除不必要的 stale-conflict 警告,剩三类。保留 indeterminate 并明确真实触发条件:未知 Created、不可用来源、有实质差异却无合法候选;它不等于 before-created。 |
+
+## 作者补充核验
+
+- **S1:日志去重。** `internal/logger/logonce.go:95-123` 同时依赖稳定 key 和错误正文,变化时间放正文会导致每轮记录。v2 使用固定原因错误,动态详情放 ReqInfo,不新增框架。
+- **S2:Policy JSON 编码。** 当前 silo-pkg 的 ActionSet/ResourceSet MarshalJSON 枚举 map。同一个已解析策略编码 100 次得到 4 种结果,因此 v1 的普通 JSON 比较键不稳定。v2 增加 Server 内的策略比较键,对已校验策略的集合数组/对象键排序,不改变 wire 或依赖。探针见 [policy-encoding-probe.go.txt](policy-encoding-probe.go.txt)。
+- **S3:R3 的实际 wire 边界。** 使用项目选定的 madmin-go v3.0.110 与 silo-pkg 执行真实编解码,确认显式 null 与 omitted 不同,带 Version 的空 Statement 策略通过现有解析且 IsEmpty=true。源码和输出见 [wire-state-probe.go.txt](wire-state-probe.go.txt)、[wire-state-probe.log](wire-state-probe.log)。`cmd/bucket-quota.go:99-110` 证实 Quota null 由现有解析器接受为零值对象;不把它改成删除。
+
+以上均为方案核查和小型诊断。没有修改产品源文件,没有把首轮 NO_GO 改写为通过;后续复审针对冻结 v2 独立给出结论。
diff --git a/docs/investigations/issue-77/review/decisions-v3.md b/docs/investigations/issue-77/review/decisions-v3.md
new file mode 100644
index 000000000..b7fca1b9a
--- /dev/null
+++ b/docs/investigations/issue-77/review/decisions-v3.md
@@ -0,0 +1,31 @@
+# v2 对抗复审意见与 v3 处置
+
+第二轮由相同 Claude Code / `claude-opus-5 --effort max` 执行,仅有 Read/Grep/Glob 工具,耗时约 11 分钟。结论 **NO_GO,1 个新增阻断**;明确确认首轮 R1/R2/R3 已全部关闭,并承认首轮 R3 关于 RawMessage 的部分依据错误。见 [第二轮原文](opus5-max-v2.md)、[调用证据](opus5-max-v2.metadata.json)。
+
+## P1-1:历史桶字段时间等于 Created
+
+接受阻断。源码核对确认历史配置可合法带 `UpdatedAt == Created`,MakeBucketHook 还会把同一 Created 传给新站点。v2 把等号也拒绝,会阻断初次同步,并因 heal 过滤 baseline 而无法补救。
+
+对评审中的小处事实作校正:当前 `applyLegacyConfigs` 末尾已经调用 `defaultTimestamps()`,因此“迁移后磁盘字段仍为零”不普遍成立;**字段等于 Created 的反例成立,不影响阻断判断**。
+
+v3 的最小修订是保留 baseline 的弱初值语义,不引入新 wire 字段或重盖到达时间:
+
+| 输入/目标 | v3 行为 |
+| --- | --- |
+| 历史 live@Created → 空 baseline | 接受初始化,保存相同源时间;初次同步和 heal 一致 |
+| baseline-live → baseline-live | 稳定比较键较大者胜;全 baseline-live 也能收敛 |
+| baseline-live → 真实 live/tombstone | 不覆盖;真实状态优先级先于时间比较 |
+| 空/nil@Created → 任意已有配置 | 不能作为删除,不清空 |
+| 非零时间严格早于目标 Created | 原有创建保护,成功/no-op 与有界诊断 |
+| 全为空 baseline | found=false,安静不写入 |
+
+T7/T8 新增上述用例,特别是历史桶从初次同步到完整一轮 heal 的验收;六类类型约束、删除 parse=false、旧事件不能复活 tombstone 均保留。
+
+## 四条 P2
+
+1. **Policy 稳定键:接受目标,不叠加第二种判等。** v3 明确对已解析策略完整 JSON 树做递归排序,覆盖 NotAction/NotResource 与 Condition,保留数字精度,并由两站点独立解析验证同键。未采纳 Equals 优先再用另一种键的建议:当前 `BucketPolicy.Equals` 对 Statement 次序敏感,而 `BPStatement.Equals` 忽略 Sid,两套判等混用可能使同时间冲突的比较不一致。公开统计现用 Equals 不变。当前 BPStatement 不支持 NotPrincipal,不为此新增语法或改 silo-pkg。
+2. **off 并非禁止删除:接受。** 直写 Policy 原有墓碑在 off 仍参与 heal;普通删除事件一直复制;只有新增 Tags/SSE/Quota 墓碑导出与初次发送受开关控制。
+3. **off 期间过时 RPC:接受。** 隐藏墓碑使部分旧 RPC 仍被尝试、在接收端被排序拒绝;这是升级阶段明确代价,不要求零 RPC,不加每轮日志。稳定无额外 RPC 的验收要求明确放在完整状态可见之后。
+4. **空策略 API 行为:接受。** T1 明确带合法 Version 的空策略 PUT 成功,随后 GET NotFound,落盘与专用删除事件三元组一致;不能误改为 4xx。
+
+三个实现提交没有增加,也没有新增协议、框架、依赖或长期集成门槛。v3 只需对本次 baseline 规则与以上说明做最后针对性复审;已关闭的首轮问题不重开全仓库审查。当前仍是设计文件,没有实现或执行实现期测试。
diff --git a/docs/investigations/issue-77/review/decisions-v4.md b/docs/investigations/issue-77/review/decisions-v4.md
new file mode 100644
index 000000000..dee030c83
--- /dev/null
+++ b/docs/investigations/issue-77/review/decisions-v4.md
@@ -0,0 +1,19 @@
+# v3 通过后的作者实测与 v4 最小补充
+
+Opus 第三轮已经给出 **GO_WITH_NONBLOCKING_NOTES,实施前阻断 0**,确认历史 baseline-live 初始化规则正确。见 [第三轮意见](opus5-max-v3.md)。其两条可执行非阻断说明(历史初值第二轮不再广播、deployment ID 不参与比较/持久化)已明确写入 v4。
+
+## S4:接管改变 Created 会把默认时间变成假墓碑
+
+这是作者补查发现,不冒称为 Opus 结论。
+
+当前 `PeerBucketMakeWithVersioningHandler`(`cmd/site-replication.go:946-961`)先加载并补齐默认字段时间,再调用 SetCreatedAt 改写 Created。若桶被接管到较早的共同 Created,原本 `PolicyUpdatedAt == oldCreated` 且 policy=nil 的缺省状态,变成 `PolicyUpdatedAt > newCreated`,新排序/导出规则便会把它当成真实删除。
+
+已在固定 main 上的独立 worktree 执行 `GOWORK=off go test ./cmd -run '^TestIssue77PlanAdoptionBaseline$' -count=1 -v`:ErasureSD 与 Erasure(默认多盘)都复现了该条件。Created 前移一小时,nil Policy 的字段时间留在原 Created。测试 PASS 的含义是**旧代码确实出现该现象**,不是修复通过;详见 [测试源码](adoption-baseline-probe.go.txt)、[实际输出](adoption-baseline-probe.log)、[元数据](adoption-baseline-probe.metadata.json)。测试只新增在临时 worktree,没有改产品代码。
+
+## 最小修订
+
+在现有接管锁内记住 oldCreated,执行现有 SetCreatedAt 得到 newCreated;仅当 Created 改变时,将本轮六类字段中原本为 0 或 oldCreated 的默认时间调整为 newCreated,再执行既有 bootstrap。所有真实字段时间(原本 > oldCreated)及载荷不动。绝不能仅以 nil 载荷识别默认状态,否则会丢失真正删除时间。
+
+这不是另做 #78 的配置接管修复,而是让 #77 新比较器需要的 baseline 不变量跨越已有 SetCreatedAt 写入路径仍成立。只在提交 1 加小分支,T8 增加默认时间维持 baseline、真实 PUT/DELETE 时间保留的断言;没有增加实现提交、存储字段、协议、锁或依赖。
+
+v4 其余行为与已通过的 v3 相同;第四轮只核查这个接管增量。
diff --git a/docs/investigations/issue-77/review/final-review.md b/docs/investigations/issue-77/review/final-review.md
new file mode 100644
index 000000000..0de659b3b
--- /dev/null
+++ b/docs/investigations/issue-77/review/final-review.md
@@ -0,0 +1,32 @@
+# Issue #77 最终方案评审记录
+
+2026-09-12,最终计划为 [issue-77-plan.md](../../issue-77-plan.md)。固定代码基准为 main `5c576581631561c446f30ae5b566f0aa793adc1c`;当日重新查询 #77 仍为 OPEN。
+
+**结论:GO_WITH_NONBLOCKING_NOTES,实施前阻断 0。** 作者已把末轮非阻断说明落实到定稿,可进入实现;本轮没有修改产品代码、提交 PR、关闭 Issue 或发布制品。
+
+## 实际执行的外部评审
+
+使用本机 Claude Code 2.1.258,每轮均明确传入 `--model claude-opus-5 --effort max`,在固定 main 的隔离 worktree 中执行,仅提供 Read/Grep/Glob。所有记录到的评审 assistant 消息模型均为 `claude-opus-5`。CLI 的用量信息另含少量辅助模型调用,已如实保留在 metadata;这不是用其它模型代替 Opus 评审。
+
+| 轮次 | 待审快照 | 结果 | 处置 |
+| --- | --- | --- | --- |
+| 1,全面方案审查 | [v1](plan-v1.md) | [NO_GO,3 个 P1](opus5-max-v1.md) | 修正零 quota 保存/发送不一致、Object Lock 归一后的提交快照、bulk wire 删除规则;缩减开关及验收范围 |
+| 2,修订复审 | [v2](plan-v2.md) | [NO_GO,新增 1 个 P1](opus5-max-v2.md) | 首轮 3 项确认关闭;修正历史 live@Created 的初始化回归 |
+| 3,历史初值增量 | [v3](plan-v3.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v3.md) | baseline-live 可初始化、不能覆盖真实状态;默认空值不能删除 |
+| 4,接管增量 | [v4](plan-v4.md) | [GO_WITH_NONBLOCKING_NOTES,0 阻断](opus5-max-v4.md) | 作者实测假墓碑后新增最小时间归一分支,经 Opus 确认必要性与充分性 |
+
+逐项记录:[首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管增量](decisions-v4.md)。每轮相邻的 `opus5-max-vN.metadata.json` 保存命令、模型、工具、耗时、源 SHA、待审计划哈希及结果哈希。
+
+## 末轮非阻断说明的落实
+
+- T8 已明确 Created 前移、后移与不变三种情况;历史 baseline-live 第一轮补齐,第二轮无额外写入/广播。
+- 保持只调整 #77 六类字段,在实现注释中说明边界。不采纳扩大到其它配置时间的可选建议:那些时间不是新比较器的输入,没有必要同批改变其行为。
+- Policy 稳定键与公开计数的判等职责明确;Sid 差异可以触发一次状态同步,但不重做 #91 的既有统计语义。递归规范化和数字精度由 T2 验证。
+
+定稿相对于第四轮冻结快照只补充上述验收/注释说明和评审状态,没有新增实现范围。
+
+## 证据边界
+
+作者实测使用了实际 Go 编解码、当前 silo-pkg 的 Policy 编码,以及 ErasureSD/默认多盘 ObjectLayer 的接管路径。接管诊断 PASS 表示在未修复 main 上成功观察到假墓碑条件。既有问题复现记录见 [当前核验](../../issue-77-current.md)。
+
+外部 reviewer 做的是源代码与方案核查,没有运行新实现测试;现在不存在本轮产品实现。局部回归、race、完整 CI、修复版双站点收敛、一次性升级冒烟均是后续实现阶段的验收,不得把本次评审结论当成这些验证已经通过。
diff --git a/docs/investigations/issue-77/review/opus5-max-v1.md b/docs/investigations/issue-77/review/opus5-max-v1.md
new file mode 100644
index 000000000..aef81524a
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v1.md
@@ -0,0 +1,124 @@
+Write 工具在本会话被禁用,无法落盘评审文件,因此评审报告直接输出如下。
+
+# SILO #77 修复计划 v1 对抗评审
+
+基准:worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本次只做方案评审:未改产品代码、未运行测试、未写 GitHub、未调用其他代理。
+
+**证据分级**:**[源码]** 本次在该 SHA 直接确认(给文件:行)/**[复现]** `issue-77/current-tests.log` 已记录/**[推论]** 由源码事实推导、未执行/**[待验]** 需实现期测试。
+
+---
+
+## 结论:NO_GO
+
+方向正确,但 §3 / §5 / §6 三处**契约文字**按字面实现会产生新的不收敛与潜在错删。计划约 85% 的内容经源码核对成立,不需要推翻结构——**修订 R1、R2、R3 后复审即可进入实现**。
+
+---
+
+## P1 阻断项
+
+### R1 零配额在发送端被改写成删除,本地却保留 live 文档 → off(默认)永不收敛,on 主动删除本地合法文档
+
+**源码位置**
+- `cmd/admin-bucket-handlers.go:85` 本地写入用的是**原始请求体** `data`;
+- `cmd/admin-bucket-handlers.go:97-99` `if quotaConfig.Size == 0 && quotaConfig.Quota == 0 { bucketMeta.Quota = nil }` —— 发出的事件被改写成**删除**;
+- `cmd/site-replication.go:2088-2107` 对端 `quota == nil` 走 `Delete`;
+- `madmin-go v3.0.110 quota-commands.go:54-60` `Quota == 0` 时 `IsValid()` 恒真(`{}` 是合法 live 文档),且该版本**只有 `SetBucketQuota`,没有删除端点** —— "清除配额"只能走这条零值 PUT;
+- `cmd/site-replication.go:4090-4094` 导出端仅在 `len(QuotaConfigJSON) > 0` 时带出时间,删除时间被隐藏。**[源码]**
+
+**计划条目**:已确定契约"合法的空 quota 对象 `{}`…不能仅因配额为零就改写为 nil";§3"同时间真实删除大于 live";提交 3 tombstone 导出;完成条件"四类 DELETE"、T7。
+
+**触发步骤**
+1. A 站点执行等价于 `mc quota clear`(零值 SetBucketQuota)。
+2. A 落盘 live `{...}`@T;发出的事件是 `Quota=nil`@T。
+3. B 按计划落盘 tombstone@T。
+4. **off(默认)**:B 的 tombstone 时间不导出 → B 候选被当 baseline 过滤 → 选源永远是 A 的 live@T → 推给 B → B 按"同时间删除优先"拒绝 → **每 30 秒(`site-replication.go:4651`)一次 RPC + 一次拒绝日志,永不收敛**。
+5. **on**:B 导出 tombstone@T → 同时间删除胜 → heal 反过来**删除 A 上合法的 live 文档**。
+
+**影响**:这是清除配额的常规路径。主干今天同样不收敛(到达时间更大导致拒绝),所以**不是回归,但计划声称会修而按 v1 修不了**,T1/T7 不可达成。**[源码]+[推论]**
+
+**同类第二处**:`cmd/bucket-policy-handlers.go:87-109` 本地存 live 的"语义为空"策略,`cmd/admin-handlers-site-replication.go:233-237` 在接收端判为删除。Policy 仍能收敛,只因 `site-replication.go:4069-4070` **无条件**导出 `PolicyUpdatedAt`。可见根因是"发送端语义 ≠ 本地落盘语义",不是导出策略。
+
+**最小修正(二选一,必须成对,不能只改一侧)**
+- (a) 解析后 quota 为零值结构时本地也走 `Delete`(parse=false),事件仍为 nil;或
+- (b) 取消 `Quota = nil` 改写,事件携带真实文档。
+
+并把"同一次本地操作,落盘状态与发出事件状态必须是同一状态"写成契约不变量;T1 增加三元组 (kind, payload, time) 一致性断言。
+
+---
+
+### R2 `saveMetadata` 内部静默归一化:落盘载荷 ≠ 比较载荷 ≠ 事件载荷;§6"无状态变化不保存"按字面不可实现;§3 未定义字节序方向
+
+**源码位置**
+- `cmd/bucket-metadata.go:391-399` `parseAllConfigs` 在 `objectLockConfig != nil` 且 versioning 文档解析失败 / `!Enabled()` / `PrefixesExcluded()` 时改写 `VersioningConfigXML` 为常量,**不改 `VersioningConfigUpdatedAt`**;
+- `cmd/bucket-metadata.go:581-603` `Save` 每次保存前都调 `parseAllConfigs` → 归一化发生在**每一次** `saveMetadata`;
+- `cmd/bucket-metadata-sys.go:232-243` `saveMetadata(…, meta BucketMetadata)` **按值接收**,归一化只作用于被落盘/入缓存的副本,调用方手里的结构仍未归一化;
+- `cmd/admin-bucket-handlers.go:1094-1116` 导入把未归一化的 `merged` 赋回 `*meta`,随后 `hook.Versioning = enc(meta.VersioningConfigXML)` —— **发出的载荷与落盘载荷不同,时间戳却相同**;
+- `cmd/admin-bucket-handlers.go:823-835` 导入只拒绝 `Suspended`,**不拒绝 `PrefixesExcluded()`**(`internal/bucket/versioning/versioning.go:153-155`:`ExcludedPrefixes` 或 `ExcludeFolders` 均算);
+- `cmd/bucket-versioning-handler.go:68-84` S3 PUT 只在**本地** LockEnabled 时拒绝 prefix-excluded;
+- 现有回归 `cmd/site-replication-object-lock_test.go:149-167` 已把该机制固化为断言。**[源码][复现-通过]**
+
+**计划条目**:§3"live/live 按稳定载荷字节序比较""apply 和 heal 使用同一比较函数";§6"无状态变化时不保存、不通知";提交 1 完成条件"导入的持久化时间与实际发出的事件一致"。
+
+**为什么阻断**
+1. §6 只能在 `saveMetadata` 之前比较,而真正落盘的是归一化之后的结构——**规则按字面无法实现**,"无变化"的判定可能与磁盘不符。
+2. 计划把到达时间换成源时间后,**同一时间戳 + 不同载荷成为正常可达状态**(Object Lock 在站点间短暂不对称时必然出现);旧代码靠"到达时间总在前进"意外掩盖了它。
+3. §3 **没写明大者胜还是小者胜**。以 `…Enabled` 对 `…Enabled` 为例,分歧字节是 `E`(0x45) 与 `/`(0x2F):方向选错,heal 每轮把未归一化文档推给已归一化站点,对方再归一化 → 重复写 + 全节点 `LoadBucketMetadata` 广播;方向选对则一轮收敛。**让正确性取决于未写明的字节序方向不可接受。**
+4. 导入路径违反提交 1 完成条件的实质:时间一致、状态不一致。
+
+**诚实边界**:我**没有**证明这会无限发散。Object Lock 不可删除,`healOLockConfigMetadata`(`5379-5447`)通常 1 轮内补齐,之后两侧归一化结果相同并收敛。实际损害是"窗口期重复写/广播 + 实现定义的裁决"。**[源码]+[推论],永久性未证实。**
+
+**最小修正**:比较器对 Versioning 先做与 `parseAllConfigs` 相同的归一化再比较;`saveMetadata` 回写/返回已持久化结构,导入与所有 hook 用**落盘后的字节**;§6 改为"以归一化后的落盘表示判定状态变化";§3 写明字节序方向并注明它只用于真并发冲突,不得用来消化序列化差异。
+
+---
+
+### R3 §5 的"bulk 中显式提供且为空 = 删除"对 Policy/Quota 在线路上不可表达;按字面实现存在"缺省即删除"的错删路径
+
+**源码位置**
+- `madmin-go v3.0.110 cluster-commands.go:501-530`:`Policy`/`Quota` 是 `json.RawMessage` + `omitempty` —— **nil 与空切片都会被省略**,解码端永远拿到 nil;而 `Tags/SSEConfig/Versioning/ObjectLockConfig` 是 `*string`,`ptr("")` 可表达"提供且为空";
+- `cmd/site-replication.go:1653-1697` bulk 一律按 `!= nil` 判"是否提供";
+- `cmd/admin-bucket-handlers.go:1103-1131` 导入发出的正是 `Type` 为空的 bulk 事件,且只填本次导入涉及的字段。**[源码]**
+
+**风险**:对 Policy/Quota,这条规则要么是走不到的死代码,要么诱导实现者写 `len(item.Policy) == 0 ⇒ 删除` —— 那样**每个只导入 tags 的 bulk 事件都会删掉对端 bucket policy**,正是 #77 要消灭的错删类。这是规范缺陷,不是当前代码缺陷。**[源码]+[推论]**
+
+**最小修正**:§5 改为——bulk 只有 Tags/SSE 能表达删除(显式空 base64 串);Policy/Quota 删除只走专用事件;bulk 缺省字段任何情况下不改变该字段。T5 补两条断言。
+
+---
+
+## P2(建议同批修订)
+
+- **R4 before-created 扩大到六类,但选源端不做同一判定。** `site-replication.go:1646-1651`(bulk 已有)、`2035-2040`(CORS 先例)、`bucket-metadata.go:190-197`(`SetCreatedAt` 无条件覆盖)、`site-replication.go:5486-5512`(`healBucket` 不修复两站点 Created 分歧)。§提交 2 只过滤 `==0` 与 `==CreatedAt`,**不过滤"早于自身 Created"**:分区期间两站点各自建过同名桶后,较老站点的真实配置被永久拒绝,heal 每轮仍选中它并挡住其它候选(今天会收敛)。**最小修正**:选源与 apply 用同一可用性判定(`time <= 自身 CreatedAt` 视为 baseline);对 `目标 CreatedAt > 候选时间` 按目标跳过并记一次 `before-created`;计划中明确这是**终态不收敛、需运维介入**,不计入 T9。
+- **R5 比较器缺第三种 kind(baseline)。** `site-replication.go:1851-1898` 的 `corsReplicationState` 已有三值模型。§3 只定义"删除 vs live",把"创建默认/未知"放在选源侧 → 任何异常发送端发出的 `nil + 创建默认时间` 在 apply 侧会被当 tombstone,同时间即可删掉对端 live。**最小修正**:六类复用 CORS 三值模型(baseline = 时间为零或不晚于 Created),apply 对 baseline 一律 no-op;顺带获得 `latestCORSConfig`(`5315-5329`)的稳定选择语义。
+- **R6 两个入口读到的"当前字段时间"来源不同。** `site-replication.go:1641` 用 `readBucketMetadata`(**不调** `defaultTimestamps`),`bucket-metadata.go:230-238` 才补默认值 → 同一记录在两条路径上分别是 `0` 与 `Created`,事件时间恰等于 `Created` 时裁决不同。**最小修正**:比较器统一 `if fieldTime.IsZero() { fieldTime = meta.Created }`。
+- **R7 heal 中不可达站点占位项会中途终止本轮修复。** `3136-3142` 把不可达站点填 `SRInfo{}`,`3219` 以空 DeploymentID 进入 `BucketStats`;`5006-5009`、`5070-5073`、`5145-5148` 等处 `getAdminClient("")` 失败即 `return`,**放弃该类型剩余站点**,且是否提前退出取决于 map 顺序。与 T9 承诺冲突。**最小修正**:两处循环都跳过 `info.Sites` 中不存在的 dID 并计入 `indeterminate`。
+- **R8 parse=true 置空字段留下已解析缓存。** `bucket-metadata.go:401-413`(versioning、quota)与 `334-338`(notification)在载荷为空时**无 else 分支**(即 [复现] `QUOTA_CACHE`)。§6 的"删除走 parse=false"规避有效且最小,但应写成显式不变量并在 T7 加断言。**不建议**本轮补 else —— 会改变 `GetQuotaConfig` 返回语义(`bucket-metadata-sys.go:572-581` 不做 nil 判断),超出范围。
+- **R9 Object Lock 的 legacy `Tags` 载荷回退必须保留。** `site-replication.go:1787-1796` + 回归 `site-replication-object-lock_test.go:132-138`。提交 1 的重构容易丢掉;T3 应显式覆盖"Type=ObjectLockConfig、载荷在 Tags 字段"的排序行为。
+- **R10 off 模式下 legacy-zero 事件盖上本地新时间后永久胜出。** `site-replication.go:5011-5015` 至今不带 `UpdatedAt`([复现] `TAG_WIRE_TIME`)。计划已有"升级后由操作者重新提交"的兜底,建议在文档条目里点名这个具体来源。
+
+---
+
+## 过度设计 / 可缩减
+
+- **O1 开关承担三件事,只有一件有证据。** 证据(quota 缓存复现)只支持"tombstone 导出需等全站点升级"。"on 模式拒绝零源时间事件"没有对应缺陷,且计划已修复树内唯一的零时间发送端;保留它只会在误开时让 heal 每 30 秒报错。**建议把开关收缩为单一含义**:是否导出/发送真实删除状态(含初次同步);零时间事件两种模式统一按 legacy-zero 兼容应用 + 限频记录。
+- **O2 开关实现不需要新框架。** `cmd/common-main.go:902` 已有 `env.Get(name, config.EnableOff) == config.EnableOn` 的两行先例,照抄即可。
+- **O3 T8 的"固定旧版与修复版两个 SILO 进程"不必作为提交门槛。** 旧版对线路的可观测差异只有两点(heal 事件不带 `UpdatedAt`;导出不含 nil 载荷时间),进程内构造 `SRInfo`/`SRBucketMeta` 即可完整覆盖;降级为一次性人工冒烟。
+- **O4 T4 不需要新建"确定性锁屏障"。** 已有 `lockBucketMetadataAcquireHook`(`bucket-metadata-sys.go:249-255`)与 `bucket-metadata-lock_test.go` 的 `runBucketMetadataRMWConflict` 模式可直接复用。
+- **O5 §3 的 deployment ID 规则可删。** "仅在状态完全相同时用于日志来源"无可观测行为;复用 `latestCORSConfig`(`5322` 的 `cmp == 0 && dID > latestID`)即自动获得稳定性。
+- **O6 `indeterminate` 缺少明确产生条件。** 若采纳 R4,`before-created` 与 `indeterminate` 会合并,应减为三类,不要为对称保留空类别。
+
+---
+
+## 经核对成立、不应削减
+
+锁外 getter→Update 竞态([复现] `TestIssue77CurrentPeerCheckBeforeLock` 失败);bulk 绕过逐字段排序(`1653-1697` 无字段时间比较,[复现] 失败);选源前过滤创建默认值([复现] 5/32、2/32 错删 Policy;六处重复写法 `4966-4982`、`5038-5054`、`5104-5120`、`5178-5194`、`5253-5269`、`5393-5409` 抽函数是净减法);Tag 远端 heal 缺 `UpdatedAt`(`5011-5015`);同内容不同时间不同步([复现] `BARRIER_NOT_HEALED`);复用 `metadata.lock` 与 `saveMetadata` 的物理桶检查(`232-243`)、放弃 `peer-require-existing`;导入 `updatedAt := UTCNow()`(`723`)在 ZIP 开始时取一次且可能早于随后创建的桶 `Created`,per-bucket `commitAt` 修正必要。另:`lockBucketMetadataWithTimeout` 不可重入,重入会挂到 `globalOperationTimeout`(非永久死锁但同样不可接受),"不循环调用公开 handler"正确;实现内部 apply 时须沿用 `updateAndParse` 传递 lock-ctx,否则 `loadBucketMetadataParse` 的迁移分支(`bucket-metadata.go:252`、`282`)会再次取锁。
+
+---
+
+## 实施前必须修订的条目
+
+1. **R1** 零值配额的发送端/本地写一致性 + "落盘状态 == 发出状态"不变量。
+2. **R2** §6 比较基准改为归一化后的落盘表示;`saveMetadata` 回写已持久化结构;导入 hook 用落盘字节;§3 写明同时间载荷序方向及适用边界。
+3. **R3** §5 重写 bulk 删除语义(仅 Tags/SSE 可表达;缺省永不改变字段)+ 对应断言。
+
+建议一并吸收 R4、R5 措辞修正与 O1、O3、O4 缩减,再做一次短复审即可进入实现。
+
+**实现阶段需要运行(本次未运行)**:把两份审计用例改为确定性回归并先记录未修复失败;R1 零配额两侧三元组一致 + off/on 各一轮 heal 无重复 RPC;R2 Object Lock 桶 + prefix-excluded/ExcludeFolders versioning 走导入与 peer 两条路径,断言"发出载荷 == 落盘载荷"且第二轮 heal 无写入;R3 bulk 缺省/空串两类断言;既有回归 `TestPeerBucketObjectLockMetadata*`、`TestPeerBucketAdoption*`、`TestQueuedMetadataUpdateAfterDelete`、`TestSiteReplicationStatusAccountsPerSiteAndSurvivesMalformedConfig`、CORS 站点复制用例,加目标 race 与完整 Go CI。
diff --git a/docs/investigations/issue-77/review/opus5-max-v1.metadata.json b/docs/investigations/issue-77/review/opus5-max-v1.metadata.json
new file mode 100644
index 000000000..2ee21216e
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v1.metadata.json
@@ -0,0 +1,46 @@
+{
+ "reviewer": "Claude Code",
+ "cli_version": "2.1.258",
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "permission_mode": "plan",
+ "tools": [
+ "Read",
+ "Grep",
+ "Glob"
+ ],
+ "round": 1,
+ "plan_snapshot": "plan-v1.md",
+ "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
+ "status": "completed",
+ "model_configuration_reference": "https://code.claude.com/docs/en/model-config",
+ "observed_model": "claude-opus-5",
+ "session_id": "9fb76061-6bcc-490c-a96b-9a1d4f9daa84",
+ "observed_tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "verdict": "NO_GO",
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 1437147,
+ "num_turns": 57,
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "plan_sha256": "0218a2ca59750acd6a24dc5ece1d55a19762a863f1a2de8edc6ffc9b4ab072e0",
+ "auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
+ "collected_at_utc": "2026-09-12T01:57:46.010985+00:00",
+ "stream_sha256": "a145cd75ebf2ab3b722c7e1764036fcac8e3245ed2de3c2390dc0b5826e63d16",
+ "review_sha256": "8a3b750a261ef520acbe658395d3303cea0835b07184b96022781a2a5d0d4dcf",
+ "used_tools": [
+ "Glob",
+ "Grep",
+ "Read",
+ "Write"
+ ],
+ "temporary_review_worktree_removed": true,
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/review/opus5-max-v2.md b/docs/investigations/issue-77/review/opus5-max-v2.md
new file mode 100644
index 000000000..d846b06de
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v2.md
@@ -0,0 +1,44 @@
+# SILO #77 修复计划 v2 对抗复审(Claude Code Opus 5,只读)
+
+基准 worktree = main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮只针对 v1→v2 的修订差异做源码核验:未改产品代码、未运行测试、未调用其他代理。
+
+## 首轮三个阻断的关闭判断
+
+**R1(零配额发送端改写 / 落盘≠出站):已关闭。** v2 取消 `admin-bucket-handlers.go:97-99` 的 `Quota=nil` 改写后,`mc quota clear` 两侧都落 live 零值文档:`bucket-quota.go:99-110` 对 `{}`/`null` 均解析为零值且 `IsValid()` 为真,`isBktQuotaCfgReplicated`(`site-replication.go:3850-3888`)此时 `numquotaCfgs==total`、Type 相同 → 判为已复制,`quotaCfgSet`(`3635`)仍为 false,#91 计数不受影响。接收端 `PeerBucketQuotaConfigHandler:2089` 会重新 `json.Marshal` 导致字节不同,但 v2 §2 规定 Quota 比较键取"解析结果的 JSON 编码",`madmin.BucketQuota` 是定长结构体、编码确定,不会因此空转。Policy 侧改成本地/导入/bulk 统一按 `IsEmpty()` 删除,与既有 `admin-handlers-site-replication.go:233-237` 一致,三元组不变量成立。
+
+**R2(saveMetadata 静默归一 / §6 不可实现 / 字节序方向未定):已关闭。** `saveMetadata`(`bucket-metadata-sys.go:232`)全部 6 个调用点(`site-replication.go:961/1708/2049`、`bucket-metadata-sys.go:203/222/354`、`admin-bucket-handlers.go:1095`)传入的都是调用方自有值,改指针回写无别名风险;导入 hook 改用提交快照后,`1110-1127` 与落盘文档不再分叉;比较改用 `parseAllConfigs:391-399` 同一条 Object Lock→Enabled 归一规则,方向也已写明"键较大者胜"且禁止用字节序补偿隐式改写。
+
+**R3(bulk 删除语义):已关闭,且首轮依据确属错误。** 复核确认:`json.RawMessage` 显式 `null` 解码为非 nil 的 `[]byte("null")`(探针 log 第 3-4 行),`policy/bucket-policy.go:78` 允许 `Version==""`,故 `null` 与 `{"Version":...,"Statement":[]}` 都能通过 `ParseBucketPolicyConfig` 且 `IsEmpty()` 为真;`*string` 的 `null` 才退化成 nil。v2 的逐字段规则与 `PeerBucketMetadataUpdateHandler:1653-1697` 的 `!=nil` 约定一致,也堵住了"`len==0 ⇒ 删除"的错删诱导。唯一 bulk 生产者(导入 `1103-1131`)在 `omitempty` 下漏发空 Policy 的问题,v2 用专用 nil 事件补齐,处置正确。
+
+## P1(新增阻断,1 项)
+
+**P1-1:`fieldTime <= Created` 一律判 baseline,会让历史桶的配置在初次同步中被静默丢弃且 heal 永不修复。**
+
+反例(全部为已修复版本、同一桶世代、合法带时间事件,属计划承诺范围):
+
+1. A 站点有 2020 年前迁移来的桶。`applyLegacyConfigs`(`bucket-metadata.go:482-513`)不写任何 `*UpdatedAt`,`convertLegacyConfigs` 保存后磁盘上这些字段仍是零值;每次 `loadBucketMetadataParse:236-238` → `defaultTimestamps:534-561` 把 `PolicyConfigUpdatedAt` 补成 **正好等于 Created**。
+2. 运维执行 `AddPeerClusters`。`MakeBucketHook:821-823` 把 `createdAt` 传给对端,`PeerBucketMakeWithVersioningHandler:950` 的 `SetCreatedAt` 使 B 的 `Created` 与 A **完全相等**。
+3. `syncToAllPeers:2217-2228` 发出 Policy@Created(非零,因此不走 legacy-zero 例外)。
+4. 按 v2 §3"非零事件**不晚于**目标桶 Created 时成功/no-op,记录 before-created" → B 直接 no-op,policy 落不下去。
+5. 按 v2 §2/提交 2.1,A 的候选是 baseline → 过滤 → "无候选,显式 found=false" → 永远不 heal,只产出 indeterminate。
+
+主干今天这条路径是成功的(`PeerBucketPolicyHandler:1718-1746` 无 before-created 检查,直接应用),bulk 侧 `1646` 也只拒绝 **严格早于** Created。因此这是回归,不是遗留缺陷;Tags/SSE/Quota 同理,Object Lock 因 `955-959` 自举侥幸掩盖。T1–T9 没有"字段时间等于 Created 的历史桶初次同步"用例,实现期不会被发现。
+
+最小修正(两句契约 + 一行验收):①把 apply 侧边界改成**严格早于** Created 才 before-created;`fieldTime == Created` 保留 baseline 的"弱"语义。②baseline 只是排序上低于任何非 baseline 候选,不是"不可用":目标该字段自身也是 baseline/缺失时,baseline-live 仍可确立初值,仅在存在非 baseline 候选时被压制;全 baseline 且内容不同时按既定键排序取胜者,而不是报 indeterminate。③T7/T8 增加一行"全部字段时间等于 Created 的历史桶,初次同步与一轮 heal 后各站点一致"。
+
+## P2(仍需改动)
+
+- **Policy 比较键的构造方式。** 递归排序自造规范 JSON 需要覆盖 `Condition` 的 `map[string]map[string]ValueSet`、`NotAction/NotResource/NotPrincipal`,一旦两站点对同一策略算出不同键,同时间平局会各自选出不同胜者 → 每轮互推、不收敛。建议判等直接复用现成的 `BucketPolicy.Equals`(`site-replication.go:3912` 已在用),规范串只用于同时间平局排序,并复用 `ActionSet.String()`(silo-pkg `policy/actionset.go:151-158`,已排序)。同时把"键必须是**已解析策略**的纯函数、跨站点必须一致"写成不变量,T2 补一条"两站点各自字节 → 相同键"断言。
+- **开关语义与 Policy 的实际不对称需写进文档。** `site-replication.go:4069-4070` 无条件导出 `PolicyUpdatedAt`,因此提交 1/2 落地后,**开关 off 时 Policy 删除照样会经 heal 传播**,只有 Tags/SSE/Quota 被门控。v2 表格里"初次同步的真实删除:off 保留已有行为"容易被读成"off 完全不传播删除",运维会据此做错误的升级判断。
+- **off 模式下的稳定态 RPC 要写进验收预期。** 持有 Tags/SSE/Quota 墓碑的站点在 off 下导出不带时间 → 每轮仍被选为待修复目标 → 每 30s(`4651`)一次必然被拒的 RPC。提交 2 的"对已归一且相同的目标不写入、不发 RPC"不覆盖该情形。请明确这是 off 期间的预期代价,并禁止为它加每轮日志(会与 §3 的三类有界日志冲突)。
+- **空策略 PUT 的对外行为补一条断言。** v2 让本地 `PutBucketPolicy` 对 `{"Version":"2012-10-17","Statement":[]}`(`bucket-policy-handlers.go:98-101` 只拒空 Version,该文档合法)改走删除。T1 请断言"PUT 返回成功、GET 返回 NotFound、出站专用事件与落盘一致",避免实现时误改成 4xx。
+
+## 作者补查核验
+
+**S1 成立。** `internal/logger/logonce.go:109` 仅在 `prev.Err.Error() == nerr.Error()` 时抑制;正文含变化时间会每轮重新打印(且分支不更新 map,等于完全失效)。固定正文 + ReqInfo 的做法正确。
+
+**S2 成立。** `policy/actionset.go:144-168` 的 `MarshalJSON → ToSlice` 直接遍历 map 不排序,`json.Marshal(BucketPolicy)` 确实不稳定,v1 的字节比较键不可用;v2 另建比较键的结论正确(构造方式见上 P2)。
+
+---
+
+**结论:NO_GO。实施前阻断数:1(P1-1)。** 首轮 R1/R2/R3 三项均已实质关闭,R4–R10、O1–O6 的处置未引入新的明确阻断;余下 4 条 P2 建议与 P1-1 一并修订即可进入实现,不需要再做全量复审。
diff --git a/docs/investigations/issue-77/review/opus5-max-v2.metadata.json b/docs/investigations/issue-77/review/opus5-max-v2.metadata.json
new file mode 100644
index 000000000..a090e6068
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v2.metadata.json
@@ -0,0 +1,50 @@
+{
+ "reviewer": "Claude Code",
+ "cli_version": "2.1.258",
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "permission_mode": "plan",
+ "tools": [
+ "Read",
+ "Grep",
+ "Glob"
+ ],
+ "round": 2,
+ "plan_snapshot": "plan-v2.md",
+ "plan_sha256": "2b2c712b17f757ca1a8501714c6c2472bd442a6be50b29d92f2d87e23c412ba8",
+ "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
+ "status": "completed",
+ "observed_model": "claude-opus-5",
+ "session_id": "e40ce534-ece8-430f-841d-99add4fab13d",
+ "observed_tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 666254,
+ "num_turns": 41,
+ "collected_at_utc": "2026-09-12T01:40:02.942542+00:00",
+ "stream_sha256": "7b3aa8de142e77b4ea1bca468685a04203261021c7978c3a97a27d1febacacab",
+ "review_sha256": "848a476f18ce88208b72d8bf6cb49a2316b2b14dcba64ad66e636fe0a3d70595",
+ "used_tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
+ "verdict": "NO_GO",
+ "blocking_findings": 1,
+ "closed_previous_blockers": [
+ "R1",
+ "R2",
+ "R3"
+ ],
+ "temporary_review_worktree_removed": true,
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/review/opus5-max-v3.md b/docs/investigations/issue-77/review/opus5-max-v3.md
new file mode 100644
index 000000000..65fcd31d2
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v3.md
@@ -0,0 +1,22 @@
+## P1-1:已关闭
+
+v2 的阻断链条有三个环节,v3 逐个断开:
+
+1. **apply 侧边界**:计划 §2 第 39 行改为「非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created」,并明写「等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created」。v2 反例第 4 步(`==` 被判 before-created)不再成立。
+2. **heal 候选**:第 37 行只把「空 baseline」排除出源,baseline-live 是初始化候选;提交 2.1(第 72 行)过滤条件同样是「严格早于**自身** Created」,等于 Created 不被滤掉。v2 反例第 5 步(found=false 永不修复)不再成立。
+3. **验收**:T8 第 111 行有「历史字段时间全部等于 Created 的桶经初次同步和一轮 heal 后一致」,T7 第 110 行有 baseline-live 初始化与同级收敛。
+
+## 四条反例核验
+
+1. **历史 live@Created → 空字段**:初次同步走 apply 分支(baseline-live 胜空 baseline,时间存原值 Created);漏发时 heal 也能选到该候选。写入后两侧同为 baseline-live@Created、键相等,按第 40 行「键、状态级别和源时间相同为 no-op」+ 提交 2.4 稳定。✅
+2. **较晚空默认值**:第 40 行排序先比状态级别,「任何真实 live/tombstone 都胜 baseline」,且空 baseline 既不是源也不是删除(37/39 行、decisions 表第 18 行)。时间更晚的空默认值无法压过较早真实修改。✅
+3. **不复活真实删除 / 同级收敛**:baseline-live「永远不能覆盖真实 live/tombstone」在 heal 选源与 apply 两侧同一套规则(提交 2 标题即「选源与应用同规则」),因此 off 模式下墓碑隐藏时,被选中的 baseline-live 推到持有墓碑的站点仍被接收端拒绝——是过时 RPC,不是复活。全 baseline-live 不同载荷按同一稳定键较大者胜,两侧独立计算同键(第 41 行纯函数不变量),单轮收敛且不回摆。✅
+4. **四条 P2**:均落到契约或验收行(41 行取消双判等、92 行 off≠禁止删除且不加每轮日志、T1 空策略 PUT 成功/GET NotFound)。剩余风险是 Policy 键的实现细节(递归排序与大整数精度),已转为 T2 断言,属实现期验证,非方案阻断。✅
+
+## 非阻断注记
+
+- 比较键含 Sid,而公开统计仍用忽略 Sid 的 `Equals`:仅 Sid 差异时 heal 会写一次、状态接口报已复制;收敛后二者一致,不会持续分歧。
+- 建议把 T8 历史桶那行的「一轮 heal」补一句第二轮无写入/广播(与第 78 行完成条件同口径),可选。
+- 第 40 行末「同状态来源不参与持久化冲突裁决」措辞含糊,实现前口头澄清即可。
+
+**结论:GO_WITH_NONBLOCKING_NOTES。实施前阻断数:0。** 未发现新反例;已关闭范围(R1/R2/R3、六类 handler、导入、计数、调用图)未重审。
diff --git a/docs/investigations/issue-77/review/opus5-max-v3.metadata.json b/docs/investigations/issue-77/review/opus5-max-v3.metadata.json
new file mode 100644
index 000000000..f38919354
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v3.metadata.json
@@ -0,0 +1,43 @@
+{
+ "reviewer": "Claude Code",
+ "cli_version": "2.1.258",
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "permission_mode": "plan",
+ "tools": [
+ "Read",
+ "Grep",
+ "Glob"
+ ],
+ "round": 3,
+ "plan_snapshot": "plan-v3.md",
+ "plan_sha256": "8c4ab51a397246a07bb1cc967df3377d26ac084b7928191eb8fecacf6e7c3aed",
+ "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
+ "status": "completed",
+ "observed_model": "claude-opus-5",
+ "session_id": "3ae0c14b-58cf-415d-8d2d-3a388d322534",
+ "observed_tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 181195,
+ "num_turns": 4,
+ "collected_at_utc": "2026-09-12T01:50:13.403953+00:00",
+ "stream_sha256": "728542385eb75e02050c17a389b49d2b4dfacdf1c54801fc6792694f91f08cff",
+ "review_sha256": "557ef70288a9a3939842a2f76d63694e2c103a8bfcaa99b7c161ee89e1f58061",
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "used_tools": [
+ "Read"
+ ],
+ "auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
+ "verdict": "GO_WITH_NONBLOCKING_NOTES",
+ "blocking_findings": 0,
+ "temporary_review_worktree_removed": true,
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/review/opus5-max-v4.md b/docs/investigations/issue-77/review/opus5-max-v4.md
new file mode 100644
index 000000000..2bfaa44c9
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v4.md
@@ -0,0 +1,16 @@
+## 复核结论(第四轮,仅针对接管增量)
+
+**必要性成立,证据链闭合。** `loadBucketMetadataParse` 内 `defaultTimestamps`(`cmd/bucket-metadata.go:534`)先把字段时间补成 oldCreated,随后 `SetCreatedAt`(`:190-197`)无条件改写 `Created`,两者之间无补偿;probe 日志两个 ObjectLayer 均得 `nil with field>Created=true`,与 v4 新比较器「时间 > Created 即真实状态、nil 载荷即 tombstone」直接相撞,会伪造删除并向外广播。补丁点选在 `cmd/site-replication.go:946-950` 现有 `lockBucketMetadata` 内,位置正确。
+
+**充分性成立。** 用「为 0 或 == oldCreated」判定默认值,与计划 §39「等于 Created 仍属 baseline」同一口径,重映射不改变任何状态级别;真实(> oldCreated)时间与全部载荷不动,故不覆盖真修改/真删除。不触 payload、不新增写入或广播路径,#78 配置保护不受影响;baseline-live 之间按稳定键而非时间决胜,平移时间不改变胜负。
+
+另补一条支持性事实:`enablePeerBucketVersioning:901` 与 handler `:957` 的 `IsZero` 守卫在此路径上已被 `defaultTimestamps` 提前失效,bootstrap 分支本身无法把时间修正到 newCreated——归一化是唯一补救,必要性比 decisions-v4 描述的更强。
+
+**两点非阻断修正:**
+
+1. `SetCreatedAt` 对方向无约束,newCreated 也可能晚于 oldCreated;此时旧默认时间变成「严格早于自身 Created」,按 §37 被当作无效候选过滤,历史 baseline-live 静默丧失初始化能力。计划正文写的是「如果 Created 改变」(双向,正确),但 decisions-v4 §S4 与 T8 只写「前移」。T8 应补后移方向断言。
+2. `defaultTimestamps` 覆盖 11 个字段,补丁只归一六类,其余五类接管后遗留 != Created 的伪真实时间。#77 比较器不读它们,不阻断;实现时请在代码注释写明限定理由,或直接对该函数的全集做同样归一(同等成本,不扩大范围)。
+
+未发现范围外扩、未证实假设或与已关闭项的冲突;独立桶世代合并仍在既定边界外,正确。
+
+**GO_WITH_NONBLOCKING_NOTES;实施前阻断数 0。**
diff --git a/docs/investigations/issue-77/review/opus5-max-v4.metadata.json b/docs/investigations/issue-77/review/opus5-max-v4.metadata.json
new file mode 100644
index 000000000..0eff0ec13
--- /dev/null
+++ b/docs/investigations/issue-77/review/opus5-max-v4.metadata.json
@@ -0,0 +1,46 @@
+{
+ "reviewer": "Claude Code",
+ "cli_version": "2.1.258",
+ "requested_model": "claude-opus-5",
+ "requested_effort": "max",
+ "source_commit": "5c576581631561c446f30ae5b566f0aa793adc1c",
+ "permission_mode": "plan",
+ "tools": [
+ "Read",
+ "Grep",
+ "Glob"
+ ],
+ "round": 4,
+ "plan_snapshot": "plan-v4.md",
+ "plan_sha256": "fbfd37498ab32a589d06254f32182869d5d49fc97722416c46c10b41e5f77816",
+ "command": "claude --print --model claude-opus-5 --effort max --safe-mode --permission-mode plan --tools Read,Grep,Glob --strict-mcp-config --no-session-persistence --output-format stream-json --verbose",
+ "status": "completed",
+ "observed_model": "claude-opus-5",
+ "session_id": "72c12e7b-d830-4f3a-9066-5fa829de7a4f",
+ "observed_tools": [
+ "Glob",
+ "Grep",
+ "Read"
+ ],
+ "subtype": "success",
+ "is_error": false,
+ "duration_ms": 112227,
+ "num_turns": 10,
+ "collected_at_utc": "2026-09-12T01:55:02.590226+00:00",
+ "stream_sha256": "d8f3509c695749cb5fae9e2f7142b887e48a950a5d96818f73a05529feab2bea",
+ "review_sha256": "cf10b9d7963ff2b7d4c0fbb1fe744a0e853549eaf7ce675a94cc000f74fe6682",
+ "assistant_models": [
+ "claude-opus-5"
+ ],
+ "used_tools": [
+ "Grep",
+ "Read"
+ ],
+ "auxiliary_model_note": "CLI may report small auxiliary model calls in model_usage; assistant_models lists the actual recorded review message models. max was explicitly supplied in the CLI command.",
+ "verdict": "GO_WITH_NONBLOCKING_NOTES",
+ "blocking_findings": 0,
+ "nonblocking_notes_disposition": "Final plan adds Created earlier/later/unchanged acceptance and a comment limiting timestamp normalization to the six fields. No implementation scope change.",
+ "final_plan_sha256": "2ee214694f16e5949ed58290364770149aee3748faee3614122fee189ef83add",
+ "temporary_review_worktree_removed": true,
+ "archive_note": "Workstation paths normalized; billing data and raw model traces omitted. Original hashes are retained in archive-manifest.json."
+}
diff --git a/docs/investigations/issue-77/review/plan-v1.md b/docs/investigations/issue-77/review/plan-v1.md
new file mode 100644
index 000000000..f951e966f
--- /dev/null
+++ b/docs/investigations/issue-77/review/plan-v1.md
@@ -0,0 +1,103 @@
+# Issue #77 最小充分修复计划
+
+版本:v1,2026-09-12。状态:作者自审完成,待 Claude Code Opus 5 Max 对抗评审。
+
+基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本计划只授权设计与评审,不表示已实现、合并或发布。复现材料见 [当前核验](../../issue-77-current.md)。
+
+## 目标与范围
+
+让修复版本间的 Policy、Tags、SSE、Quota、Versioning、Object Lock 桶配置,在合法带源时间的 peer 事件被延迟、重复、乱序或漏发后,能按同一规则应用并由 heal 最终收敛;删除状态必须落盘、能传播,不能被较旧配置复活。桶元数据批量导入的复制入口属于同一范围。
+
+本轮不重做 #91 的计数、#76 的 Object Lock wire 字段、#78 的桶接管、#103/#156 的整记录锁和删除保护。CORS、Lifecycle/expiry、notification、对象数据复制、MRF、resync、IAM 均不改变其语义。支持与验收对象是协调的 PGSTY 栈;不引入为了上游 MinIO 兼容而修改依赖的工作。
+
+## 已确定的行为契约
+
+| 类型 | 专用事件 nil 或解码后空内容 | bulk 字段未提供/null | 真实删除状态导出 |
+| --- | --- | --- | --- |
+| Policy / Tags / SSE / Quota | 删除;保留源时间,删除走现有 parse=false 路径 | 不修改该字段 | Policy 保留现状;另三类受下述单一开关控制 |
+| Versioning / Object Lock | no-op,不能通过 heal 删除配置 | 不修改该字段 | 不新增删除语义 |
+
+合法的空 quota 对象 `{}` 仍按现有 quota 解析语义处理,不能仅因配额为零就改写为 nil。Policy 空策略继续使用现有专用 admin handler 的判空行为。所有既有字段编码、公开 getter 的 NotFound 行为、RPC 返回结构及权限要求保持不变。
+
+1. **读取原始字段时间。** 排序使用 `BucketMetadata` 的原始 `*UpdatedAt` 字段,不能用会隐藏删除时间的公开 getter,也不能用整记录 `lastUpdate()` 代替字段时间。
+2. **创建边界与未知值。** `UpdatedAt == 0` 或 `UpdatedAt == CreatedAt` 的记录不能作为 heal 的权威候选。后者沿用既有创建默认值约定,包括无法恢复真实修改时间的旧记录;全部为此类状态时不写入并给出有界诊断。peer 事件早于当前桶 CreatedAt 时不应用。新产生的实际配置写入时间必须严格大于 CreatedAt。
+3. **带时间事件的确定性次序。** 先比较源时间;相同时间下,真实删除大于 live,live/live 按稳定载荷字节序比较;内容和时间均相同则 no-op。Policy/Quota 使用既有解析后 JSON 编码作为比较表示;XML 使用解码后的实际文档字节。apply 和 heal 使用同一个比较函数。deployment ID 只在完全相同状态下用于选取日志中的来源,不能作为未持久化的冲突元数据。
+4. **本地新操作不倒退。** 对上述六类的实际本地写入,锁内分配 `max(UTCNow(), CreatedAt+1ns, 当前字段时间+1ns)`。其他配置类型保持原有 UTCNow 行为。保留现有 Object Lock 强制 Enabled versioning 的规则,不改变 suspend、prefix exclusion、retention 的合法性。
+5. **nil 与删除区分。** 只有可删除类型的专用事件,或 bulk 中明确提供且按既有规则解析为空的可删除字段,表示删除。bulk 的 nil 必须继续表示未提供。Versioning/Object Lock 的 nil/空输入在任何路径均不能清空配置。
+6. **写入原子性。** 原始读取、比较、修改及保存全部在现有桶 `metadata.lock` 内。更新用现有 parse=true 路径,删除用 parse=false 路径;bulk 保留当前原始读取、最后统一解析保存的方式,避免 Quota 已解析缓存残留。任何字段校验失败不得保存部分 bulk。无状态变化时不保存、不通知;有变化时成功保存一次,解锁后再通知。
+
+## 实施顺序
+
+### 提交 1:原子排序与本地事件时间
+
+改动主要在 `cmd/bucket-metadata-sys.go`、`cmd/site-replication.go` 和 `cmd/admin-bucket-handlers.go`,只增加六类字段的内部访问/比较/更新时间能力,不引入注册表、接口插件或复制框架。
+
+- 复用现有 `updateAndParse` 的加载、类型设置、保存和通知边界,增加内部 peer 源时间输入及是否应用的结果。公开 `Update/Delete` 的签名保持不变;专用 peer handler 的公开 error-only 签名也不变。
+- 六个 peer handler 删除锁外 getter 判旧,使用内部源时间更新/删除路径。对非零源时间执行上述比较,持久化原始源时间而非到达时间;重复和被拒绝的旧事件不落盘。
+- 批量 `PeerBucketMetadataUpdateHandler` 对六类已提供字段在其已有整记录锁内执行同一比较。不能循环调用会再次取得桶锁的公开 handler。保留 bulk 中未提供字段、既有 CORS 分支以及一次保存语义。
+- 普通本地六类写入用字段内单调时间。`enablePeerBucketVersioning` 在实际修改现有配置时也用该时间函数;仅缺失字段的创建 bootstrap 保留 CreatedAt 默认值,不给默认配置伪造一次新修改。
+- **导入生成端一并修复。** 在每桶最终提交锁内,对本次导入涉及的六类字段分配一个共同 `commitAt`:严格大于 CreatedAt 和这些字段现存时间,并且不早于锁内当前时刻。覆盖导入暂存对象中这些字段的时间后,再调用现有 `applyImportedBucketMetadata`。发送 bulk hook 的 UpdatedAt 必须是同一个 commitAt,不能再用整个 ZIP 请求开始时的 updatedAt。未导入字段不改;CORS 继续自己的时间函数和独立事件,Lifecycle/notification 不参与这个时间上界。
+- 原样保留 `saveMetadata` 的物理桶存在检查、桶删除锁序、后台通知上下文和加载/迁移路径。不得另加一套 peer-require-existing 或新锁。
+- 对真实历史桶 `Created == 0`,仅在该少见路径取得现有物理桶 Created 并补齐,再按现有默认时间规则加载;物理桶不存在就返回现有错误。若物理创建时间也未知,不能用本次到达时间伪造桶世代:不应用并报告 indeterminate。
+
+提交 1 的完成条件:六类 peer PUT 落盘保留源时间;四类 DELETE 后旧 PUT 不复活;相同内容重复事件不保存;getter→Update 竞态和 bulk 旧事件覆盖被挡住;本地后续写入可以胜过已有未来源时间;导入的持久化时间与实际发出的事件一致。
+
+### 提交 2:heal 统一选源与状态同步
+
+改动主要在 `cmd/site-replication.go`。
+
+- 先过滤未知/创建默认状态和 update-only 的空候选,再从剩余候选中按相同比较器选最新;必须显式返回 found=false。取消“先用 map 首项初始化,再判断是否默认”的写法。
+- 为 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类 heal 替换对应选源循环。不触碰 CORS、Lifecycle 或对象数据修复路径。
+- 本地 heal 通过提交 1 的源时间更新/删除路径;远端 heal 保留对应事件类型并携带选中状态的源时间,补齐 Tag 的 UpdatedAt。
+- heal 是否写入由完整状态比较决定,不再由公开 mismatch 计数或仅 payload 相等决定。即使内容相同,较旧时间也必须同步;完全相同的状态不保存、不发送。
+- 保留 #91 的 payload/数量统计语义和字段,不为实现时间戳同步更改 madmin API。持续 indeterminate 或因桶创建边界拒绝而留下的状态差异通过提交 3 的日志解释。
+
+提交 2 的完成条件:map 顺序不影响选源;默认状态不能删除真配置;全默认/未知状态不修改磁盘;nil 不清空 Versioning/Object Lock;Quota 删除后磁盘和缓存一致;远端 Tag 时间完整;同内容不同时间、同时间不同内容都能确定性收敛。
+
+### 提交 3:删除传播与有界诊断
+
+使用一个启动配置开关,暂定名称 `MINIO_SITE_REPLICATION_METADATA_CONVERGENCE`,`off` 为默认,`on` 为显式启用。不加入 madmin/silo-pkg 字段、不新增 RPC、不构建通用能力协商。
+
+| 行为 | off:兼容升级阶段 | on:全站点修复后 |
+| --- | --- | --- |
+| 有源时间的 peer apply、锁内排序、heal | 使用提交 1/2 的修复 | 同左 |
+| 专用事件零源时间 | 保留旧版兼容:锁内按本地新操作时间应用,限频记录兼容降级;此类事件不在排序收敛保证内 | 拒绝为无效事件,不写入 |
+| bulk 零源时间 | 维持当前拒绝行为 | 同左 |
+| Tags/SSE/Quota nil payload 时间导出 | 维持旧版 payload 条件导出 | 导出真实删除时间;零/创建默认仍不是删除事件 |
+| Policy 时间导出 | 保持已有行为 | 保持已有行为 |
+| 初次同步的删除状态 | 保持已有行为 | Policy/Tags/SSE/Quota 的真实删除也发送专用 nil + source time 事件 |
+
+这里的开关只隔离新增的删除传播和不再接受无时间事件的行为。它不证明远端能力;启用的明确条件是所有参与站点、每站点全部节点都已部署包含提交 1/2/3 的修复版本,旧请求已排空。配置同一站点内必须一致。任何旧节点仍在线时保持 off。
+
+- exporter 只把可区分的真实删除状态作为新增导出对象,不把新桶默认配置改成删除。Policy 已有导出保留,选择端过滤默认值。
+- `syncToAllPeers` 也使用同一状态判断发送真实删除;不能只修定期元数据查询而遗漏初次同步。保持现有逐类型 RPC 和编码。
+- 复用已有日志去重/限频设施,固定为有限的原因集合:legacy-zero、stale-conflict、before-created、indeterminate。精确重复属于正常 no-op,不记警告;普通顺序已知的旧事件不制造每轮噪音;仅实际错误/持续无法收敛的原因记录桶、配置类型、当前/源时间,不输出完整策略或配置。
+- stale 与 before-created 保持 RPC 成功/no-op;缺桶、非法 payload 和 on 模式下的零时间仍返回相应错误。不开新重试循环。
+- 更新 Server 的 site-replication 文档,写清 off/on、全节点升级条件、零时间兼容降级和回滚限制。回滚/降级前先在所有修复节点关闭该开关,再滚动降级;旧软件原有缺陷会恢复,不宣称回滚后仍具备收敛保证。
+- 旧版本已经记录成到达时间的历史值不能推导回真实源时间。升级完成后,操作者在看到各站点状态后选择权威配置,重新提交需要纠正的配置/删除;不得自动把时间归零或选任意站点强制覆盖。
+
+## 最小验收矩阵
+
+以下均为缺陷或上述修复引入的行为边界;不把发布制品、外部上游服务或不相关产品作为本计划硬门槛。
+
+| 组 | 必须验证 | 验证层 |
+| --- | --- | --- |
+| T1 | 六类 PUT 原样源时间;四类 DELETE;较旧 PUT/DELETE 不回退;精确重复无写入 | 真实 admin 路由 + 两种 ObjectLayer + 磁盘重载 |
+| T2 | 同时间不同 live 状态两种到达顺序结果相同;可删除类型 PUT/DELETE 同时间删除获胜 | 比较器确定性测试 + 代表性真实 handler |
+| T3 | Versioning/Object Lock nil/空不删除;保留 lock→Enabled versioning 约束;空 quota 对象语义 | 类型回归 + 现有 #76/#78 测试 |
+| T4 | 旧 peer 在锁前通过检查,较新写入先提交后旧 peer 再落锁,不能覆盖;并发不同字段均保留 | 确定性锁屏障 + 两种 ObjectLayer,针对性 race |
+| T5 | bulk 有旧字段/新字段/未提供字段混合,分别忽略/应用/保留;非法字段不部分保存;不能借 bulk 复活墓碑 | admin bulk 路由 + 磁盘/缓存 |
+| T6 | 本地操作的时间大于收到的未来时间;两个相邻本地提交不倒序;ZIP 导入期间插入写入,最终提交字段与发出的 bulk 时间完全一致 | 本地 API、import 路由、捕获 RPC |
+| T7 | 多站点排序排列、默认/零时间、全部无候选、空 update-only;同 payload 新时间、同时间冲突;Tag 出站源时间;Quota nil 后缓存清空 | heal + 本地/远端路径 |
+| T8 | 保存/缓存失效/进程重启后墓碑仍有效;缺桶/排队写入/真实零 Created 历史桶;on/off 与新旧发送端混合;初次同步携带真实删除 | ObjectLayer、现有删除测试、固定旧版与修复版两个 SILO 进程 |
+| T9 | 短暂断线、事件漏发/重复/乱序后重新连通,六类允许状态收敛;开关 on 后旧零时间事件失败且无修改;日志有界 | 隔离的双站点/多节点实验 |
+
+实现期间先把已有审计用例改成确定性正式回归:不能将反复遍历 map 的概率复现直接提交为生产测试。先记录未修复失败,再验证修复后通过。完成局部修复后运行相关现有回归、目标 race、仓库必需的格式/静态检查与完整 Go CI;同一基准通过后不因形式要求反复全量测试。
+
+## 作者自审结论
+
+- 必要:六类共享的是字段排序与锁内写入;删除、编码、bulk 未提供字段和 Object Lock 约束仍按类型处理。
+- 充分:普通本地写、专用 peer、bulk/import、local/remote heal、导出、初次同步、重载均在清单内。没有继续保留绕过排序的实际写入入口。
+- 最小:复用一把已有锁、现有持久化和通知、一套小的字段比较逻辑;只加一个针对实际混合版本风险的开关。没有存储 schema、SDK、Console、客户端或新协议依赖。
+- 明确限制:off 模式、无时间旧事件、任意未升级节点和已污染的历史时间不承诺自动收敛;on 模式的验收仅针对修复 PGSTY 栈的合法状态与已知桶世代。
+- 当前只批准进入外部方案评审。任何对抗评审发现的具体遗漏或多余工作,应先修订本文件并复审,再决定进入实现。
diff --git a/docs/investigations/issue-77/review/plan-v2.md b/docs/investigations/issue-77/review/plan-v2.md
new file mode 100644
index 000000000..7915aa772
--- /dev/null
+++ b/docs/investigations/issue-77/review/plan-v2.md
@@ -0,0 +1,118 @@
+# Issue #77 最小充分修复计划
+
+版本:v2,2026-09-12。状态:已处理首轮对抗评审并完成作者复核,待 Claude Code Opus 5 Max 复审。
+
+基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),首轮意见及处置见 [评审处置](decisions-v2.md)。
+
+## 目标与边界
+
+修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
+
+在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
+
+不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
+
+## 行为契约
+
+### 1. 明确事件、缺省值与删除
+
+| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
+| --- | --- | --- | --- |
+| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
+| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
+| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
+| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
+
+“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
+
+合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
+
+**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
+
+### 2. 统一状态与排序
+
+内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
+
+- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
+- 在已知 Created 下,零时间或 `fieldTime <= Created` 是 baseline;它不能成为 heal 权威源,也不能作为带时间 peer 更新覆盖实际状态。Versioning/Object Lock 的空候选无论时间如何都不参与选源。
+- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
+- 非零事件不晚于目标桶 Created 时成功/no-op,记录 before-created。heal 同时过滤源端 baseline;对候选时间不晚于目标 Created 的目标跳过,不能每轮继续发送必然拒绝的 RPC。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
+- 有效状态先按源时间排序;同时间 tombstone 胜 live;同时间 live/live 的**稳定比较键字节序较大者胜**。键与时间相同为 no-op。同状态来源按现有稳定遍历/选源方式取即可,deployment ID 不参与持久化冲突裁决。
+- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对 JSON 对象键和集合数组递归排序,包括 Statement、Action、Resource、Principal、Condition 值。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不为此修改 silo-pkg 或 wire/schema。
+- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
+
+### 3. 先得到有效状态,再比较与提交
+
+原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
+
+- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
+- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
+- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
+- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
+- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
+
+## 三个实现提交
+
+### 提交 1:原子 apply、发送一致性与本地时间
+
+主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
+
+1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
+2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
+3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
+4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
+5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
+
+完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
+
+### 提交 2:heal 选源与应用同规则
+
+主要文件:`cmd/site-replication.go`。
+
+1. 先过滤 baseline、无效来源和 update-only 空配置,再选最大状态;无候选必须显式返回 found=false。消除六处“先 seed map 首项,再过滤默认值”的写法。
+2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
+3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
+4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
+5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
+
+完成条件:map 顺序不影响结果;默认值不再删真配置;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;第二轮稳定 heal 无写入/广播。
+
+### 提交 3:新增删除传播与有界诊断
+
+只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
+
+| 行为 | off:升级阶段 | on:所有参与节点修复后 |
+| --- | --- | --- |
+| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
+| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
+| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
+| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
+| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
+
+开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。它不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
+
+日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
+
+Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
+
+## 最小验收矩阵
+
+| 组 | 必须覆盖 | 证据方式 |
+| --- | --- | --- |
+| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota、空 Policy 的落盘/出站三元组一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
+| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多 Action/Resource/Principal/Condition 集合反复编码与排列后键稳定 | 确定性比较器测试与代表性真实 handler |
+| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
+| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
+| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
+| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
+| T7 | 多站点排序排列、baseline/全无候选、空 update-only;同内容新时间;Tag 源时间;Quota 删除清缓存;空 deployment ID 不阻断其它目标;世代冲突跳过且不反复发 RPC | 确定性 heal 本地/远端用例 |
+| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created 历史桶;开关 on/off、legacy-zero 与初次同步删除 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
+| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
+
+固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
+
+## 作者复核结论
+
+三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
+
+首轮三个 P1 意见均已有明确处置:R1 修发送/保存一致性;R2 使用有效状态和提交快照;R3 给出经过实际编解码验证的逐字段规则,而非照搬其不准确的 RawMessage 推断。作者另补充了 Policy 非确定性编码与日志去重问题。外部复审未返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
diff --git a/docs/investigations/issue-77/review/plan-v3.md b/docs/investigations/issue-77/review/plan-v3.md
new file mode 100644
index 000000000..a0d685777
--- /dev/null
+++ b/docs/investigations/issue-77/review/plan-v3.md
@@ -0,0 +1,120 @@
+# Issue #77 最小充分修复计划
+
+版本:v3,2026-09-12。状态:首轮三个阻断已被 Opus 复审确认关闭;已修订第二轮发现的历史桶初始化边界,待最后的针对性复审。
+
+基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)。
+
+## 目标与边界
+
+修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
+
+在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
+
+不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
+
+## 行为契约
+
+### 1. 明确事件、缺省值与删除
+
+| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
+| --- | --- | --- | --- |
+| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
+| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
+| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
+| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
+
+“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
+
+合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
+
+**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
+
+### 2. 统一状态与排序
+
+内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
+
+- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
+- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
+- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
+- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
+- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。同状态来源不参与持久化冲突裁决。
+- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
+- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
+
+### 3. 先得到有效状态,再比较与提交
+
+原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
+
+- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
+- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
+- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
+- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
+- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
+
+## 三个实现提交
+
+### 提交 1:原子 apply、发送一致性与本地时间
+
+主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
+
+1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
+2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
+3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
+4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
+5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
+
+完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
+
+### 提交 2:heal 选源与应用同规则
+
+主要文件:`cmd/site-replication.go`。
+
+1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
+2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
+3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
+4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
+5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
+
+完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
+
+### 提交 3:新增删除传播与有界诊断
+
+只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
+
+| 行为 | off:升级阶段 | on:所有参与节点修复后 |
+| --- | --- | --- |
+| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
+| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
+| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
+| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
+| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
+
+开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
+
+开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
+
+日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
+
+Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
+
+## 最小验收矩阵
+
+| 组 | 必须覆盖 | 证据方式 |
+| --- | --- | --- |
+| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
+| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
+| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
+| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
+| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
+| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
+| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
+| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间全部等于 Created 的桶经初次同步和一轮 heal 后一致;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
+| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
+
+固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
+
+## 作者复核结论
+
+三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
+
+Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,并确认作者的 Policy 编码和日志去重补查。v3 修正第二轮新增的历史桶 baseline-live 初始化回归,补齐相关验收与 off 模式说明,保留三个实现提交。最后针对性复审返回前不标记为可进入实现;后续仍须用实现期测试证明,而非把本计划当作修复完成。
diff --git a/docs/investigations/issue-77/review/plan-v4.md b/docs/investigations/issue-77/review/plan-v4.md
new file mode 100644
index 000000000..af3af5e80
--- /dev/null
+++ b/docs/investigations/issue-77/review/plan-v4.md
@@ -0,0 +1,121 @@
+# Issue #77 最小充分修复计划
+
+版本:v4,2026-09-12。状态:Opus 已对 v3 给出 GO_WITH_NONBLOCKING_NOTES、0 阻断;作者追加实测发现接管时默认时间需随 Created 保持 baseline,补入提交 1 后待该增量复核。
+
+基准:`pgsty/silo` main `5c576581631561c446f30ae5b566f0aa793adc1c`。本轮交付是方案与评审,不表示已经实现、合并或发布。问题证据见 [当前核验](../../issue-77-current.md),意见处置见 [首轮处置](decisions-v2.md)、[第二轮处置](decisions-v3.md)、[接管边界补查](decisions-v4.md)。
+
+## 目标与边界
+
+修复 Policy、Tags、SSE、Quota、Versioning、Object Lock 六类桶配置的源时间丢失、锁外判旧、heal 错选源和删除传播不完整。覆盖普通本地写入、专用 peer 事件、bulk/import、local/remote heal、元数据导出与初次同步。
+
+在修复 PGSTY 栈、同一已知桶世代、合法带源时间事件的范围内,使重复、乱序和漏发后的状态能够确定性收敛。开关关闭期间新增删除信息不可见,不承诺完整删除收敛;无时间旧事件、旧版污染时间和桶创建世代冲突需要单独解释,不能自动推断历史真相。
+
+不重做 #91 的计数、#76 的 Object Lock wire 修复、#78 的桶接管、#103/#156 的锁与删除保护。不改变 CORS、Lifecycle/expiry、notification、对象复制、MRF、resync、IAM 的语义;不改存储 schema、SDK、Console、mcli 或 silo-pkg,不新建能力协商、复制框架、锁或重试系统。
+
+## 行为契约
+
+### 1. 明确事件、缺省值与删除
+
+| 类型 | 专用事件 | bulk 字段未提供 | bulk 字段明确提供 |
+| --- | --- | --- | --- |
+| Policy | nil 为删除;沿用现有解析器 `IsEmpty()` 为删除 | 保留 | 非空 RawMessage 按现有解析器处理;语义空策略归一为删除 |
+| Quota | nil 为删除;非 nil 按现有 quota 解析器处理 | 保留 | 非空 RawMessage 按现有解析器处理,零 quota 仍是 live 文档 |
+| Tags / SSE | nil 或 base64 解码后空内容为删除 | 保留 | 空字符串为删除,其他内容按原规则解码、校验 |
+| Versioning / Object Lock | nil/空内容为 no-op | 保留 | nil/空内容仍为 no-op,不能清空配置 |
+
+“未提供”必须依据真实 wire 类型判定:Policy/Quota 的 `json.RawMessage` 为 nil 或空切片时经 `omitempty` 省略;**显式 JSON `null` 解码后是非 nil 的 `[]byte("null")`**,不能与缺省混淆。按现有解析器,Policy `null` 是语义空策略,Quota `null` 是零值 quota 文档。`*string` 类型的 JSON `null` 则解码为 nil。不得使用统一的 `len(payload)==0 => 删除` 来处理 bulk。
+
+合法 `{}`/零值 quota 保持 live;取消普通 quota PUT 出站时“零配额改写为 nil”的逻辑,保存与发送同一个语义状态。Policy 保留现有专用 peer 的“空策略=删除”解释,本地 PUT、导入和 bulk 统一归一为同样的删除状态;这是需要写入兼容说明的小范围变化:空策略本地立即按删除处理,GET 返回既有 NotFound 行为,不再先保留空文档、等复制后才被清除。
+
+**同一次操作的落盘状态与出站事件,经同一归一规则后,必须具有相同的 `(kind, payload key, source time)`。** JSON 的无意义编码次序不要求字节相同;Object Lock 改写后的有效 Versioning 文档必须来自提交结果。
+
+### 2. 统一状态与排序
+
+内部仅需一个小状态表示:baseline / live / tombstone,以及比较键和字段源时间。baseline 可携带历史有效配置,但没有真实修改时间;它绝不能被当成删除。复用 CORS 已有设计思路,不改 CORS 本身或扩展为通用框架。
+
+- 使用 `BucketMetadata` 原始字段时间;字段时间为零时在比较视图中补为 Created,与 `defaultTimestamps()` 一致,不借用会隐藏墓碑时间的 getter 或整记录 `lastUpdate()`。
+- 在已知 Created 下,零字段时间回退后等于 Created 的状态是 baseline:有有效非空配置的 baseline-live 可作为初始化候选;空 baseline 只是缺省值,不能作为删除或 heal 来源。真正严格早于自身 Created 的字段不是候选;Versioning/Object Lock 的空候选无论时间如何都不参与选源。真实 live/tombstone 的时间必须大于 Created。
+- 专用 peer 的零时间保留兼容例外:锁内按本地新操作分配时间并限频记录 legacy-zero;不受删除传播开关影响,不在源时间排序保证之内。bulk 零时间仍按现状拒绝。
+- 非零事件**严格早于**目标桶 Created 才成功/no-op 并记录 before-created,heal 对这种目标跳过。等于目标 Created 的 live 事件可更新仍是 baseline 的目标字段,时间仍保存为源 Created,不盖上到达时间;同时间的空/nil 只算空 baseline,不能删除配置。不同桶世代不能自动合并,需要运维处理,不纳入收敛承诺。
+- 排序先比较是否为真实状态:任何真实 live/tombstone 都胜 baseline,不能让较晚创建的默认值压过较早的真实修改。真实状态之间再比较源时间,同时间 tombstone 胜 live、live/live 的**稳定比较键字节序较大者胜**。baseline-live 只胜空 baseline,或在 baseline-live 之间按同一稳定键较大者胜;永远不能覆盖真实 live/tombstone。所有候选都是 baseline-live 时仍可确定性初始化并收敛;全部为空 baseline 才安静 found=false。键、状态级别和源时间相同为 no-op。deployment ID 不参与上述比较,也不作为新字段保存。
+- Quota 使用现有解析结果的 JSON 编码作为比较键。Policy 在 Server 内生成确定性比较表示:既有解析器校验/去重后,对其**完整 JSON 树**的对象键和集合数组递归排序,统一覆盖 Statement、Action/NotAction、Resource/NotResource、Principal、Condition;保留数字类型和精度。比较键必须是已解析策略的纯函数,同一配置从两个站点分别解析也必须得到相同键。普通 `json.Marshal(BucketPolicy)` 不稳定,不能直接作键;不增加依赖不支持的 NotPrincipal 语法,不改 wire/schema。新状态比较仅使用这一套键:不把忽略 Sid 且对 Statement 顺序敏感的 `BucketPolicy.Equals` 再叠加为另一套判等规则,既有公开统计对 Equals 的使用保持不变。
+- XML 使用有效文档字节,保留大小写和实际内容;Versioning 先应用下述现有 Object Lock 约束。比较器不能依靠字节序方向来补偿保存阶段的隐式改写。
+
+### 3. 先得到有效状态,再比较与提交
+
+原始读取、类型处理、比较、修改及保存均在现有 `metadata.lock` 内。内部入口必须传递现有 lock context,避免 legacy migration 再次取锁。
+
+- 提取并复用 `parseAllConfigs` 已有的 Object Lock→Enabled Versioning 归一规则,使比较视图和真正保存一致;不得扩大 suspend、prefix exclusion、retention 的限制。bulk 先确定实际接受的 Object Lock,再比较该约束下的 Versioning,并在最终提交前应用同一规则。
+- 以归一化后的有效 `(kind, key, time)` 判定变化;更新了时间也算变化。完全重复不保存、不通知;一次 bulk 校验失败不保存部分结果;成功至多保存一次,解锁后通知。
+- 可删除字段清空必须基于现有 parse=false 的新加载对象,不能在已经解析且仍持有旧 quota 的对象上执行 Update(nil)。bulk 保留原始读取再解析保存的方式。不要顺手修改 Quota getter 或所有 `parseAllConfigs` 空分支。
+- 保存函数必须让需要发送 hook 的调用方拿到**本次提交的最终快照**,不能先解锁再读取“最新”状态拼接旧时间。最小做法是让内部 `saveMetadata` 接收元数据指针并回写 Save 的归一化结果,机械更新现有少量调用;对外 `Update/Delete` 签名不变,新增内部提交结果仅供需要该快照的本地 handler/import 使用。不得原地修改已发布到缓存的引用字段。
+- 保留现有物理桶存在检查、删除锁序、迁移、后台通知上下文。真实历史桶 Created 为零时仅走少见的物理桶 Created 补齐路径;物理桶缺失返回现有错误;创建时间仍未知则不伪造到达时间,报告 indeterminate。
+
+## 三个实现提交
+
+### 提交 1:原子 apply、发送一致性与本地时间
+
+主要文件:`cmd/bucket-metadata-sys.go`、`cmd/bucket-metadata.go`、`cmd/site-replication.go`、`cmd/admin-bucket-handlers.go`,以及实际需要提交快照的本地配置 handler。
+
+1. 在现有 update/delete 内部路径增加源时间、状态比较和提交结果;公开签名不变。六个 peer handler 移除锁外 getter 判旧,锁内持久化原始源时间。保留 Object Lock 的 legacy Tags 字段载荷回退。
+2. bulk 对明确提供的六类字段在已有锁内逐字段比较,再原子保存;未提供字段不动,不能循环调用会重入锁的公开 handler。保留 CORS 独立分支与既有行为。
+3. 六类本地实际写入在锁内分配 `max(UTCNow(), Created+1ns, 当前字段时间+1ns)`。其他类型不变。`enablePeerBucketVersioning` 的实际变更也使用它,只有缺失配置的创建 bootstrap 继续 Created 默认值。
+ 接管已有桶时保留原 Created,再执行现有 `SetCreatedAt`;如果 Created 改变,仅将这六类中原本为零或等于原 Created 的默认字段时间调整到新 Created,随后再做既有 versioning/lock bootstrap。原本晚于旧 Created 的真实修改/删除时间及其配置保持不变。不能仅凭 payload 为 nil 判断默认值。该小分支用于防止原默认时间因 Created 前移被误识别为墓碑,复用已有接管锁,不重做 #78 的配置保护。
+4. quota 本地 PUT 保留零值文档并原样表示该语义;Policy 空策略本地与 peer 一致走删除。需要归一化的本地 handler 从本次提交快照生成 hook;其他内容不发生归一变化的路径可保留既有编码,但必须满足三元组一致性。
+5. 导入在每桶最终提交锁内,为本次涉及的六类字段生成共同 commitAt,严格大于 Created 和这些字段当前时间且不早于锁内现在。该时间同时用于落盘和 bulk hook,不能沿用 ZIP 开始时间。bulk hook 从最终提交快照构建;若导入的空 Policy 已归一成删除,另外使用现有专用 Policy nil 事件表达它,不能因 `omitempty` 漏发。未导入字段不改,Object Lock 的既有派生 Versioning 修正保留原时间语义;CORS 继续独立时间/事件,其他字段不参与该上界。
+
+完成条件:六类源时间落盘;旧事件不能越过锁覆盖新状态;四类删除不会被旧 PUT 复活;真实 wire、落盘和出站状态一致;重复无写入;bulk 与 import 没有绕过排序或静默遗漏删除。
+
+### 提交 2:heal 选源与应用同规则
+
+主要文件:`cmd/site-replication.go`。
+
+1. 先过滤空 baseline、无效来源、严格早于自身 Created 的字段和 update-only 空配置,再按上述强弱排序选最大状态;无候选必须显式返回 found=false。保留历史 baseline-live 的初次同步和 heal 能力,消除六处“先 seed map 首项,再过滤默认值”的写法。
+2. 选源和目标遍历都跳过 `info.Sites` 中不存在的 deployment ID,包括不可达站点的空 ID 占位项;单一 peer 失败记录后继续其它目标,不因 map 顺序放弃健康站点。不改变状态计数或新建重试机制。
+3. 本地 heal 使用提交 1 的源时间 update/delete;远端仍用原有逐类型 RPC,全部携带源时间,补齐 Tag 的 UpdatedAt。
+4. 比较完整有效状态,去掉公开 mismatch/payload-only 对写入的门控;同内容较旧时间也同步。对已归一且相同的目标不写入、不发 RPC。Versioning 比较使用与该站点 Object Lock 一致的有效文档;全站点 Lock 状态补齐后不再因旧原始文档产生空转。
+5. 保留 #91 的计数与公开字段;创建世代冲突、无可用来源通过有限诊断解释。已知 baseline 且各站点无实质差异时安静 no-op。
+
+完成条件:map 顺序不影响结果;默认空值不再删真配置;历史 baseline-live 可以初始化新站点、不能覆盖真实状态;同内容不同时间、同时间冲突最终一致;Tag 时间完整;Quota 删除后磁盘、缓存、重载一致;不可达占位项不阻断健康目标;完整状态已可见且稳定时,第二轮 heal 无写入/广播。
+
+### 提交 3:新增删除传播与有界诊断
+
+只增加一个启动开关,暂定 `MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off/on`,默认 off;实现沿用现有 env 开关写法,不做能力协商。
+
+| 行为 | off:升级阶段 | on:所有参与节点修复后 |
+| --- | --- | --- |
+| 带时间 peer apply、锁内排序与 heal | 使用提交 1/2 | 同左 |
+| 专用事件零时间 | 兼容应用并记录 legacy-zero | 同左,不新增协议拒绝 |
+| Tags/SSE/Quota nil payload 时间导出 | 保留旧版条件导出 | 导出 `time > Created` 的真实删除时间 |
+| Policy 时间导出 | 保留已有行为 | 保留已有行为 |
+| 初次同步的真实删除 | 保留已有行为 | Policy/Tags/SSE/Quota 都发送专用 nil + source time 事件 |
+
+开关只控制**新增**删除信息的导出/初次发送,普通本地删除事件照常复制。**off 不等于禁止删除传播:Policy 墓碑原本已导出,修复后在 off 下也照常参与 heal;Tags/SSE/Quota 的新增墓碑信息才被门控。** off 期间这些字段的隐藏墓碑会使 heal 继续尝试过时 RPC,由接收端排序拒绝;这是状态不可见时的已知代价,不承诺第二轮零 RPC,也不为这种正常拒绝增加每轮日志。
+
+开关不检测或证明远端能力。启用条件是所有参与站点的全部节点已经修复,同一站点配置一致,旧请求排空;旧节点仍在线时保持 off。此隔离有实证依据:旧版接到新增 Quota heal 墓碑会留下已解析缓存残留。
+
+日志仅保留三个实际原因:legacy-zero、before-created、indeterminate(未知创建时间、缺失/不可达来源或有实际差异却无可用候选)。精确重复、正常旧事件和成功裁决的同时间冲突不记警告。复用 `LogOnceIf`,以稳定的桶/字段/原因作为 key,**错误正文也必须稳定**;变化的时间与 peer 详情放入日志 ReqInfo,沿用现有每小时清理,不新增限流框架、不输出完整策略。
+
+Server 文档解释启用顺序和回滚:降级前所有修复节点先关开关,然后滚动降级;旧软件缺陷会恢复。点名旧版 Tag heal 无 UpdatedAt 的来源。旧版到达时间污染、legacy-zero 产生的新本地时间以及创建世代分歧无法自动还原;操作者查看状态后在权威站点重新提交需要纠正的配置/删除。历史世代冲突先处理桶身份,不能靠任意站点强刷绕过创建保护。
+
+## 最小验收矩阵
+
+| 组 | 必须覆盖 | 证据方式 |
+| --- | --- | --- |
+| T1 | 六类 PUT 源时间;四类 DELETE;旧事件不回退;重复无写入;零 quota 三元组一致;带合法 Version 的空 Policy PUT 成功、GET NotFound、专用删除事件与落盘一致 | 真实 admin/S3 路由、ErasureSD/Erasure16、磁盘重载、RPC 捕获 |
+| T2 | 同时间两种到达顺序结果相同、删除优先;Policy 多集合及 NotAction/NotResource/Condition 反复编码与排列后,两站点独立解析得到相同键;Sid 差异及大整数不被错误合并 | 确定性比较器测试与代表性真实 handler |
+| T3 | Versioning/Object Lock nil/空 no-op;legacy Tags 载荷回退;Object Lock + prefix exclusion/ExcludeFolders 在普通写、peer、bulk/import 后有效状态一致,第二轮 heal 无额外写入 | 原有 #76/#78 回归加针对性用例 |
+| T4 | 锁前旧事件排队、较新写先提交后旧事件不得覆盖;不同字段并发均保留 | 复用已有 `lockBucketMetadataAcquireHook` / RMW 屏障、两种 ObjectLayer、目标 race |
+| T5 | bulk 新/旧/缺省字段混合;真实 JSON 编解码的 nil、空 RawMessage、显式 null、空字符串、空策略、零 quota;非法字段不部分保存;只导入 tags 不修改 Policy/Quota | 真实 bulk 路由、缓存与磁盘 |
+| T6 | 本地时间胜过已有未来时间;相邻提交不倒序;ZIP 导入期间插入写入,最终落盘和发出事件的状态与时间一致,空 Policy 删除不漏发 | 本地 API、import 路由与 RPC 捕获 |
+| T7 | map 排列、空 baseline/全无候选;baseline-live 初始化与同级冲突收敛,但不能覆盖真实 live/tombstone;nil@Created 不删除配置;空 update-only;Tag 时间、Quota 清缓存、空 ID、世代冲突 | 确定性 heal 本地/远端用例 |
+| T8 | 墓碑经保存/缓存失效/重启仍有效;缺桶、排队写入、零 Created;历史字段时间等于 Created 的桶经初次同步、一轮 heal 后一致,第二轮无写入/广播;接管前移 Created 后默认时间仍是 baseline,真实 PUT/DELETE 时间不变;on/off 与 legacy-zero,off 下 Policy 仍 heal、其它隐藏墓碑允许被拒 RPC 但无每轮警告 | ObjectLayer 与进程内旧版 wire/SRInfo 模拟;复用既有删除/迁移回归 |
+| T9 | 修复版双站点短暂断线、漏发/重复/乱序后六类合法配置收敛;删除传播启用后第二轮稳定无写入;日志确实有界 | 隔离双站点实验,不把未知桶世代或零时间事件算成通过 |
+
+固定旧版与修复版混合进程仅作一次性升级冒烟,不新增为长期提交门槛。将已有审计用例改成正式确定性回归,不能把遍历 map 的概率复现直接提交。先记录未修复失败,再验证通过;复用原有 Object Lock、adoption、metadata-lock、计数、CORS 回归,并在实现后运行目标 race、仓库必需检查和完整 Go CI。方案审查、局部测试、双站点结果、合并与发布是不同证据。
+
+## 作者复核结论
+
+三个提交分别处理写入正确性、heal 收敛和新增删除信息的升级边界;每项对应已证实缺陷或本次修复直接触及的实际路径。缩减了拒绝零时间的新协议行为、混合版本长期测试门槛和新锁屏障;保留已有持久化、通知与缓存语义。
+
+Opus 第二轮已确认首轮 R1/R2/R3 实质关闭,第三轮确认 v3 的历史桶初始化边界修正,给出 0 阻断。作者随后在两个真实 ObjectLayer 上补查到接管改变 Created 会把旧默认时间变成假墓碑,v4 仅补充默认时间随接管保持 baseline 的分支与回归。所有变更仍在三个实现提交内;该增量复审返回前不标记最终方案通过,后续仍须用实现期测试证明,而非把本计划当作修复完成。
diff --git a/docs/investigations/issue-77/review/policy-encoding-probe.go.txt b/docs/investigations/issue-77/review/policy-encoding-probe.go.txt
new file mode 100644
index 000000000..b9f43a6f0
--- /dev/null
+++ b/docs/investigations/issue-77/review/policy-encoding-probe.go.txt
@@ -0,0 +1,14 @@
+package main
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "github.com/pgsty/silo-pkg/v3/policy"
+)
+func main() {
+ p,err:=policy.ParseBucketPolicyConfig(strings.NewReader(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject","s3:PutObject"],"Resource":["arn:aws:s3:::issue77-test/a/*","arn:aws:s3:::issue77-test/b/*"]}]}`),"issue77-test")
+ if err!=nil {panic(err)}
+ variants:=map[string]int{}
+ for i:=0;i<100;i++ {b,err:=json.Marshal(p);if err!=nil{panic(err)};variants[string(b)]++}
+ fmt.Printf("100 encodings of the same parsed BucketPolicy produced %d distinct byte sequences\n",len(variants))
+}
diff --git a/docs/investigations/issue-77/review/wire-state-probe.go.txt b/docs/investigations/issue-77/review/wire-state-probe.go.txt
new file mode 100644
index 000000000..eb77b6b53
--- /dev/null
+++ b/docs/investigations/issue-77/review/wire-state-probe.go.txt
@@ -0,0 +1,24 @@
+package main
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "github.com/minio/madmin-go/v3"
+ "github.com/pgsty/silo-pkg/v3/policy"
+)
+func main() {
+ for _, raw := range []string{`{"bucket":"b"}`, `{"bucket":"b","policy":null,"quota":null}`, `{"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}`} {
+ var x madmin.SRBucketMeta
+ if err:=json.Unmarshal([]byte(raw),&x);err!=nil{panic(err)}
+ fmt.Printf("wire=%s\nPolicy nil=%t bytes=%q; Quota nil=%t bytes=%q\n",raw,x.Policy==nil,[]byte(x.Policy),x.Quota==nil,[]byte(x.Quota))
+ if len(x.Policy)>0 && !bytes.Equal(x.Policy,[]byte("null")) {
+ p,err:=policy.ParseBucketPolicyConfig(bytes.NewReader(x.Policy),"b")
+ if err!=nil{panic(err)}
+ fmt.Printf("Policy parses successfully, IsEmpty=%t\n",p.IsEmpty())
+ }
+ }
+ for _, v := range []json.RawMessage{nil,{},[]byte("null")} {
+ b,err:=json.Marshal(madmin.SRBucketMeta{Bucket:"b",Policy:v});if err!=nil{panic(err)}
+ fmt.Printf("encoded raw=%q -> %s\n",[]byte(v),b)
+ }
+}
diff --git a/docs/investigations/issue-77/review/wire-state-probe.log b/docs/investigations/issue-77/review/wire-state-probe.log
new file mode 100644
index 000000000..592373551
--- /dev/null
+++ b/docs/investigations/issue-77/review/wire-state-probe.log
@@ -0,0 +1,10 @@
+wire={"bucket":"b"}
+Policy nil=true bytes=""; Quota nil=true bytes=""
+wire={"bucket":"b","policy":null,"quota":null}
+Policy nil=false bytes="null"; Quota nil=false bytes="null"
+wire={"bucket":"b","policy":{"Version":"2012-10-17","Statement":[]},"quota":{}}
+Policy nil=false bytes="{\"Version\":\"2012-10-17\",\"Statement\":[]}"; Quota nil=false bytes="{}"
+Policy parses successfully, IsEmpty=true
+encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
+encoded raw="" -> {"type":"","bucket":"b","updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
+encoded raw="null" -> {"type":"","bucket":"b","policy":null,"updatedAt":"0001-01-01T00:00:00Z","expiryUpdatedAt":"0001-01-01T00:00:00Z"}
diff --git a/docs/investigations/issue-77/runtime/converged.json b/docs/investigations/issue-77/runtime/converged.json
new file mode 100644
index 000000000..e7e392f5d
--- /dev/null
+++ b/docs/investigations/issue-77/runtime/converged.json
@@ -0,0 +1 @@
+[null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z",null,"2026-09-12T09:14:44.759058Z","PFZlcnNpb25pbmdDb25maWd1cmF0aW9uIHhtbG5zPSJodHRwOi8vczMuYW1hem9uYXdzLmNvbS9kb2MvMjAwNi0wMy0wMS8iPjxTdGF0dXM+RW5hYmxlZDwvU3RhdHVzPjwvVmVyc2lvbmluZ0NvbmZpZ3VyYXRpb24+","2026-09-12T09:13:44.759058Z","PE9iamVjdExvY2tDb25maWd1cmF0aW9uPjxPYmplY3RMb2NrRW5hYmxlZD5FbmFibGVkPC9PYmplY3RMb2NrRW5hYmxlZD48UnVsZT48RGVmYXVsdFJldGVudGlvbj48TW9kZT5HT1ZFUk5BTkNFPC9Nb2RlPjxEYXlzPjMwPC9EYXlzPjwvRGVmYXVsdFJldGVudGlvbj48L1J1bGU+PC9PYmplY3RMb2NrQ29uZmlndXJhdGlvbj4=","2026-09-12T09:13:44.759058Z"]
\ No newline at end of file
diff --git a/docs/investigations/issue-77/runtime/main.go b/docs/investigations/issue-77/runtime/main.go
new file mode 100644
index 000000000..e0282400d
--- /dev/null
+++ b/docs/investigations/issue-77/runtime/main.go
@@ -0,0 +1,333 @@
+//go:build ignore
+
+// Standalone disposable loopback-lab driver; see ../../issue-77.md.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/minio/madmin-go/v3"
+ minio "github.com/minio/minio-go/v7"
+ "github.com/minio/minio-go/v7/pkg/credentials"
+ "github.com/minio/minio-go/v7/pkg/tags"
+)
+
+const bucket = "issue77-runtime"
+const user = "issue77-lab-admin"
+const password = "issue77-disposable-lab-secret"
+
+var ctx = context.Background()
+var root string
+
+type site struct {
+ name, address, binary, gate string
+ cmd *exec.Cmd
+ log *os.File
+ proxy *httptest.Server
+ block atomic.Bool
+ calls atomic.Int64
+ dropped atomic.Int64
+ admin *madmin.AdminClient
+ s3 *minio.Client
+}
+
+func check(err error) {
+ if err != nil {
+ panic(err)
+ }
+}
+func stamp(s string) { fmt.Println(time.Now().UTC().Format(time.RFC3339), s) }
+func port() string {
+ l, e := net.Listen("tcp", "127.0.0.1:0")
+ check(e)
+ s := l.Addr().String()
+ check(l.Close())
+ return s
+}
+func newSite(name, binary string) *site {
+ s := &site{name: name, address: port(), binary: binary, gate: "on"}
+ target, e := url.Parse("http://" + s.address)
+ check(e)
+ reverse := httputil.NewSingleHostReverseProxy(target)
+ reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { w.WriteHeader(503) }
+ s.proxy = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "/site-replication/peer/bucket-meta") {
+ data, e := io.ReadAll(r.Body)
+ check(e)
+ r.Body = io.NopCloser(bytes.NewReader(data))
+ var event madmin.SRBucketMeta
+ check(json.Unmarshal(data, &event))
+ if event.Bucket == bucket {
+ s.calls.Add(1)
+ if s.block.Load() {
+ s.dropped.Add(1)
+ w.WriteHeader(503)
+ return
+ }
+ }
+ }
+ reverse.ServeHTTP(w, r)
+ }))
+ s.admin, e = madmin.New(s.address, user, password, false)
+ check(e)
+ s.s3, e = minio.New(s.address, &minio.Options{Creds: credentials.NewStaticV4(user, password, ""), Secure: false})
+ check(e)
+ return s
+}
+func (s *site) start() {
+ var e error
+ s.log, e = os.OpenFile(filepath.Join(root, s.name+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
+ check(e)
+ args := []string{"--json", "server", "--quiet", "--address", s.address, "--console-address", port()}
+ for i := 0; i < 4; i++ {
+ dir := filepath.Join(root, s.name, fmt.Sprintf("disk%d", i))
+ check(os.MkdirAll(dir, 0700))
+ args = append(args, dir)
+ }
+ s.cmd = exec.Command(s.binary, args...)
+ s.cmd.Env = append(os.Environ(), "MINIO_ROOT_USER="+user, "MINIO_ROOT_PASSWORD="+password, "MINIO_BROWSER=off", "MINIO_CI_CD=1", "MINIO_SITE_REPLICATION_METADATA_TOMBSTONES="+s.gate)
+ s.cmd.Stdout = s.log
+ s.cmd.Stderr = s.log
+ check(s.cmd.Start())
+ client := http.Client{Timeout: time.Second}
+ deadline := time.Now().Add(50 * time.Second)
+ for time.Now().Before(deadline) {
+ r, e := client.Get("http://" + s.address + "/minio/health/ready")
+ if e == nil {
+ r.Body.Close()
+ if r.StatusCode == 200 {
+ stamp(s.name + " ready gate=" + s.gate)
+ return
+ }
+ }
+ time.Sleep(time.Second)
+ }
+ panic(s.name + " startup timed out; inspect " + s.log.Name())
+}
+func (s *site) stop() {
+ if s.cmd == nil {
+ return
+ }
+ _ = s.cmd.Process.Signal(os.Interrupt)
+ done := make(chan error, 1)
+ go func() { done <- s.cmd.Wait() }()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ _ = s.cmd.Process.Kill()
+ <-done
+ }
+ s.log.Close()
+ s.cmd = nil
+}
+func (s *site) info() madmin.SRBucketInfo {
+ i, e := s.admin.SRMetaInfo(ctx, madmin.SRStatusOptions{Buckets: true})
+ check(e)
+ return i.Buckets[bucket]
+}
+func (s *site) apply(e madmin.SRBucketMeta) {
+ e.Bucket = bucket
+ check(s.admin.SRPeerReplicateBucketMeta(ctx, e))
+}
+func enc(s string) *string { r := base64.StdEncoding.EncodeToString([]byte(s)); return &r }
+func events(at time.Time, newer bool) []madmin.SRBucketMeta {
+ value, days := "old", 10
+ if newer {
+ value, days = "new", 30
+ }
+ return []madmin.SRBucketMeta{
+ {Type: madmin.SRBucketMetaTypePolicy, UpdatedAt: at, Policy: []byte(fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Sid":%q,"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::%s/*"}]}`, value, bucket))},
+ {Type: madmin.SRBucketMetaTypeTags, UpdatedAt: at, Tags: enc(`key` + value + ``)},
+ {Type: madmin.SRBucketMetaTypeSSEConfig, UpdatedAt: at, SSEConfig: enc(`AES256`)},
+ {Type: madmin.SRBucketMetaTypeQuotaConfig, UpdatedAt: at, Quota: []byte(`{"quota":1024,"quotatype":"hard"}`)},
+ {Type: madmin.SRBucketMetaTypeVersionConfig, UpdatedAt: at, Versioning: enc(`Enabled`)},
+ {Type: madmin.SRBucketMetaTypeObjectLockConfig, UpdatedAt: at, ObjectLockConfig: enc(fmt.Sprintf(`EnabledGOVERNANCE%d`, days))},
+ }
+}
+func states(m madmin.SRBucketInfo) []byte {
+ b, e := json.Marshal([]any{m.Policy, m.PolicyUpdatedAt, m.Tags, m.TagConfigUpdatedAt, m.SSEConfig, m.SSEConfigUpdatedAt, m.QuotaConfig, m.QuotaConfigUpdatedAt, m.Versioning, m.VersioningConfigUpdatedAt, m.ObjectLockConfig, m.ObjectLockConfigUpdatedAt})
+ check(e)
+ return b
+}
+func times(m madmin.SRBucketInfo) []time.Time {
+ return []time.Time{m.PolicyUpdatedAt, m.TagConfigUpdatedAt, m.SSEConfigUpdatedAt, m.QuotaConfigUpdatedAt, m.VersioningConfigUpdatedAt, m.ObjectLockConfigUpdatedAt}
+}
+func converge(a, b *site, label string) {
+ stamp(label + ": waiting for ordinary 30-second heal")
+ deadline := time.Now().Add(130 * time.Second)
+ for time.Now().Before(deadline) {
+ if bytes.Equal(states(a.info()), states(b.info())) {
+ stamp(label + ": six field states equal")
+ return
+ }
+ time.Sleep(2 * time.Second)
+ }
+ check(os.WriteFile(filepath.Join(root, label+"-a.json"), states(a.info()), 0600))
+ check(os.WriteFile(filepath.Join(root, label+"-b.json"), states(b.info()), 0600))
+ panic(label + " convergence timed out")
+}
+func quiet(a, b *site, label string) {
+ beforeA, beforeB := a.calls.Load(), b.calls.Load()
+ stamp(label + ": observing two heal intervals")
+ time.Sleep(65 * time.Second)
+ if a.calls.Load() != beforeA || b.calls.Load() != beforeB {
+ panic(fmt.Sprintf("%s not quiet: a %d -> %d, b %d -> %d", label, beforeA, a.calls.Load(), beforeB, b.calls.Load()))
+ }
+ stamp(label + ": zero metadata RPCs across 65 seconds")
+}
+func main() {
+ root = os.Args[1]
+ binary, e := filepath.Abs(os.Args[2])
+ check(e)
+ check(os.MkdirAll(root, 0700))
+ a, b := newSite("site-a", binary), newSite("site-b", binary)
+ defer a.proxy.Close()
+ defer b.proxy.Close()
+ defer a.stop()
+ defer b.stop()
+ a.start()
+ b.start()
+ created := time.Now().UTC().Add(-time.Hour)
+ check(a.admin.SRPeerBucketOps(ctx, bucket, madmin.MakeWithVersioningBktOp, map[string]string{"createdAt": created.Format(time.RFC3339Nano), "lockEnabled": "true", "versioningEnabled": "true"}))
+ for _, event := range events(created, false) {
+ a.apply(event)
+ }
+ stamp("historical bucket prepared: six live fields at Created")
+ status, e := a.admin.SiteReplicationAdd(ctx, []madmin.PeerSite{{Name: a.name, Endpoint: a.proxy.URL, AccessKey: user, SecretKey: password}, {Name: b.name, Endpoint: b.proxy.URL, AccessKey: user, SecretKey: password}}, madmin.SRAddOptions{})
+ check(e)
+ if !status.Success || status.InitialSyncErrorMessage != "" {
+ panic(fmt.Sprintf("add sites: %+v", status))
+ }
+ converge(a, b, "baseline-initial-sync")
+ for _, tm := range times(a.info()) {
+ if !tm.Equal(created) {
+ panic("initial sync reclocked historical baseline: " + tm.String())
+ }
+ }
+ stamp("initial sync preserved six historical Created timestamps")
+ a.block.Store(true)
+ b.block.Store(true)
+ faultTags, e := tags.NewTags(map[string]string{"fault": "outgoing-message-dropped"}, true)
+ check(e)
+ check(a.s3.SetBucketTagging(ctx, bucket, faultTags))
+ if b.dropped.Load() == 0 {
+ panic("outbound fault injection did not drop a real metadata RPC")
+ }
+ stamp("real local PUT outgoing replication RPC dropped by partition")
+ at := time.Now().UTC().Add(time.Minute)
+ newer, older := events(at, true), events(at.Add(-time.Second), false)
+ for i := range newer {
+ a.apply(newer[i])
+ a.apply(newer[i])
+ b.apply(older[i])
+ a.apply(older[i])
+ }
+ for _, tm := range times(a.info()) {
+ if !tm.Equal(at) {
+ panic("duplicate/out-of-order source time changed: " + tm.String())
+ }
+ }
+ if bytes.Equal(states(a.info()), states(b.info())) {
+ panic("fault setup did not create divergence")
+ }
+ a.block.Store(false)
+ b.block.Store(false)
+ converge(a, b, "live-reconnect")
+ quiet(a, b, "live-steady")
+ for _, event := range older {
+ b.apply(event)
+ }
+ if !bytes.Equal(states(a.info()), states(b.info())) {
+ panic("stale event after convergence rolled back state")
+ }
+ stamp("six duplicate/out-of-order events rejected without clock changes")
+ a.block.Store(true)
+ b.block.Store(true)
+ dropped := b.dropped.Load()
+ check(a.s3.RemoveBucketTagging(ctx, bucket))
+ if b.dropped.Load() <= dropped {
+ panic("ordinary delete RPC was not dropped")
+ }
+ stamp("real local DELETE outgoing replication RPC dropped by partition")
+ deleteAt := at.Add(time.Minute)
+ for _, event := range newer[:4] {
+ a.apply(madmin.SRBucketMeta{Type: event.Type, UpdatedAt: deleteAt})
+ }
+ beforeRestart := states(a.info())
+ a.stop()
+ a.start()
+ if !bytes.Equal(beforeRestart, states(a.info())) {
+ panic("restart lost deletion/source state")
+ }
+ stamp("four tombstones survived process restart")
+ a.block.Store(false)
+ b.block.Store(false)
+ converge(a, b, "delete-reconnect")
+ quiet(a, b, "delete-steady")
+ final := a.info()
+ if len(final.Policy) != 0 || final.Tags != nil || final.SSEConfig != nil || final.QuotaConfig != nil {
+ panic("deletion did not converge")
+ }
+ check(os.WriteFile(filepath.Join(root, "converged.json"), states(final), 0600))
+ // Deliberate legacy and generation exceptions are outside convergence proof.
+ for i := 0; i < 20; i++ {
+ event := newer[1]
+ event.UpdatedAt = time.Time{}
+ a.apply(event)
+ event.UpdatedAt = final.CreatedAt.Add(-time.Duration(i+1) * time.Second)
+ a.apply(event)
+ }
+ data, e := os.ReadFile(filepath.Join(root, "site-a.jsonl"))
+ check(e)
+ for _, reason := range []string{"legacy-zero", "before-created"} {
+ n := strings.Count(string(data), "bucket metadata replication: "+reason)
+ if n != 1 {
+ panic(fmt.Sprintf("%s diagnostics count %d, want 1", reason, n))
+ }
+ }
+ stamp("40 exceptional events produced exactly one log per reason")
+ if len(os.Args) > 3 {
+ stamp("starting fixed/previous-server rolling-upgrade smoke with gate off")
+ a.stop()
+ b.stop()
+ a.gate = "off"
+ b.gate = "off"
+ b.binary, e = filepath.Abs(os.Args[3])
+ check(e)
+ a.start()
+ b.start()
+ tag, e := tags.NewTags(map[string]string{"upgrade": "works"}, true)
+ check(e)
+ check(a.s3.SetBucketTagging(ctx, bucket, tag))
+ remote, e := b.s3.GetBucketTagging(ctx, bucket)
+ check(e)
+ if remote.ToMap()["upgrade"] != "works" {
+ panic("mixed-version live event lost")
+ }
+ check(a.s3.RemoveBucketTagging(ctx, bucket))
+ if a.info().Tags != nil || b.info().Tags != nil {
+ panic("mixed-version ordinary tag delete lost")
+ }
+ if !a.info().TagConfigUpdatedAt.IsZero() {
+ panic("off exporter exposed new tombstone")
+ }
+ stamp("mixed-version ordinary PUT/DELETE and off tombstone visibility passed")
+ }
+ stamp("PASS: isolated two-site implementation acceptance")
+}
diff --git a/docs/site-replication/README.md b/docs/site-replication/README.md
index 234c48022..84a6ecc89 100644
--- a/docs/site-replication/README.md
+++ b/docs/site-replication/README.md
@@ -60,4 +60,81 @@ mc admin replicate info silo1
** Note **
Previously, site replication required the root credentials of peer sites to be identical. This is no longer necessary because STS tokens are now signed with the site replicator service account credentials, thus allowing flexibility in the independent management of root accounts across sites and the ability to disable root accounts eventually.
-However, this means that STS tokens signed previously by root credentials will no longer be valid upon upgrading to the latest version with this change. Please re-generate them as you usually do. Additionally, if site replication is ever removed - the STS tokens will become invalid, regenerate them as you usually do.
\ No newline at end of file
+However, this means that STS tokens signed previously by root credentials will no longer be valid upon upgrading to the latest version with this change. Please re-generate them as you usually do. Additionally, if site replication is ever removed - the STS tokens will become invalid, regenerate them as you usually do.
+
+## Bucket metadata source times and deletion recovery
+
+Policy, tags, encryption, quota, versioning and Object Lock use the originating
+field timestamp. Peer apply and healing compare under the bucket metadata lock;
+duplicates and older events do not rewrite the field. Real changes win over
+creation-time defaults. Equal-time deletions win over live values; equal-time
+live conflicts use a deterministic content key. An empty creation-time default
+is never a deletion. Versioning and Object Lock cannot be deleted by an empty
+replication event.
+
+`MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=off` is the startup default. After
+**all nodes at every participating site** run the fixed server, have consistent
+settings within each site, and old requests have drained, restart them with
+`MINIO_SITE_REPLICATION_METADATA_TOMBSTONES=on`. This exports the saved deletion
+times for absent tags, encryption and quota, and includes real Policy/tag/SSE/
+quota deletions in initial synchronization. It lets healing recover deletions
+missed during an outage. The setting does not detect remote capabilities.
+
+With the setting off, the new timestamp ordering still applies. Ordinary delete
+events still replicate, and Policy deletion times remain exported as before.
+Only the newly exposed deletion information is withheld. Hidden tag/SSE/quota
+tombstones can cause repeated stale heal RPCs that the fixed receiver rejects;
+zero RPCs on a subsequent heal is only expected when full state is visible.
+Do not enable the setting while old nodes remain: old quota heal can leave a
+stale parsed quota in memory after deletion.
+
+Before rolling back to an older server, turn the setting off on every fixed
+node and restart it, then downgrade. This removes the newly exposed deletion
+information; it does not repair bugs in the older software. The maintained and
+release-tested target is the coordinated PGSTY stack. Compatibility with
+unmodified upstream MinIO is best effort.
+
+Legacy events without a source timestamp are still accepted and assigned a
+monotonic local time, independently of this setting. In particular, older
+servers sent tag heal events without `UpdatedAt`. Their arrival-time pollution,
+previously polluted timestamps, and genuine differences in bucket creation
+identity cannot be reconstructed automatically. Inspect the sites, resolve
+bucket identity conflicts first, then resubmit the intended configuration or
+delete at the authoritative site. A local write advances beyond an existing
+future field timestamp. Source times before the target bucket's creation are
+ignored; an unknown creation time is recovered from the physical bucket, or the
+operation fails without writing. Recovery uses a physical approximation (the
+bucket directory's modification time), which can differ across drives and be
+later than the real creation time. A source event older than this recovered
+value is still ignored; its timestamp is not used to invent an earlier bucket
+identity. Recovery happens on the write path and during initial site sync. While
+a bucket's stored metadata still carries no creation time, site status reports
+it that way and periodic healing skips that bucket in both directions; the first
+successful configuration write on it, local or replicated, or initial site sync
+records the physical time and returns the bucket to the normal path.
+
+The server emits bounded warnings for `legacy-zero`, `before-created`,
+`indeterminate`, `unreachable` and `peer-error`. Each reason keeps its own log
+key, so a peer that did not report cannot hide an unusable peer state or a real
+heal RPC failure for the same bucket and field. A peer that simply does not
+have the bucket yet is a normal transient and is not reported here. Keys and
+error messages remain stable for each bucket/field/reason; timestamps and peer
+details are log attributes. Existing hourly logger cleanup applies. Normal
+duplicates, older events and resolved ties are quiet. Invalid existing field
+states emit `indeterminate` even when no valid source can be selected; empty
+baselines alone remain quiet and do not cause a heal RPC.
+
+A local PUT of a policy whose parsed statements are empty now consistently
+means deletion: PUT succeeds and GET returns the existing NotFound response.
+This matches the established peer-event interpretation. Zero-value quota
+JSON (`{}`, `null`, or a valid zero quota document) remains a live document;
+it is not silently sent as a deletion. Bulk omission preserves a field,
+whereas an explicit Policy JSON `null` deletes it. These rules use the existing
+wire fields and on-disk metadata format.
+
+Policy GET and admin export use the same validated encoder as replication.
+Statement and set arrays may appear in a different order from older output;
+policy evaluation is unchanged. This also makes policies using the parser's
+existing NotAction/NotResource alternatives writable and readable. Public
+replication status compares the same stable policy key as heal, so a peer's
+equivalent legacy statement order does not remain a false mismatch.