Skip to content

Commit f06da57

Browse files
authored
fix: remove index-based immutability check that blocked demo deploy (#2091)
* fix: remove index-based immutability check that blocked cross-manifest applies The validateImmutability function matched instruments and account types by array index position, producing false "code changed" errors when applying a manifest with different composition than the previously applied one (e.g. energy manifest after banking manifest on demo deploy). Codes are primary keys - identity is code-based, not position-based. A code appearing in the old manifest but not the new is a removal, already caught by validateDestructiveChanges. The index-based check is removed and the function made a no-op. * fix: update applier test expecting IMMUTABLE_FIELD_CHANGED The applier integration test also expected IMMUTABLE_FIELD_CHANGED errors from the now-removed index-based immutability check. Updated to verify no such errors are produced. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent c64e9da commit f06da57

4 files changed

Lines changed: 37 additions & 155 deletions

File tree

services/control-plane/internal/applier/grpc_handler_test.go

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -535,11 +535,12 @@ func TestApplyManifest_ExpectedSequenceNumber_NoHistoryService_SkipsCheck(t *tes
535535
assert.Equal(t, controlplanev1.ApplyManifestStatus_APPLY_MANIFEST_STATUS_DRY_RUN, resp.Status)
536536
}
537537

538-
func TestApplyManifest_SkipImmutabilityChecks_NotDryRun_StillEnforces(t *testing.T) {
538+
func TestApplyManifest_SkipImmutabilityChecks_NotDryRun_NoImmutabilityErrors(t *testing.T) {
539539
prev := newTestManifest()
540540
handler := newTestHandlerWithVersionStore(t, prev)
541541

542-
// Change instrument code — triggers IMMUTABLE_FIELD_CHANGED
542+
// Change instrument code - validateImmutability is now a no-op, so this
543+
// should not produce IMMUTABLE_FIELD_CHANGED errors regardless of flags.
543544
curr := newTestManifest()
544545
curr.Instruments[0].Code = "USD"
545546
curr.AccountTypes[0].AllowedInstruments = []string{"USD"}
@@ -548,24 +549,17 @@ func TestApplyManifest_SkipImmutabilityChecks_NotDryRun_StillEnforces(t *testing
548549
Manifest: curr,
549550
DryRun: false,
550551
AppliedBy: "test-user",
551-
SkipImmutabilityChecks: true, // should be ignored because dry_run=false
552+
SkipImmutabilityChecks: true,
552553
})
553554

554555
require.NoError(t, err)
555556
require.NotNil(t, resp)
556557

557-
// Should fail validation because skip_immutability_checks is ignored when dry_run=false
558-
assert.Equal(t, controlplanev1.ApplyManifestStatus_APPLY_MANIFEST_STATUS_VALIDATION_FAILED, resp.Status,
559-
"expected VALIDATION_FAILED when skip_immutability_checks is set but dry_run=false")
560-
561-
found := false
558+
// No IMMUTABLE_FIELD_CHANGED errors since the check is a no-op
562559
for _, ve := range resp.ValidationErrors {
563-
if ve.Code == "IMMUTABLE_FIELD_CHANGED" {
564-
found = true
565-
break
566-
}
560+
assert.NotEqual(t, "IMMUTABLE_FIELD_CHANGED", ve.Code,
561+
"unexpected IMMUTABLE_FIELD_CHANGED error: %s", ve.Message)
567562
}
568-
assert.True(t, found, "expected IMMUTABLE_FIELD_CHANGED error when dry_run=false, regardless of skip flag")
569563
}
570564

571565
// --- Phase Status Tests ---

services/control-plane/internal/validator/lifecycle_validator.go

Lines changed: 8 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -97,50 +97,16 @@ func (v *ManifestValidator) detectOrphanInstructionRoutes(
9797
}
9898
}
9999

