fix(di): validate contract changes at the transaction boundary - #580
Merged
Conversation
skhaz
approved these changes
Aug 19, 2026
Adding a method to a live contract definition is impossible today: the
definition update and its binding updates arrive in one changeset, but the
contract manager validates each entry against already-committed state. The
definition update re-validates the still-old binding ("contract method is not
bound"); applied the other way around, the binding update validates against
the still-old definition ("bound method is not defined in contract
definition"). Both are apierror.Invalid, which the registry runner treats as
fatal, so every ordering rolls the changeset back. The runner's deferral loop
cannot help: it retries only NotFound, and neither half is individually valid
against the pre-change peer regardless of ordering - the dependency is a
genuine two-cycle at per-entry granularity.
Boot never hits this because a fresh load delivers everything as EntryCreate
in dependency order, and handleDefinitionAdd does not cross-validate. The
rejection is specific to EntryUpdate on a live instance, which is exactly the
module-upgrade path.
The registry has offered the fix since the tx protocol landed:
registry.TransactionListener. The manager now participates. Begin opens a
staging area; entry operations validate structurally, stage their mutation
against an effective view (so same-transaction operations see each other),
and queue their contract-plane events. Commit validates every effective
binding against every effective definition plus unique defaults - the
complete post-transaction state - then applies and flushes events atomically.
A commit-time failure leaves committed state untouched and emits nothing; the
runner rolls back and discards. Discard drops the staging, and tolerates
running after a failed Commit.
Outside a transaction, behaviour is byte-for-byte what it was: per-entry
validation, immediate apply, immediate events. The definition-in-use check
and the update-would-invalidate check still guard direct operations; inside a
transaction both defer to Commit, where a definition deleted while a binding
survives surfaces as an unresolved contract reference.
Tests cover the live-upgrade case in both orders, commit rejection with state
and event silence, discard, delete-together vs delete-alone, in-transaction
create order independence, duplicate defaults across staged and committed
bindings, and staged-state visibility to existence checks.
go test ./service/di/ ./system/registry/... ./boot/... ./service/...
./internal/... ./api/... -race -short passes; golangci-lint v2.8.0 reports
0 issues on the package.
wolfy-j
force-pushed
the
fix/contract-transactional-validation
branch
from
August 19, 2026 13:55
a39214e to
46c09bb
Compare
wolfy-j
force-pushed
the
fix/contract-transactional-validation
branch
from
August 19, 2026 13:58
46c09bb to
34548e6
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Adding a method to a live contract definition is impossible. The module upgrade produces one changeset carrying the definition update and its binding updates, but
di.Managervalidates each entry against already-committed state:handleDefinitionUpdatere-validates the still-old binding →contract method is not boundhandleBindingUpdatevalidates against the still-old definition →bound method is not defined in contract definitionBoth are
apierror.Invalid→ fatal to the runner → full rollback, every ordering. The self-healing deferral loop cannot rescue it — it retries onlyNotFound, and neither half is individually valid against the pre-change peer: the dependency is a genuine two-cycle at per-entry granularity.Boot never hits this: a fresh load delivers everything as
EntryCreatein dependency order, andhandleDefinitionAdddoes not cross-validate. The rejection is specific toEntryUpdateon a live instance — exactly the hub module-upgrade path. (Downstream, keeper surfaces any apply failure as HTTP 409, which is how this presents to operators.)Encountered in production: upgrading
kickside/contractto add a method to a live contract definition 409s in every ordering — single-module install, co-planned install with version floors, both directions.The fix
di.Managernow implementsregistry.TransactionListener— the mechanism the registry has offered since the tx protocol landed (Dec 2024), already used byservice/netand the Lua code manager.TxDiscard.Outside a transaction, behaviour is unchanged — per-entry validation, immediate apply, immediate events. All pre-existing tests pass untouched.
The definition-in-use check moves to Commit inside a transaction, where a definition deleted while a binding survives surfaces as
binding references undefined contract; retiring a contract together with its bindings in one changeset now also works.Semantics notes for review
TxDiscardis a no-op.Addno longer needs the runner's NotFound deferral for binding-before-definition ordering; Commit sees both.Testing
manager_tx_test.go: the live-upgrade case in both orders, commit rejection (state untouched, zero events emitted), discard, delete-together vs delete-alone, in-transaction create order independence, duplicate defaults across staged+committed bindings, staged-state visibility to existence checks.go test ./service/di/ ./system/registry/... ./boot/... ./service/... ./internal/... ./api/... -race -short— all pass.golangci-lint v2.8.0 run --build-tags=race ./service/di/...— 0 issues.