From bc7b9c768f8106519a7b51eb9d7bccc856a67bb1 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Fri, 9 Oct 2026 04:54:16 +0500 Subject: [PATCH 1/2] fix(recovery): match write-set locks to repository plans --- core/app/recovery_operations.go | 20 +++++++++++++++++++- core/app/recovery_operations_test.go | 16 ++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/core/app/recovery_operations.go b/core/app/recovery_operations.go index 2898382..2197d78 100644 --- a/core/app/recovery_operations.go +++ b/core/app/recovery_operations.go @@ -556,7 +556,7 @@ func decideRecovery( if lock.Scope != "repository" { decision.Blockers = append(decision.Blockers, "unexpected-lock-scope") } - if _, allowed := allowedRepositories[lock.ScopeID]; !allowed { + if !repositoryLockBelongsToPlan(lock.ScopeID, allowedRepositories) { decision.Blockers = append(decision.Blockers, "lock-scope-outside-plan") } if lock.DeviceID != deviceID { @@ -655,6 +655,24 @@ func decideRecovery( return decision } +// repositoryLockBelongsToPlan accepts both the legacy bare repository scope +// and the operation engine's write-set scope (`:`). +// Locks are keyed by the latter so independent write targets in one repository +// do not collide, while recovery is scoped by the repository id in the plan. +// Comparing only exact strings made every interrupted write-set operation look +// foreign and left its expired lock unrecoverable. +func repositoryLockBelongsToPlan(scopeID string, allowedRepositories map[string]struct{}) bool { + if _, allowed := allowedRepositories[scopeID]; allowed { + return true + } + for repositoryID := range allowedRepositories { + if strings.HasPrefix(scopeID, repositoryID+":") { + return true + } + } + return false +} + func processAlive(pid int) (bool, error) { if pid <= 0 { return false, fmt.Errorf("invalid process id %d", pid) diff --git a/core/app/recovery_operations_test.go b/core/app/recovery_operations_test.go index 13d888d..7c5d24a 100644 --- a/core/app/recovery_operations_test.go +++ b/core/app/recovery_operations_test.go @@ -73,6 +73,22 @@ func TestRecoveryDecisionReportsCompensationWithoutApplyingIt(t *testing.T) { } } +func TestRecoveryDecisionAcceptsWriteSetRepositoryLockScope(t *testing.T) { + now := time.Now().UTC() + snapshot := recoveryDecisionFixture(now) + snapshot.Locks[0].ScopeID = "repo_fixture:repository" + plan := operations.Plan{ + Steps: []operations.Step{{ + StepID: "step-1", Compensation: operations.Compensation{Mode: "manual"}, + }}, + Scope: operations.Scope{Repositories: []string{"repo_fixture"}}, + } + decision := decideRecovery(snapshot, plan, now, "device:test") + if !decision.Automatable || decision.Classification != "safe-abort" { + t.Fatalf("decision=%#v", decision) + } +} + func containsString(values []string, expected string) bool { for _, value := range values { if value == expected { From 6d076886a674eff04464c011053114ac2acef122 Mon Sep 17 00:00:00 2001 From: Danil Silantyev Date: Fri, 9 Oct 2026 05:33:04 +0500 Subject: [PATCH 2/2] chore(projections): refresh generated bundle --- .gds/bundle.lock.yaml | 10 +++++----- .github/workflows/gds-ci.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 4205530..d43206b 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -4,14 +4,14 @@ schema_version: 1 bundle: version: "0.9.7-dev" release_sequence: 0 - source_tree_digest: "sha256:4c7e5f20f939b7c78e0d3432c61d62f0b01d7fade56621491da307d656463448" - digest: "sha256:3b2e0e5abf1515b37407f609e7e35034136c5acea5ad46f37c67aac17f3ed93a" + source_tree_digest: "sha256:4a2f83f369d3820571e9572382e50436b38e1f3057a4549814c6a24ef7432e81" + digest: "sha256:85055636230aad2c7e7cc9e15f53c42cd64cc356c889048806a12d98bd7bfd07" projection: - input_digest: "sha256:924e25d3b7a56d3966975e735ec39f9eec2bca3420b0d4ee6af6e6e07d5191e0" - output_digest: "sha256:91c917a3497b68e1c5d6ab372a072a43f0e34f4815511b341357e5b0721b04aa" + input_digest: "sha256:28ac88ff134bfafe0c1c65d0537987899c2616a49a71e7d340cdf245611b6cf8" + output_digest: "sha256:ef44e3e22510e72e0062ec6593de83ecc3b43cc4d52a8ef7858383491ea1af8b" files: - path: ".gds/compiled-policy.json" digest: "sha256:9f498788bdc34e52a0ab793c536e0e6a7b360c2e1a20446cbf03ed51986cdc6f" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:4f263ba0c7c0e71d2eaeacca25f1fbdaa43cac912d12a178b00dee26c051f932" + digest: "sha256:cb471c7e0af94c79ca2912bf347fb400e60fddf1aacfcd229837524806053749" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 5269820..c4118f5 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.9.7-dev -# source-tree-digest: sha256:4c7e5f20f939b7c78e0d3432c61d62f0b01d7fade56621491da307d656463448 -# input-digest: sha256:924e25d3b7a56d3966975e735ec39f9eec2bca3420b0d4ee6af6e6e07d5191e0 +# source-tree-digest: sha256:4a2f83f369d3820571e9572382e50436b38e1f3057a4549814c6a24ef7432e81 +# input-digest: sha256:28ac88ff134bfafe0c1c65d0537987899c2616a49a71e7d340cdf245611b6cf8 # output-digest: sha256:4ef1ee2fcc42927eaedef9c85f7b421f87e5cc75ff7055ef520216a00f7d4b74 # edit-source: # - .gds/repository.yaml