100-
// validateImmutability checks that immutable code fields have not been changed.
100+
// validateImmutability is intentionally a no-op. The previous implementation matched
101+
// instruments and account types by array index position, which produced false positives
102+
// whenever manifests had different compositions (e.g. applying an energy manifest after
103+
// a banking manifest). Codes are primary keys and identity is code-based, so a "rename"
104+
// is actually a removal + addition - already caught by validateDestructiveChanges.
101105
func (v *ManifestValidator) validateImmutability(
102-
current *controlplanev1.Manifest,
103-
previous *controlplanev1.Manifest,
104-
result *ValidationResult,
106+
_ *controlplanev1.Manifest,
107+
_ *controlplanev1.Manifest,
108+
_ *ValidationResult,
105109
) {
106-
// Build maps of previous codes by index position to detect renames
107-
prevInstrumentsByIdx := make(map[int]string)
108-
for i, inst := range previous.GetInstruments() {
109-
prevInstrumentsByIdx[i] = inst.GetCode()
110-
}
111-
112-
prevAccountTypesByIdx := make(map[int]string)
113-
for i, acct := range previous.GetAccountTypes() {
114-
prevAccountTypesByIdx[i] = acct.GetCode()
115-
}
116-
117-
// Check instruments: detect code changes at same index position
118-
for i, inst := range current.GetInstruments() {
119-
if prevCode, existed := prevInstrumentsByIdx[i]; existed {
120-
if inst.GetCode() != prevCode {
121-
addError(result, ValidationError{
122-
Severity: SeverityError,
123-
Path: fmt.Sprintf("instruments[%d].code", i),
124-
Code: "IMMUTABLE_FIELD_CHANGED",
125-
Message: fmt.Sprintf("instrument code changed from %q to %q; codes are immutable primary keys", prevCode, inst.GetCode()),
126-
})
127-
}
128-
}
129-
}
130-
131-
// Check account types: detect code changes at same index position
132-
for i, acct := range current.GetAccountTypes() {
133-
if prevCode, existed := prevAccountTypesByIdx[i]; existed {
134-
if acct.GetCode() != prevCode {
135-
addError(result, ValidationError{
136-
Severity: SeverityError,
137-
Path: fmt.Sprintf("account_types[%d].code", i),
138-
Code: "IMMUTABLE_FIELD_CHANGED",
139-
Message: fmt.Sprintf("account type code changed from %q to %q; codes are immutable primary keys", prevCode, acct.GetCode()),
140-
})
141-
}
142-
}
143-
}
144110
}
145111

146112
// validateDestructiveChanges detects removal of resources that have dependencies in the

services/control-plane/internal/validator/lifecycle_validator_test.go

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -164,91 +164,37 @@ func TestDetectOrphanInstructionRoutes_NotUsed(t *testing.T) {
164164
assert.Contains(t, orphanRoutes[0].Message, "REFUND")
165165
}
166166

167-
// ─── Immutability checks ────────────────────────────────────────────────────
167+
// ─── Immutability checks (now a no-op; removals caught by destructive changes)
168168

