|
| 1 | +# Decorator ScopedValue Scope Fix — Implementation Plan |
| 2 | + |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | +
|
| 5 | +**Goal:** Make `ResponseDecorator` instances run inside the `ScopedValue` bindings established by `RequestInterceptor`s, matching the contract documented in `README.md` ("Combining the two"). |
| 6 | + |
| 7 | +**Architecture:** Move the decorator loop from `DispatchHandler.handle` into the base case of the recursive `invoke(...)` helper. Decorators then execute after `handler.handle(request)` but *before* control unwinds through the interceptor stack, so every interceptor's `ScopedValue.where(...).call(...)` frame is still live when a decorator runs. A side effect is that interceptors observe the *decorated* Response on the way back up — explicitly endorsed by the README. |
| 8 | + |
| 9 | +**Tech Stack:** Java 25, JDK `com.sun.net.httpserver.HttpServer`, JUnit 5, AssertJ, Mockito, Maven. |
| 10 | + |
| 11 | +**Spec:** `docs/superpowers/specs/2026-05-23-decorator-scoped-value-scope-design.md` |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## File Structure |
| 16 | + |
| 17 | +- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` — relocate decorator loop. |
| 18 | +- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` — three new unit tests pinning the new contract. |
| 19 | + |
| 20 | +No README change. No public API change. No new files. |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## Task 1: Failing test — decorator sees interceptor-bound ScopedValue |
| 25 | + |
| 26 | +**Files:** |
| 27 | +- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` |
| 28 | + |
| 29 | +- [ ] **Step 1: Add imports and a class-level test ScopedValue** |
| 30 | + |
| 31 | +Open `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java`. Add the following imports near the existing ones (keep alphabetical order with the rest of the file): |
| 32 | + |
| 33 | +```java |
| 34 | +import com.retailsvc.http.RequestInterceptor; |
| 35 | +import com.retailsvc.http.ResponseDecorator; |
| 36 | +import java.util.concurrent.atomic.AtomicReference; |
| 37 | +``` |
| 38 | + |
| 39 | +Add a static field inside the class body, immediately after the class declaration line: |
| 40 | + |
| 41 | +```java |
| 42 | + private static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance(); |
| 43 | +``` |
| 44 | + |
| 45 | +- [ ] **Step 2: Add a helper that builds a DispatchHandler with interceptors and decorators** |
| 46 | + |
| 47 | +Add this overload of `dispatcher` to the class (place it directly under the existing `dispatcher` method): |
| 48 | + |
| 49 | +```java |
| 50 | + private static DispatchHandler dispatcher( |
| 51 | + Map<String, RequestHandler> handlers, |
| 52 | + List<RequestInterceptor> interceptors, |
| 53 | + List<ResponseDecorator> decorators) { |
| 54 | + return new DispatchHandler(handlers, interceptors, decorators, new ResponseRenderer(Map.of())); |
| 55 | + } |
| 56 | +``` |
| 57 | + |
| 58 | +- [ ] **Step 3: Write the failing test** |
| 59 | + |
| 60 | +Append this test method to the class: |
| 61 | + |
| 62 | +```java |
| 63 | + @Test |
| 64 | + void decoratorSeesInterceptorBoundScopedValue() throws Exception { |
| 65 | + RequestHandler ok = req -> Response.status(HTTP_OK); |
| 66 | + RequestInterceptor bindCid = |
| 67 | + (request, next) -> ScopedValue.where(CORRELATION_ID, "cid-123").call(next::proceed); |
| 68 | + ResponseDecorator stampCid = |
| 69 | + (req, resp) -> resp.withHeader("X-Correlation-Id", CORRELATION_ID.get()); |
| 70 | + |
| 71 | + HttpExchange ex = stubExchange(); |
| 72 | + AtomicReference<Response> rendered = new AtomicReference<>(); |
| 73 | + |
| 74 | + withRequest( |
| 75 | + "get-x", |
| 76 | + () -> { |
| 77 | + dispatcher(Map.of("get-x", ok), List.of(bindCid), List.of(stampCid)).handle(ex); |
| 78 | + rendered.set((Response) ex.getAttribute(DispatchHandler.RESPONSE_ATTR)); |
| 79 | + return null; |
| 80 | + }); |
| 81 | + |
| 82 | + assertThat(rendered.get().headers()).containsEntry("X-Correlation-Id", "cid-123"); |
| 83 | + } |
| 84 | +``` |
| 85 | + |
| 86 | +- [ ] **Step 4: Wire up `exchange.getAttribute` on the stub** |
| 87 | + |
| 88 | +The stub returned by `stubExchange()` is a Mockito mock and `getAttribute(...)` returns `null` by default. Switch the stub to capture the `setAttribute(...)` call. Replace the existing `stubExchange()` body with: |
| 89 | + |
| 90 | +```java |
| 91 | + private static HttpExchange stubExchange() { |
| 92 | + HttpExchange exchange = mock(HttpExchange.class); |
| 93 | + when(exchange.getResponseHeaders()).thenReturn(new Headers()); |
| 94 | + Map<String, Object> attrs = new HashMap<>(); |
| 95 | + doAnswer( |
| 96 | + inv -> { |
| 97 | + attrs.put(inv.getArgument(0), inv.getArgument(1)); |
| 98 | + return null; |
| 99 | + }) |
| 100 | + .when(exchange) |
| 101 | + .setAttribute(anyString(), any()); |
| 102 | + when(exchange.getAttribute(anyString())).thenAnswer(inv -> attrs.get(inv.getArgument(0))); |
| 103 | + return exchange; |
| 104 | + } |
| 105 | +``` |
| 106 | + |
| 107 | +Add the imports the new stub needs (keep alphabetical): |
| 108 | + |
| 109 | +```java |
| 110 | +import static org.mockito.ArgumentMatchers.any; |
| 111 | +import static org.mockito.ArgumentMatchers.anyString; |
| 112 | +import static org.mockito.Mockito.doAnswer; |
| 113 | +import java.util.HashMap; |
| 114 | +``` |
| 115 | + |
| 116 | +- [ ] **Step 5: Run the new test and confirm it FAILS for the right reason** |
| 117 | + |
| 118 | +Run: |
| 119 | + |
| 120 | +```bash |
| 121 | +mvn test -Dtest=DispatchHandlerTest#decoratorSeesInterceptorBoundScopedValue |
| 122 | +``` |
| 123 | + |
| 124 | +Expected: test fails with `java.util.NoSuchElementException: ScopedValue not bound` thrown from `stampCid` — proves the bug. |
| 125 | + |
| 126 | +If it fails for any other reason (compile error, NPE in the stub, etc.), stop and fix that before continuing — the test must fail *because the bug exists*, not because the test is broken. |
| 127 | + |
| 128 | +- [ ] **Step 6: Commit the failing test** |
| 129 | + |
| 130 | +```bash |
| 131 | +git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java |
| 132 | +git commit -m "test: Decorator should see interceptor-bound ScopedValue" |
| 133 | +``` |
| 134 | + |
| 135 | +(Pre-commit will run Google Java Format; let it.) |
| 136 | + |
| 137 | +--- |
| 138 | + |
| 139 | +## Task 2: Fix — move decorator loop inside the interceptor scope |
| 140 | + |
| 141 | +**Files:** |
| 142 | +- Modify: `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` |
| 143 | + |
| 144 | +- [ ] **Step 1: Move the decorator loop into `invoke`'s base case** |
| 145 | + |
| 146 | +Open `src/main/java/com/retailsvc/http/internal/DispatchHandler.java`. Replace the current `handle` and `invoke` methods with: |
| 147 | + |
| 148 | +```java |
| 149 | + @Override |
| 150 | + public void handle(HttpExchange exchange) throws IOException { |
| 151 | + Request request = CURRENT.get(); |
| 152 | + RequestHandler handler = handlers.get(request.operationId()); |
| 153 | + Response response = invoke(0, request, handler); |
| 154 | + exchange.setAttribute(RESPONSE_ATTR, response); |
| 155 | + renderer.render(exchange, response); |
| 156 | + } |
| 157 | + |
| 158 | + private Response invoke(int idx, Request request, RequestHandler handler) { |
| 159 | + if (idx == interceptors.size()) { |
| 160 | + Response response = handler.handle(request); |
| 161 | + for (ResponseDecorator decorator : decorators) { |
| 162 | + response = decorator.decorate(request, response); |
| 163 | + } |
| 164 | + return response; |
| 165 | + } |
| 166 | + return interceptors.get(idx).around(request, () -> invoke(idx + 1, request, handler)); |
| 167 | + } |
| 168 | +``` |
| 169 | + |
| 170 | +The change: the `for (ResponseDecorator ...)` loop moved from `handle` into the `idx == interceptors.size()` branch of `invoke`. Nothing else in the file changes. |
| 171 | + |
| 172 | +- [ ] **Step 2: Run the previously-failing test and confirm it PASSES** |
| 173 | + |
| 174 | +Run: |
| 175 | + |
| 176 | +```bash |
| 177 | +mvn test -Dtest=DispatchHandlerTest#decoratorSeesInterceptorBoundScopedValue |
| 178 | +``` |
| 179 | + |
| 180 | +Expected: PASS. |
| 181 | + |
| 182 | +- [ ] **Step 3: Run the whole `DispatchHandlerTest` class** |
| 183 | + |
| 184 | +Run: |
| 185 | + |
| 186 | +```bash |
| 187 | +mvn test -Dtest=DispatchHandlerTest |
| 188 | +``` |
| 189 | + |
| 190 | +Expected: all tests PASS — pre-existing `invokesRegisteredHandler` should still pass; the new test passes. |
| 191 | + |
| 192 | +- [ ] **Step 4: Commit the fix** |
| 193 | + |
| 194 | +```bash |
| 195 | +git add src/main/java/com/retailsvc/http/internal/DispatchHandler.java |
| 196 | +git commit -m "fix: Run response decorators inside interceptor ScopedValue scope" |
| 197 | +``` |
| 198 | + |
| 199 | +--- |
| 200 | + |
| 201 | +## Task 3: Pin interceptor-observes-decorated-response |
| 202 | + |
| 203 | +**Files:** |
| 204 | +- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` |
| 205 | + |
| 206 | +- [ ] **Step 1: Add the test** |
| 207 | + |
| 208 | +Append to the class: |
| 209 | + |
| 210 | +```java |
| 211 | + @Test |
| 212 | + void interceptorObservesDecoratedResponse() throws Exception { |
| 213 | + RequestHandler ok = req -> Response.status(HTTP_OK); |
| 214 | + AtomicReference<Response> seen = new AtomicReference<>(); |
| 215 | + RequestInterceptor capture = |
| 216 | + (request, next) -> { |
| 217 | + Response r = next.proceed(); |
| 218 | + seen.set(r); |
| 219 | + return r; |
| 220 | + }; |
| 221 | + ResponseDecorator stamp = (req, resp) -> resp.withHeader("X-Stamped", "yes"); |
| 222 | + |
| 223 | + HttpExchange ex = stubExchange(); |
| 224 | + withRequest( |
| 225 | + "get-x", |
| 226 | + () -> { |
| 227 | + dispatcher(Map.of("get-x", ok), List.of(capture), List.of(stamp)).handle(ex); |
| 228 | + return null; |
| 229 | + }); |
| 230 | + |
| 231 | + assertThat(seen.get()).isNotNull(); |
| 232 | + assertThat(seen.get().headers()).containsEntry("X-Stamped", "yes"); |
| 233 | + } |
| 234 | +``` |
| 235 | + |
| 236 | +- [ ] **Step 2: Run the test** |
| 237 | + |
| 238 | +```bash |
| 239 | +mvn test -Dtest=DispatchHandlerTest#interceptorObservesDecoratedResponse |
| 240 | +``` |
| 241 | + |
| 242 | +Expected: PASS. |
| 243 | + |
| 244 | +- [ ] **Step 3: Commit** |
| 245 | + |
| 246 | +```bash |
| 247 | +git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java |
| 248 | +git commit -m "test: Interceptor observes decorated response" |
| 249 | +``` |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## Task 4: Pin decorator-throw-is-catchable-by-interceptor |
| 254 | + |
| 255 | +Adds `import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR;` alongside the existing `HTTP_OK` static import (memory: `feedback_http_status_constants` — no magic numbers). |
| 256 | + |
| 257 | +**Files:** |
| 258 | +- Modify: `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` |
| 259 | + |
| 260 | +- [ ] **Step 1: Add the test** |
| 261 | + |
| 262 | +Append to the class: |
| 263 | + |
| 264 | +```java |
| 265 | + @Test |
| 266 | + void interceptorCanCatchDecoratorFailure() throws Exception { |
| 267 | + RequestHandler ok = req -> Response.status(HTTP_OK); |
| 268 | + AtomicBoolean caught = new AtomicBoolean(false); |
| 269 | + RequestInterceptor catcher = |
| 270 | + (request, next) -> { |
| 271 | + try { |
| 272 | + return next.proceed(); |
| 273 | + } catch (RuntimeException e) { |
| 274 | + caught.set(true); |
| 275 | + return Response.status(HTTP_INTERNAL_ERROR); |
| 276 | + } |
| 277 | + }; |
| 278 | + ResponseDecorator boom = |
| 279 | + (req, resp) -> { |
| 280 | + throw new IllegalStateException("boom"); |
| 281 | + }; |
| 282 | + |
| 283 | + HttpExchange ex = stubExchange(); |
| 284 | + withRequest( |
| 285 | + "get-x", |
| 286 | + () -> { |
| 287 | + dispatcher(Map.of("get-x", ok), List.of(catcher), List.of(boom)).handle(ex); |
| 288 | + return null; |
| 289 | + }); |
| 290 | + |
| 291 | + assertThat(caught.get()).isTrue(); |
| 292 | + } |
| 293 | +``` |
| 294 | + |
| 295 | +- [ ] **Step 2: Run the test** |
| 296 | + |
| 297 | +```bash |
| 298 | +mvn test -Dtest=DispatchHandlerTest#interceptorCanCatchDecoratorFailure |
| 299 | +``` |
| 300 | + |
| 301 | +Expected: PASS. (Before the Task 2 fix this would have failed because the decorator exception propagated past the already-popped interceptor frame straight to `ExceptionFilter`; after the fix the throw happens *inside* the interceptor's `try`.) |
| 302 | + |
| 303 | +- [ ] **Step 3: Commit** |
| 304 | + |
| 305 | +```bash |
| 306 | +git add src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java |
| 307 | +git commit -m "test: Interceptor can catch decorator failures" |
| 308 | +``` |
| 309 | + |
| 310 | +--- |
| 311 | + |
| 312 | +## Task 5: Full verification |
| 313 | + |
| 314 | +**Files:** none (verification only). |
| 315 | + |
| 316 | +- [ ] **Step 1: Run the full unit suite** |
| 317 | + |
| 318 | +```bash |
| 319 | +mvn test |
| 320 | +``` |
| 321 | + |
| 322 | +Expected: BUILD SUCCESS, zero failures, zero errors. No flakiness expected — all new tests are synchronous and use the existing `withRequest` ScopedValue harness. |
| 323 | + |
| 324 | +- [ ] **Step 2: Run the integration suite** |
| 325 | + |
| 326 | +```bash |
| 327 | +mvn verify |
| 328 | +``` |
| 329 | + |
| 330 | +Expected: BUILD SUCCESS. This runs Failsafe IT tests including `DecoratorAndInterceptorIT`, which exercises the same code path end-to-end through `HttpServer`. Confirms no regression in the existing decorator + interceptor IT. |
| 331 | + |
| 332 | +- [ ] **Step 3: Inspect the JaCoCo report for `DispatchHandler`** |
| 333 | + |
| 334 | +Open `target/site/jacoco/com.retailsvc.http.internal/DispatchHandler.html`. Confirm both branches of `invoke` (`idx == interceptors.size()` and the recursive case) are covered and the new decorator loop inside the base case is exercised. If a branch is uncovered, add a test before continuing. |
| 335 | + |
| 336 | +- [ ] **Step 4: SonarLint pre-push check** |
| 337 | + |
| 338 | +Per project memory `feedback_sonar_pre_push`, analyze every touched file with SonarLint MCP before pushing. The worktree caveat from `feedback_sonarlint_blind_to_worktrees` applies — SonarLint may return `not_found` for files only in the worktree. If so, rely on the CI scan for this branch and continue; do not skip the local check on files that *are* visible. |
| 339 | + |
| 340 | +Run SonarLint MCP against: |
| 341 | +- `src/main/java/com/retailsvc/http/internal/DispatchHandler.java` |
| 342 | +- `src/test/java/com/retailsvc/http/internal/DispatchHandlerTest.java` |
| 343 | + |
| 344 | +Fix any new issues in this branch before pushing. |
| 345 | + |
| 346 | +- [ ] **Step 5: Push and open PR** |
| 347 | + |
| 348 | +```bash |
| 349 | +git push -u origin fix/decorator-scoped-values |
| 350 | +gh pr create --title "fix: Run response decorators inside interceptor ScopedValue scope" --body "$(cat <<'EOF' |
| 351 | +## Summary |
| 352 | +- Move the response-decorator loop into the base case of `DispatchHandler.invoke(...)` so decorators execute inside every `RequestInterceptor`'s `ScopedValue` scope. Honors the contract documented in README "Combining the two". |
| 353 | +- Interceptors observe the decorated `Response` on the way back up the stack. |
| 354 | +- A decorator exception now propagates through the interceptor chain (interceptors can catch it) before reaching `ExceptionFilter`. |
| 355 | +
|
| 356 | +Spec: `docs/superpowers/specs/2026-05-23-decorator-scoped-value-scope-design.md` |
| 357 | +
|
| 358 | +## Test plan |
| 359 | +- [x] New unit test: decorator reads interceptor-bound `ScopedValue` |
| 360 | +- [x] New unit test: interceptor sees decorator-added header on the returned `Response` |
| 361 | +- [x] New unit test: interceptor `try`/`catch` around `next.proceed()` catches a throwing decorator |
| 362 | +- [x] `mvn verify` passes (including `DecoratorAndInterceptorIT`) |
| 363 | +EOF |
| 364 | +)" |
| 365 | +``` |
| 366 | + |
| 367 | +--- |
| 368 | + |
| 369 | +## Notes for the implementer |
| 370 | + |
| 371 | +- **Do not touch `RequestPreparationFilter` or `AfterResponseHook`.** The spec explicitly scopes those out. Fixing after-hooks to see interceptor ScopedValues is a separate, larger change with a contract shift. |
| 372 | +- **Do not edit `README.md`.** The README is already correct; this PR brings the implementation into compliance. |
| 373 | +- **Decorator order is preserved.** They still iterate in registration order; that was true before and is still true after the move. |
| 374 | +- **Branch is `fix/decorator-scoped-values`** (already created via worktree). |
| 375 | +- **Commit subjects start with a capital letter after the type prefix** — see memory `feedback_skip_commitlint_in_worktrees`. |
0 commit comments