Skip to content

Commit 55282d1

Browse files
Fix context merge order: Transaction before Client per spec (#87)
* Fix context merge order: Transaction before Client per spec * Add test for context merge order: client overrides transaction * Fix context merge order in docs and ScalaDoc
1 parent ee1aac0 commit 55282d1

6 files changed

Lines changed: 26 additions & 17 deletions

File tree

core/src/main/scala/zio/openfeature/FeatureFlags.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ trait FeatureFlags {
108108

109109
/** Set the client-level evaluation context.
110110
*
111-
* Per OpenFeature spec, context merges in order: API (global) -> Client -> Transaction -> Invocation. Client context
111+
* Per OpenFeature spec, context merges in order: API (global) -> Transaction -> Client -> Invocation. Client context
112112
* is persisted on this FeatureFlags instance.
113113
*/
114114
def setClientContext(ctx: EvaluationContext): UIO[Unit]

core/src/main/scala/zio/openfeature/FeatureFlagsLive.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ final private[openfeature] class FeatureFlagsLive(
111111
}
112112
}
113113

114-
// Context merges in order per OpenFeature spec: API (global) -> Client -> Transaction -> Invocation
114+
// Context merges per OpenFeature spec: API (global) -> Transaction -> Client -> FiberLocal -> Invocation
115115
private def effectiveContext(invocation: EvaluationContext): UIO[EvaluationContext] =
116116
for {
117117
global <- state.globalContextRef.get
@@ -120,9 +120,9 @@ final private[openfeature] class FeatureFlagsLive(
120120
transaction <- state.transactionRef.get
121121
txContext = transaction.map(_.context).getOrElse(EvaluationContext.empty)
122122
} yield global
123+
.merge(txContext)
123124
.merge(clientCtx)
124125
.merge(fiberLocal)
125-
.merge(txContext)
126126
.merge(invocation)
127127

128128
private def runWithHooks[A: FlagType](

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,12 +183,12 @@ Evaluation context flows through five levels (per OpenFeature spec), with later
183183
| Level | Scope | Use Case |
184184
|:------|:------|:---------|
185185
| **Global** | Application-wide | App version, environment, deployment region |
186+
| **Transaction** | Within transaction block | Test overrides, experiment context |
186187
| **Client** | FeatureFlags instance | Service name, region |
187188
| **Scoped** | Block of code (via `withContext`) | User session, request context |
188-
| **Transaction** | Within transaction block | Test overrides, experiment context |
189189
| **Invocation** | Single evaluation | One-off targeting attributes |
190190

191-
Contexts merge with higher-precedence levels overriding lower ones: `Invocation > Transaction > Scoped > Client > Global`.
191+
Contexts merge with higher-precedence levels overriding lower ones: `Invocation > Scoped > Client > Transaction > Global`.
192192

193193
See [Evaluation Context]({{ site.baseurl }}/context) for detailed usage, attribute types, and practical examples.
194194

docs/context.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ Evaluation context provides information about the current evaluation environment
2222
ZIO OpenFeature supports a hierarchical context system with five levels (per OpenFeature spec):
2323

2424
1. **Global context** - API-level, shared across all evaluations, set via `setGlobalContext`
25-
2. **Client context** - Client-level, persisted on the FeatureFlags instance, set via `setClientContext`
26-
3. **Scoped context** - Applied to a block of code via `withContext` (fiber-local)
27-
4. **Transaction context** - Applied within a transaction block
25+
2. **Transaction context** - Applied within a transaction block
26+
3. **Client context** - Client-level, persisted on the FeatureFlags instance, set via `setClientContext`
27+
4. **Scoped context** - Applied to a block of code via `withContext` (fiber-local)
2828
5. **Invocation context** - Passed directly to evaluation methods
2929

3030
Contexts are merged in order, with later contexts taking precedence.
@@ -224,19 +224,19 @@ Per the OpenFeature specification, contexts are merged in this order (lowest to
224224
```
225225
┌──────────────────────────────────────────────────────────────┐
226226
│ Final Merged Context │
227-
│ (Invocation > Transaction > Scoped > Client > Global) │
227+
│ (Invocation > Scoped > Client > Transaction > Global) │
228228
└──────────────────────────────────────────────────────────────┘
229229
230230
231231
┌───────────────────────┼───────────────────────┐
232232
│ │ │
233233
┌───┴───┐ ┌─────┴─────┐ ┌─────┴─────┐
234-
│Invoc. │ │Transaction│ │ Scoped
234+
│Invoc. │ │ Scoped │ │ Client
235235
│(high) │ │ Context │ │ Context │
236236
└───────┘ └───────────┘ └───────────┘
237237
238238
┌──────┴──────┐
239-
Client
239+
Transaction
240240
│ Context │
241241
└─────────────┘
242242

docs/spec-compliance.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,14 @@ FeatureFlags.booleanDetails("flag", false, context, options)
102102
| Client context || `setClientContext` / `clientContext` |
103103
| Scoped context || `withContext` |
104104
| Invocation context || Per-evaluation parameter |
105-
| Context merging || Global → ClientScopedTransaction → Invocation |
105+
| Context merging || Global → TransactionClientScoped → Invocation |
106106

107107
### Context Merge Order
108108

109109
```
110110
┌──────────────────────────────────────────────────────────────┐
111111
│ Final Merged Context │
112-
│ (Invocation > Transaction > Scoped > Client > Global) │
112+
│ (Invocation > Scoped > Client > Transaction > Global) │
113113
└──────────────────────────────────────────────────────────────┘
114114
```
115115

testkit/src/test/scala/zio/openfeature/testkit/FeatureFlagsSpec.scala

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -832,9 +832,12 @@ object FeatureFlagsSpec extends ZIOSpecDefault {
832832
assertTrue(effective.getString("invocation-only").contains("yes"))
833833
}
834834
}.provide(testLayer(Map("flag" -> true))),
835-
test("transaction context merges between scoped and invocation") {
836-
val globalCtx = EvaluationContext.empty.withAttribute("source", "global")
837-
val txCtx = EvaluationContext.empty.withAttribute("source", "transaction")
835+
test(
836+
"transaction context merges between global and client per spec (API -> Transaction -> Client -> Invocation)"
837+
) {
838+
val globalCtx = EvaluationContext.empty.withAttribute("source", "global").withAttribute("global-only", "yes")
839+
val txCtx = EvaluationContext.empty.withAttribute("source", "transaction").withAttribute("tx-only", "yes")
840+
val clientCtx = EvaluationContext.empty.withAttribute("source", "client").withAttribute("client-only", "yes")
838841

839842
val capturedCtx = Unsafe.unsafe { implicit u =>
840843
Runtime.default.unsafe.run(Ref.make(Option.empty[EvaluationContext])).getOrThrow()
@@ -847,14 +850,20 @@ object FeatureFlagsSpec extends ZIOSpecDefault {
847850

848851
for {
849852
_ <- FeatureFlags.setGlobalContext(globalCtx)
853+
_ <- FeatureFlags.setClientContext(clientCtx)
850854
_ <- FeatureFlags.addHook(ctxCapture)
851855
_ <- FeatureFlags.transaction(context = txCtx) {
852856
FeatureFlags.boolean("flag", default = false)
853857
}
854858
ctx <- capturedCtx.get
855859
} yield {
856860
val effective = ctx.get
857-
assertTrue(effective.getString("source").contains("transaction"))
861+
// Client overrides Transaction (spec: API -> Transaction -> Client -> Invocation)
862+
assertTrue(effective.getString("source").contains("client")) &&
863+
// All unique attributes from each level are present
864+
assertTrue(effective.getString("global-only").contains("yes")) &&
865+
assertTrue(effective.getString("tx-only").contains("yes")) &&
866+
assertTrue(effective.getString("client-only").contains("yes"))
858867
}
859868
}.provide(testLayer(Map("flag" -> true)))
860869
),

0 commit comments

Comments
 (0)