169-
func TestValidateImmutability_NoChange(t *testing.T) {
170-
v, err := New()
171-
require.NoError(t, err)
172-
173-
manifest := &controlplanev1.Manifest{
174-
Instruments: []*controlplanev1.InstrumentDefinition{
175-
{Code: "GBP", Name: "British Pound"},
176-
},
177-
AccountTypes: []*controlplanev1.AccountTypeDefinition{
178-
{Code: "SETTLEMENT", Name: "Settlement"},
179-
},
180-
}
181-
182-
result := &ValidationResult{Valid: true}
183-
v.validateImmutability(manifest, manifest, result)
184-
assert.Empty(t, result.Errors)
185-
}
186-
187-
func TestValidateImmutability_InstrumentCodeChanged_Direct(t *testing.T) {
169+
func TestValidateImmutability_IsNoOp(t *testing.T) {
188170
v, err := New()
189171
require.NoError(t, err)
190172

173+
// validateImmutability is intentionally a no-op. Different instrument/account
174+
// compositions between manifests should not produce false "code changed" errors.
175+
// Removals are caught by validateDestructiveChanges instead.
191176
previous := &controlplanev1.Manifest{
192177
Instruments: []*controlplanev1.InstrumentDefinition{
193178
{Code: "GBP", Name: "British Pound"},
179+
{Code: "EUR", Name: "Euro"},
194180
},
195-
}
196-
current := &controlplanev1.Manifest{
197-
Instruments: []*controlplanev1.InstrumentDefinition{
198-
{Code: "USD", Name: "US Dollar"},
199-
},
200-
}
201-
202-
result := &ValidationResult{Valid: true}
203-
v.validateImmutability(current, previous, result)
204-
205-
require.Len(t, result.Errors, 1)
206-
assert.Equal(t, "IMMUTABLE_FIELD_CHANGED", result.Errors[0].Code)
207-
assert.Contains(t, result.Errors[0].Message, "GBP")
208-
assert.Contains(t, result.Errors[0].Message, "USD")
209-
}
210-
211-
func TestValidateImmutability_AccountTypeCodeChanged_Direct(t *testing.T) {
212-
v, err := New()
213-
require.NoError(t, err)
214-
215-
previous := &controlplanev1.Manifest{
216181
AccountTypes: []*controlplanev1.AccountTypeDefinition{
217182
{Code: "SETTLEMENT", Name: "Settlement"},
218183
},
219184
}
220185
current := &controlplanev1.Manifest{
221-
AccountTypes: []*controlplanev1.AccountTypeDefinition{
222-
{Code: "CLEARING", Name: "Clearing"},
223-
},
224-
}
225-
226-
result := &ValidationResult{Valid: true}
227-
v.validateImmutability(current, previous, result)
228-
229-
require.Len(t, result.Errors, 1)
230-
assert.Equal(t, "IMMUTABLE_FIELD_CHANGED", result.Errors[0].Code)
231-
}
232-
233-
func TestValidateImmutability_AddingNewInstrument(t *testing.T) {
234-
v, err := New()
235-
require.NoError(t, err)
236-
237-
previous := &controlplanev1.Manifest{
238186
Instruments: []*controlplanev1.InstrumentDefinition{
239187
{Code: "GBP", Name: "British Pound"},
188+
{Code: "KWH", Name: "Kilowatt Hour"},
240189
},
241-
}
242-
current := &controlplanev1.Manifest{
243-
Instruments: []*controlplanev1.InstrumentDefinition{
244-
{Code: "GBP", Name: "British Pound"},
245-
{Code: "USD", Name: "US Dollar"},
190+
AccountTypes: []*controlplanev1.AccountTypeDefinition{
191+
{Code: "ENERGY_TRADING", Name: "Energy Trading"},
246192
},
247193
}
248194

249195
result := &ValidationResult{Valid: true}
250196
v.validateImmutability(current, previous, result)
251-
assert.Empty(t, result.Errors)
197+
assert.Empty(t, result.Errors, "validateImmutability should be a no-op")
252198
}
253199

254200
// ─── dispatchInstructionRegex ────────────────────────────────────────────────

services/control-plane/internal/validator/manifest_validator_test.go

Lines changed: 12 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ func TestValidateCrossRef_WithSuggestion(t *testing.T) {
797797
}
798798
}
799799

800-
func TestValidateImmutability_InstrumentCodeChanged(t *testing.T) {
800+
func TestValidateImmutability_InstrumentCodeChanged_NoImmutabilityError(t *testing.T) {
801801
v, err := New()
802802
if err != nil {
803803
t.Fatalf("New() error: %v", err)
@@ -808,26 +808,16 @@ func TestValidateImmutability_InstrumentCodeChanged(t *testing.T) {
808808
curr.Instruments[0].Code = "USD" // Changed from GBP
809809

810810
result := v.Validate(curr, prev)
811-
if result.Valid {
812-
t.Error("expected invalid manifest for changed instrument code")
813-
}
814-
815-
found := false
811+
// validateImmutability is now a no-op - removals are caught by destructive changes.
812+
// There should be no IMMUTABLE_FIELD_CHANGED errors.
816813
for _, e := range result.Errors {
817-
if e.Code == "IMMUTABLE_FIELD_CHANGED" && strings.Contains(e.Path, "instruments") {
818-
found = true
819-
if !strings.Contains(e.Message, "GBP") || !strings.Contains(e.Message, "USD") {
820-
t.Errorf("expected message to mention old and new codes, got: %s", e.Message)
821-
}
822-
break
814+
if e.Code == "IMMUTABLE_FIELD_CHANGED" {
815+
t.Errorf("unexpected IMMUTABLE_FIELD_CHANGED error: %s", e.Message)
823816
}
824817
}
825-
if !found {
826-
t.Error("expected IMMUTABLE_FIELD_CHANGED error for instruments")
827-
}
828818
}
829819

830-
func TestValidateImmutability_AccountTypeCodeChanged(t *testing.T) {
820+
func TestValidateImmutability_AccountTypeCodeChanged_NoImmutabilityError(t *testing.T) {
831821
v, err := New()
832822
if err != nil {
833823
t.Fatalf("New() error: %v", err)
@@ -838,20 +828,12 @@ func TestValidateImmutability_AccountTypeCodeChanged(t *testing.T) {
838828
curr.AccountTypes[0].Code = "SAVINGS" // Changed from SETTLEMENT
839829

840830
result := v.Validate(curr, prev)
841-
if result.Valid {
842-
t.Error("expected invalid manifest for changed account type code")
843-
}
844-
845-
found := false
831+
// validateImmutability is now a no-op - removals are caught by destructive changes.
846832
for _, e := range result.Errors {
847-
if e.Code == "IMMUTABLE_FIELD_CHANGED" && strings.Contains(e.Path, "account_types") {
848-
found = true
849-
break
833+
if e.Code == "IMMUTABLE_FIELD_CHANGED" {
834+
t.Errorf("unexpected IMMUTABLE_FIELD_CHANGED error: %s", e.Message)
850835
}
851836
}
852-
if !found {
853-
t.Error("expected IMMUTABLE_FIELD_CHANGED error for account_types")
854-
}
855837
}
856838

857839
func TestValidateImmutability_DisplayNameChangeAllowed(t *testing.T) {
@@ -2966,7 +2948,7 @@ func TestWithSkipImmutabilityChecks_SkipsDestructiveRemoval(t *testing.T) {
29662948
}
29672949
}
29682950

2969-
func TestWithoutSkipImmutabilityChecks_StillEnforcesImmutability(t *testing.T) {
2951+
func TestWithoutSkipImmutabilityChecks_NoImmutabilityErrors(t *testing.T) {
29702952
v, err := New()
29712953
require.NoError(t, err)
29722954

@@ -2975,16 +2957,10 @@ func TestWithoutSkipImmutabilityChecks_StillEnforcesImmutability(t *testing.T) {
29752957
curr.Instruments[0].Code = "USD" // Changed from GBP
29762958

29772959
result := v.Validate(curr, prev)
2978-
assert.False(t, result.Valid)
2979-
2980-
found := false
2960+
// validateImmutability is now a no-op - no IMMUTABLE_FIELD_CHANGED errors expected.
29812961
for _, e := range result.Errors {
2982-
if e.Code == "IMMUTABLE_FIELD_CHANGED" {
2983-
found = true
2984-
break
2985-
}
2962+
assert.NotEqual(t, "IMMUTABLE_FIELD_CHANGED", e.Code, "unexpected IMMUTABLE_FIELD_CHANGED error: %s", e.Message)
29862963
}
2987-
assert.True(t, found, "expected IMMUTABLE_FIELD_CHANGED error without skip option")
29882964
}
29892965

29902966
// --- Market Data validation tests ---

0 commit comments

Comments
 (0)