feat(release): canary agent releases with sticky arm assignment - #75
Merged
Conversation
added 3 commits
August 18, 2026 09:21
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.
Summary
Agent releases promote between environments as immutable bundles via per-env pointers, but cutover is all-or-nothing: the pointer serves
100% of traffic from one release. There is no way to expose a candidate to a fraction of traffic and compare arms before committing. Weighted Lambda aliases don't apply - agents are data (configs), not code - so the split happens at release-pointer resolution.
Scope: attribution-first
This PR ships the full split, stickiness, and per-arm attribution machinery. Release resolution today is gate/attribution-level, arms run identical live config until release›runtime-config binding lands (an explicit follow-up). The runbook states this plainly and warns against presenting canaries as behavioral protection in the interim. What this PR proves deterministic assignment, no mid-flow arm mixing, per-arm data, one-mutation promote/abort, and the governance rails around all of it.
What changed
canary { candidateReleaseId, percentBasisPoints, stickiness, salt, startedAt, startedBy }; absent → 100% stable - existing pointers and behavior are byte-identical.sha256(salt:key) % 10000 < percentBasisPoints, mirrored in TypeScript and Python with one shared parity fixture plus a mutant test proving the parity check bites. Salt is minted at canary start and preserved across reweight, so buckets never reshuffle - reweight moves only the delta band. The arm is assigned once at flow entry and propagated in server-minted fields; external arm claims are stripped at the boundary. Interim stickiness unit is the orchestration/execution (conversation-level stickiness arrives with the binding follow-up).startCanary/reweightCanary/promoteCanary/abortCanary- each a single atomic, version-gated write (pointer + history row in oneTransactWriteItems). Promote-to-100 re-runs the full ladder + gate + approval chain at the cutover moment; canary start obeys the same Ladder adjacency as promotion (a canary is prod exposure, not an exemption).release: canarypermission covers start/reweight/abort; "promoteCanary' additionally requires 'release:promote, so canary authority cannot escalate to full promotion.canaryMaxBasisPointsjoins the promotion policy chain (default 2500 = 25%, org-overridable; prod ceiling ≤ staging via the tightening-fields rule; fail-closed on unreadable policy) •ReleaseArmdimension (release ids stay out of metric dimensions).Testing
tsc --noEmitclean; backend Jest and the arbiter supervisor/stepRunner pytest sets green on the final committed tree.Deployment notes
No new tables - canary state lives on the existing pointer record and history rows. Additive GraphQL schema/resolver and IAM changes. Deploys are manual; a pre-merge branch deploy to dev is recommended as usual.