diff --git a/README.md b/README.md
index c3e542cf8..4055c9a1b 100644
--- a/README.md
+++ b/README.md
@@ -293,6 +293,8 @@ Run with `go run gen_package.go`, then delete the script.
If you encounter difficulties or unexpected results while installing the plugin with SonarQube, or when trying to scan a repository, please check out our guide [*Testing your configuration and troubleshooting*](docs/TROUBLESHOOTING.md) to run our plugin with step-by-step instructions.
+To measure the plugin's runtime performance and heap usage — including a full end-to-end scan of a large project (Keycloak) — see [*Performance & Heap Testing*](docs/PERFORMANCE_TESTING.md).
+
## Contribution Guidelines
If you'd like to contribute to Sonar Cryptography Plugin, please take a look at our
diff --git a/docs/PERFORMANCE_TESTING.md b/docs/PERFORMANCE_TESTING.md
new file mode 100644
index 000000000..bd6d2232c
--- /dev/null
+++ b/docs/PERFORMANCE_TESTING.md
@@ -0,0 +1,391 @@
+# Performance & Heap Testing
+
+This guide explains how to measure the plugin's runtime performance and heap usage,
+primarily by scanning a large real-world project (**Keycloak**) end-to-end through
+SonarQube. It exists to validate memory-sensitive engine work such as the call-stack
+AST-detach fix (see `docs/superpowers/plans/2026-07-05-callstack-hooks-heap-reduction.md`).
+
+There are two levels of testing:
+
+| Level | What it is | When to use |
+|---|---|---|
+| **A. Self-contained JUnit harness** | `CallStackHeapPerfTest` — generates a synthetic cross-file corpus, scans it in-process, asserts detach invariants. No Docker, no network. | Quick, deterministic regression check. Runs in seconds–minutes. |
+| **B. Keycloak end-to-end scan** | Full `mvn sonar:sonar` of Keycloak against a local SonarQube with the plugin installed. | Realistic heap/time numbers on a large project. Manual, ~10–15 min + setup. |
+
+Start with **A** for a fast signal; use **B** to get true, large-project numbers.
+
+---
+
+## A. Self-contained JUnit harness (fast)
+
+A `@Tag("performance")` test excluded from the default build. It generates N synthetic
+cross-file crypto wrapper/caller pairs, compiles them to a classpath directory (so types
+resolve and detections fire), scans them with `CheckVerifier`, and asserts that recorded
+calls were detached (ASTs released) at `leaveFile`.
+
+```bash
+# default (~200 files, a few seconds)
+mvn test -pl java -DexcludedGroups= -Dtest=CallStackHeapPerfTest
+
+# heavy soak (scale the corpus)
+mvn test -pl java -DexcludedGroups= -Dtest=CallStackHeapPerfTest -Dperf.corpus.files=3000
+```
+
+It prints a line like:
+
+```
+[callstack-perf] files=200 units=100 time=2325ms heapDeltaMB=83 \
+ retainedWithTree=0 detached=367 total=367 buckets=2 ratio=1.000
+```
+
+- **`retainedWithTree`** — recorded calls still pinning a live AST. Must stay ~0.
+- **`detached` / `ratio`** — calls converted to tree-free records. Ratio must stay high.
+- **`heapDeltaMB`** — reported only, **never asserted** (a coarse whole-JVM number; at this
+ synthetic scale it is *not* a reliable proxy for AST-pinning savings — the generated files
+ are tiny, so pinned ASTs cost little in bytes). Use Keycloak (Part B) for real byte numbers.
+
+The assertions (`ratio >= 0.9`, `retainedWithTree <= 10`) fail hard if AST-detaching regresses.
+
+> **Why not CI?** It is `@Tag("performance")` and excluded from the default build via the
+> surefire `performance` config in the root `pom.xml`.
+> `mvn test` skips it; `-DexcludedGroups=` re-includes it.
+
+---
+
+## B. Keycloak end-to-end scan (real numbers)
+
+### Prerequisites
+
+- **Docker** + **Docker Compose** (for the local SonarQube + PostgreSQL).
+- **JDK 17** to build this plugin; **the JDK Keycloak requires** to build Keycloak
+ (currently **JDK 21** for `main`).
+- **Maven 3.9+**.
+- A full **JDK** (not just a JRE) on `PATH` for `jcmd` (used to sample heap — see Step 6).
+- ~10 GB free RAM and a few GB disk. The scanner engine is capped at 6 GB in Step 5.
+
+All commands below assume the plugin repo root is the current directory unless stated.
+
+---
+
+### Step 1 — Clone Keycloak
+
+```bash
+cd ~/Downloads # or any working directory outside this repo
+git clone --depth 1 https://github.com/keycloak/keycloak.git keycloak-main
+cd keycloak-main
+```
+
+`--depth 1` is fine — we only need the source tree, not history.
+
+---
+
+### Step 2 — Build Keycloak (REQUIRED — compile so types resolve)
+
+**This is the most important step.** The heap term only appears when detections actually
+fire, and detections only fire when **types resolve**. A source-only scan (no compiled
+classes / `sonar.java.binaries`) resolves nothing → 0 detections → the call-stack term is 0
+and the measurement is meaningless.
+
+Build Keycloak so every module produces `target/classes`:
+
+```bash
+# from the keycloak-main directory; uses Keycloak's Maven wrapper
+./mvnw -q clean install -DskipTests -DskipITs
+```
+
+This is heavy (many modules) and needs Keycloak's required JDK. When it finishes you should
+have compiled classes:
+
+```bash
+find . -type d -path '*target/classes' | wc -l # expect dozens of dirs
+```
+
+If you only want a subset, you must still compile the modules you intend to scan (and their
+dependencies), e.g. `./mvnw -pl services -am -DskipTests clean install`.
+
+---
+
+### Step 3 — Build the plugin and start SonarQube with it installed
+
+SonarQube loads plugins **at startup**, so the JAR must be in place and the container
+(re)started for changes to take effect.
+
+**3a. Build the plugin JAR from your branch:**
+
+```bash
+# from the plugin repo root
+mvn clean package -DskipTests
+ls -la sonar-cryptography-plugin/target/sonar-cryptography-plugin-*.jar # the artifact
+```
+
+> If `mvn` reformats/regenerates `mapper/.../JsonCipherSuites.java` (a known Spotless/fetch
+> quirk), restore it before committing: `git checkout -- mapper/src/main/java/com/ibm/mapper/mapper/ssl/json/JsonCipherSuites.java`.
+
+**3b. Deploy the JAR into the compose-mounted plugins directory:**
+
+```bash
+rm -f .SonarQube/plugins/sonar-cryptography-plugin-*.jar
+cp sonar-cryptography-plugin/target/sonar-cryptography-plugin-*.jar .SonarQube/plugins/
+```
+
+(Do **not** copy the `*-sources.jar` or `original-*.jar`.)
+
+**3c. Start SonarQube + PostgreSQL.** `docker-compose.yaml` uses `user: "${UID}"`, and in
+zsh `UID` is a read-only variable that is *not* exported — if it is blank the container runs
+as root and Elasticsearch/temp dirs get root-owned, causing permission crashes. Provide `UID`
+explicitly via a `.env` file (Compose reads it automatically):
+
+```bash
+echo "UID=$(id -u)" > .env
+docker compose up -d
+```
+
+Wait until it reports UP:
+
+```bash
+curl -s http://localhost:9000/api/system/status # {"status":"UP", ...}
+```
+
+> If a previous run started as root and left root-owned volumes, wipe and retry:
+> `docker compose down -v && echo "UID=$(id -u)" > .env && docker compose up -d`.
+
+**3d. Verify the running instance loaded YOUR plugin** (the deployed JAR must contain your
+changes, and SonarQube must have started *after* you copied it):
+
+```bash
+# the plugin's registered timestamp should be AFTER your JAR's build time
+TOKEN= # from Step 4
+curl -s -u "$TOKEN:" http://localhost:9000/api/plugins/installed \
+ | python3 -c "import sys,json;[print(p['key'],p['version']) for p in json.load(sys.stdin)['plugins'] if 'crypto' in p['key']]"
+# confirm the JAR carries your code:
+unzip -l .SonarQube/plugins/sonar-cryptography-plugin-*.jar | grep CallContextStats
+```
+
+If you rebuild the plugin later, repeat 3a–3b then `docker compose restart sonarqube`.
+
+---
+
+### Step 4 — Create an analysis token
+
+In the SonarQube UI (`http://localhost:9000`, default login `admin`/`admin`, change on first
+login): **My Account → Security → Generate Token** (type: *Global Analysis Token*). Copy it.
+
+```bash
+export TOKEN=sqp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+```
+
+---
+
+### Step 5 — Run the scan with heap instrumentation
+
+Run from the **Keycloak** directory (so `cbom.json` and scanner work dirs land there, not in
+this repo).
+
+**Important — the scanner forks a separate JVM.** `sonar-maven-plugin` downloads and runs the
+**SonarScanner Engine** in a child JVM under `~/.sonar/cache/...` — this is where the plugin,
+`CallStackAgent`, and the heap actually live. It does **not** inherit `MAVEN_OPTS`. To cap and
+instrument the *right* JVM, pass `-Dsonar.scanner.javaOpts`.
+
+```bash
+cd ~/Downloads/keycloak-main
+
+mvn org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \
+ -Dsonar.projectKey=keycloak \
+ -Dsonar.projectName='keycloak' \
+ -Dsonar.host.url=http://localhost:9000 \
+ -Dsonar.token=$TOKEN \
+ -Dsonar.scanner.javaOpts="-Xms512m -Xmx6g -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp"
+```
+
+- `-Xmx6g` is deliberate: the pre-fix code drove this scan past **7 GB and never finished**,
+ so completing under 6 GB is itself a pass/fail signal.
+- A heap dump is written to `/tmp` if it OOMs, for post-mortem analysis.
+
+> Avoid embedding `-Xlog:gc*:file=...` in `sonar.scanner.javaOpts`; the multi-colon argument
+> is mangled when passed through the scanner. Sample heap externally instead (Step 6).
+
+---
+
+### Step 6 — Sample the engine heap during the scan
+
+While the scan runs, sample the **engine** JVM's heap with `jcmd` (from a full JDK — the
+scanner's bundled JRE has no `jcmd`). Run this in a second terminal *after* the scan reaches
+its analysis phase:
+
+```bash
+JCMD=$(command -v jcmd) # must be a JDK jcmd
+OUT=/tmp/keycloak-heap-samples.csv
+echo "epoch,rss_mb,heap_used_mb,heap_total_mb,meta_used_mb" > "$OUT"
+while true; do
+ E=$(pgrep -f sonar-scanner-engine-shaded | head -1)
+ [ -z "$E" ] && break # engine gone -> scan finished
+ RSS=$(ps -o rss= -p "$E" 2>/dev/null | tr -d ' ')
+ INFO=$("$JCMD" "$E" GC.heap_info 2>/dev/null)
+ HU=$(echo "$INFO" | grep -oE 'used [0-9]+K' | head -1 | grep -oE '[0-9]+')
+ HT=$(echo "$INFO" | grep -oE 'total [0-9]+K' | head -1 | grep -oE '[0-9]+')
+ MU=$(echo "$INFO" | grep Metaspace | grep -oE 'used [0-9]+K' | grep -oE '[0-9]+')
+ [ -n "$RSS" ] && echo "$(date +%s),$((RSS/1024)),$((${HU:-0}/1024)),$((${HT:-0}/1024)),$((${MU:-0}/1024))" >> "$OUT"
+ sleep 15
+done
+
+# peak heap used / committed / RSS across the run:
+tail -n +2 "$OUT" | awk -F, 'BEGIN{u=0;c=0;r=0}
+ $3>u{u=$3} $4>c{c=$4} $2>r{r=$2}
+ END{printf "peak heap_used=%d MB peak committed=%d MB peak RSS=%d MB samples=%d\n",u,c,r,NR}'
+```
+
+---
+
+### Step 7 — Interpret the results
+
+Confirm the scan actually exercised the plugin:
+
+```bash
+grep -E "ANALYSIS SUCCESSFUL|CBOM was successfully generated"
+```
+
+A CBOM (`cbom.json` in the scan CWD) means detections fired, so the call-stack term was
+exercised.
+
+**What good looks like (with the AST-detach fix, measured on Keycloak `main`, SonarQube 26.1,
+93 modules / ~8200 files):**
+
+| Metric | Pre-fix (old) | With AST-detach fix |
+|---|---|---|
+| Outcome | ~7 GB **and climbing, did not finish** | **ANALYSIS SUCCESSFUL** in ~11 min |
+| Under `-Xmx6g` | would OOM | completed, no OOM |
+| Peak heap used | 7 GB+ (linear, no plateau) | ~4.6 GB (oscillating, GC reclaims) |
+
+- **Healthy:** `heap_used` **oscillates** — rises then drops as G1 reclaims — and the scan
+ completes. The post-GC floor may grow modestly but the run finishes under the cap.
+- **Regression:** `heap_used` climbs **monotonically** toward the cap and OOMs (heap dump in
+ `/tmp`), or the scan never finishes. That signals AST pinning (or another unbounded term)
+ has returned.
+
+Note the residual ~4.6 GB is *not* the call-stack AST term (that is eliminated — see the
+synthetic harness's `retainedWithTree=0`); it is SonarQube's baseline analysis cost plus the
+CBOM nodes accumulated for the whole scan (`JavaAggregator.detectedNodes`). `jcmd`'s
+`heap_used` includes uncollected garbage, so the true retained set sits at the GC *floors*,
+below the sampled peak.
+
+---
+
+### Cleanup
+
+```bash
+# stop SonarQube (keep data) # or: down -v to wipe volumes
+docker compose down
+
+# remove scan artifacts from the Keycloak dir
+rm -f ~/Downloads/keycloak-main/cbom.json
+rm -rf ~/Downloads/keycloak-main/.scannerwork
+```
+
+---
+
+## C. Post-detach floor attribution (H1)
+
+AST-detach removed the dominant AST-pinning heap term, but the post-GC floor still grows over a
+scan (observed ~1.6 → ~3.4 GB). This procedure attributes that residual to one of three
+populations so we know whether the call-stack still needs trimming (H2) or whether retained CBOM
+nodes dominate (a separate follow-up).
+
+### Two signals
+
+**1. In-process population counts (cheap).** At end of scan the plugin logs, at DEBUG:
+
+```
+[heap-attribution] detectedNodes= detachedCalls= totalCalls= callStackBuckets=
+```
+
+Enable DEBUG for the plugin (e.g. `-Dsonar.log.level=DEBUG` on the scanner, or the analysis
+`sonar.verbose=true`) and read the line from the scanner log. The fast local proxy is
+`CallStackHeapPerfTest`, whose report line now also prints `detectedNodes=` (note: `0` in that
+harness — it runs with `isInventory=false`, so CBOM nodes are not aggregated there; the count is
+meaningful only on a real inventory scan).
+
+Counts size the *populations*, not their bytes — a small count of heavy objects can still
+dominate. Use them to spot which population grows, then confirm bytes with the histogram below.
+
+**2. Byte attribution via `jmap` (decisive).** During a constrained-heap Keycloak scan (see
+section B), capture a live histogram near the end of analysis:
+
+```bash
+# find the scanner JVM pid (the surefire/scanner java process running the analysis)
+jps -l
+# live histogram (forces a GC first), sorted by retained bytes
+jmap -histo:live > histo.txt
+```
+
+Bucket the top entries of `histo.txt` into the three sources:
+
+| Bucket | Classes to sum in `histo.txt` |
+|---|---|
+| Retained CBOM nodes | `com.ibm.mapper.model.**` (e.g. `Algorithm`, `Key`, `Property`, `MessageDigest`, …) and their child `HashMap`/`HashMap$Node` share |
+| Detached call-stack | `com.ibm.engine.callstack.DetachedCall`, `...callstack.ArgSnapshot`, `...callstack.ResolvedSnapshotValue` |
+| Residual Tree / hooks | `org.sonar.**Tree*` still live + `com.ibm.engine.hooks.**` |
+
+Sample two or three histograms as the scan progresses to see which bucket *grows* (the floor is
+about accumulation, not a one-time cost).
+
+### Decision — measured 2026-07-06 (Keycloak `main`, 94 compiled modules, SonarQube 26.1, `-Xmx6g`)
+
+Scan outcome: **ANALYSIS SUCCESSFUL** in ~14 min, CBOM generated (108 detected assets → 68
+components), no OOM; post-GC heap floor oscillated and ended ~2.9–3.4 GB (healthy — G1 reclaims,
+no monotonic climb). Attribution from live `GC.class_histogram` (post-full-GC) sampled early /
+mid / late in the run:
+
+| Population | early | mid | late (near end) |
+|---|---|---|---|
+| Retained CBOM nodes (`com.ibm.mapper.**`) | ~0 | 0.01 MB (484) | **0.02 MB (824 inst)** |
+| Detached call-stack (`com.ibm.engine.callstack.**`) | ~0 | 7.6 MB (291k) | **15.6 MB (594k inst)** |
+| Hooks (`com.ibm.engine.hooks.**`) | ~0 | 0.001 MB | **~1 KB (1376 inst)** |
+| **Plugin total (`com.ibm.**`)** | ~0 | 14.2 MB | **28.4 MB** |
+| **Whole heap floor** | 0.05 GB | 1.55 GB | **2.90 GB** |
+
+The ~2.9 GB floor is overwhelmingly **SonarQube / ECJ baseline**, not plugin state — the top
+retained classes are `byte[]` (332 MB), `HashMap$Node` (251 MB), `Object[]` (219 MB), `char[]`
+(210 MB), `ArrayList` (176 MB), and sonar-java/ECJ semantic objects (`InternalPosition` 152 MB,
+`InternalSyntaxToken` 122 MB, `MethodBinding` 95 MB, `InternalRange` 76 MB).
+
+**Outcome — the original hypothesis is disproven.** The floor growth (~1.6 → ~3.4 GB) is *not*
+`JavaAggregator.detectedNodes`: retained CBOM nodes are **negligible (~25 KB, 824 instances)**.
+The entire plugin footprint is **~28 MB (~1 % of the floor)**, and the floor growth tracks
+SonarQube's own accumulating semantic model / AST of the module under analysis — which the plugin
+cannot reduce. Within the plugin the **call-stack dominates** (15.6 MB vs. 0.02 MB) and grows
+linearly/unbounded (291k → 594k records mid→late), but AST-detach keeps each record tiny, so the
+absolute heap cost is small.
+
+**H2 routing (revised by this measurement):**
+- **No `detectedNodes` spec.** CBOM-node retention is not a heap problem — drop that candidate.
+- **No heap-motivated retention cap.** 594k detached records ≈ 15.6 MB is not a memory risk; the
+ cap (old Task 5) is unjustified on heap grounds — defer/drop it.
+- **Eligibility filter → justify on CPU, not heap.** Skipping library calls still avoids building
+ their expensive detached form (`buildDetachedCall`), so fold it into the **throughput track
+ (C1/C2)**, not a heap spec. Net: the heap track is effectively closed by this measurement.
+
+> Note: signal 1 (the `[heap-attribution]` DEBUG line) does not surface in the Maven scanner
+> console even with `sonar.verbose=true` — the scanner does not route plugin `LOGGER.debug` there
+> (the INFO `Detected Assets` statistics line does print). The `jmap`/`jcmd` histogram (signal 2)
+> is the reliable attribution path; use it directly.
+
+> Note on H2's eligibility filter: the predicate cannot be derived from `methodSymbol().declaration()`
+> — cross-file *user* calls resolve via `sonar.java.binaries` and have a null declaration, exactly
+> like library calls (see the comment in `JavaLanguageSupport.isDetachableCall`). The discriminator
+> must be pinned empirically against the `crossfile/` fixtures before the filter is written.
+
+---
+
+## Gotchas quick reference
+
+- **0 detections / term is 0** → Keycloak wasn't compiled. Rebuild (Step 2); the scan needs
+ resolvable types (`sonar.java.binaries`).
+- **Heap cap/logging seem ignored** → you set `MAVEN_OPTS`. The analysis runs in the forked
+ scanner-engine JVM; use `-Dsonar.scanner.javaOpts` (Step 5).
+- **`jcmd` not found / can't attach** → the scanner's bundled JRE lacks `jcmd`; use a full JDK's
+ `jcmd` (`GC.heap_info` attaches across compatible versions).
+- **Container permission crashes / runs as root** → `UID` was unset (zsh read-only). Create
+ `.env` with `UID=$(id -u)` and, if it already ran as root, `docker compose down -v` first.
+- **Plugin changes not reflected** → rebuild JAR, copy into `.SonarQube/plugins/`, then
+ `docker compose restart sonarqube` (plugins load at startup). Verify with the
+ `api/plugins/installed` check in Step 3d.
diff --git a/docs/superpowers/plans/2026-07-05-callstack-detach-implementation.md b/docs/superpowers/plans/2026-07-05-callstack-detach-implementation.md
new file mode 100644
index 000000000..575a51f8c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-05-callstack-detach-implementation.md
@@ -0,0 +1,138 @@
+# Call-stack AST-Detach Heap Reduction — Implementation Record (as-built)
+
+> **Status:** Implemented on branch `feat/callstack-ast-detach`. Full clean build green. One task
+> (heap measurement) deferred. This document records what was actually built; it diverges
+> substantially from the original step-by-step plan because several assumptions were corrected
+> during execution (see "Divergences from the original plan").
+
+**Goal:** Stop the per-language `CallStackAgent` from pinning whole-file ASTs for the entire scan
+(the measured ~7 GB / ~179k-`CallContext` runtime leak) without regressing detection or SonarQube
+issue reporting — same-file *or* cross-file.
+
+**Outcome:** Recorded calls are detached from their AST when their file finishes analysis. A file's
+AST becomes garbage-collectable after `leaveFile`, while cross-file hook matching continues from a
+tree-free snapshot. Same-file detections keep full behavior; cross-file detections produce CBOM
+nodes and raise SonarQube issues without pinning any AST.
+
+**Tech stack:** Java 17, Maven multi-module, sonar-java 8.0.1, JUnit 5 + AssertJ + `CheckVerifier`.
+
+---
+
+## As-built architecture
+
+A recorded call is a sealed `CallContext`:
+- `RetainedCall(T tree, IScanContext publisher, @Nullable DetachedCall detachedForm)` — holds the
+ live tree; used while the call's file is being analyzed.
+- `DetachedCall(IType invokedObjectType, String methodName, List parameterTypes,
+ List arguments, DetachedScanContext publisher)` — tree-free.
+
+**Lifecycle:**
+1. **Record (file live):** `JavaDetectionEngine.recordCall` stores a `RetainedCall`. If the call is
+ detachable (`isDetachableCall`), it also pre-builds the `DetachedCall` *now* (arguments resolved
+ while the file's AST + symbols are live) and stashes it in `RetainedCall.detachedForm`.
+2. **Same-file fire (file live):** hook detections during the file's own analysis match/replay
+ through the `RetainedCall`'s live tree + context, so their SonarQube issues and value resolution
+ are unchanged.
+3. **`leaveFile`:** `JavaBaseDetectionRule.leaveFile` → `ILanguageSupport.notifyLeaveFile` →
+ `CallStackAgent.detachCallsForFile(inputFile)` swaps each of that file's `RetainedCall`s for its
+ `detachedForm`, dropping the tree → the file's AST is GC-eligible.
+4. **Cross-file fire (file gone):** later hooks match the `DetachedCall` via
+ `MethodMatcher.matchKeys(...)` and replay from its `ArgSnapshot`s. The detection value's location
+ is an AST-free `DetachedSyntaxToken`; its SonarQube issue is raised via `SonarComponents` (see
+ Reporting).
+
+**Detachability (`JavaLanguageSupport.isDetachableCall`):** a `MethodInvocationTree` whose arguments
+contain no `NEW_ARRAY` (the one value-factory-dependent resolution case, `SizeFactory`). It does
+**not** require `methodSymbol().declaration() != null` — cross-file callees resolve via the binary
+classpath, so `declaration()` is null for exactly the cross-file calls we must detach.
+
+**Reporting (cross-file, no AST):** sonar-java's public issue API is entirely tree-based, and any
+tree pins the AST. So cross-file detached issues are raised through the internal
+`SonarComponents.reportIssue(new AnalyzerMessage(check, inputFile, TextSpan, message, 0))` —
+`SonarComponents` is shared per scan and pins no AST. It is captured at record time and read
+reflectively from the protected `DefaultModuleScannerContext.sonarComponents` field
+(`JavaDetachedIssueReporter`, with a graceful null fallback to CBOM-only). `IDetachedIssueReporter`
+abstracts this in the engine; `DetachedScanContext.reportIssue` delegates to it using the
+`DetachedSyntaxToken`'s captured range.
+
+---
+
+## Files (as-built)
+
+Engine (`engine/src/main/java/com/ibm/engine/`):
+- `callstack/CallContext.java` — sealed interface (`permits RetainedCall, DetachedCall`).
+- `callstack/RetainedCall.java` — NEW — `(tree, publisher, @Nullable detachedForm)`.
+- `callstack/DetachedCall.java` — NEW — tree-free record.
+- `callstack/ArgSnapshot.java` — NEW — per-argument resolved values + location.
+- `callstack/DetachedSyntaxToken.java` — NEW — AST-free `SyntaxToken` (value location).
+- `callstack/DetachedScanContext.java` — NEW — AST-free `IScanContext` (InputFile + path + reporter).
+- `callstack/IDetachedIssueReporter.java` — NEW — engine-side reporting abstraction.
+- `callstack/CallStackAgent.java` — variant-aware add/match; `detachCallsForFile`; key-indexed
+ subscription lookup; `visitedTreeObjects` removed (per-bucket dedup).
+- `detection/MethodMatcher.java` — `matchKeys(IType, name, List)` overload.
+- `detection/DetectionStore.java`, `detection/DetectionStoreWithHook.java` — fire path carries
+ `CallContext`; detached replay branch (`replayDetachedParameterHook`).
+- `hooks/*` — `notify`/`onHookInvocation` carry `CallContext`; `isInvocationOn(CallContext)` matches
+ `DetachedCall` via `matchKeys`.
+- `language/ILanguageSupport.java` — `isDetachableCall`, `parameterIndexOf`, `notifyLeaveFile`
+ defaults.
+- `language/java/JavaLanguageSupport.java` — Java impls of the above.
+- `language/java/JavaDetectionEngine.java` — `recordCall` / `buildDetachedCall` / `captureLocation`.
+- `language/java/JavaDetachedIssueReporter.java` — NEW — `SonarComponents` reporter.
+
+Java plugin (`java/src/`):
+- `main/.../rules/detection/JavaBaseDetectionRule.java` — `leaveFile` hook.
+- `test/files/rules/detection/crossfile/*` + `test/.../crossfile/CrossFileHookDetachTest.java`,
+ `IsDetachableCallTest.java` — NEW — the first multi-file tests (compiled-classpath technique).
+
+---
+
+## Testing
+
+**Cross-file test technique (new to the repo):** `CheckVerifier.onFiles(...)` alone does not give
+sibling sources shared semantics, so cross-file callee types don't resolve and hooks never match
+(why no multi-file test ever existed). Production resolves cross-file types via `sonar.java.binaries`
+(the project is compiled first). Reproduced by compiling the fixtures to `.class` in-test and passing
+the dir to `CheckVerifier.withClassPath(...)`. See `CrossFileHookDetachTest`.
+
+**What the cross-file guard asserts:** all three detach branches resolve cross-file into the CBOM
+(literal → detached, field-constant → detached, `new byte[]` → retained). The retained (array) case
+also asserts its SonarQube issue via `verifyIssues`.
+
+**`CheckVerifier` limitation (documented):** it reads issues only from the test context's
+`getIssues()` (populated by `context.reportIssue`), not from `SonarComponents`. So cross-file
+detached squid issues (retro-scan order) are production-correct but not `verifyIssues`-observable;
+they are asserted via CBOM values instead.
+
+---
+
+## Divergences from the original plan (and why)
+
+1. **Detach at `leaveFile`, not at record time.** Record-time detach routed same-file hook
+ detections through the tree-free path, so they lost their live-context SonarQube issues and broke
+ ~7 `rules/resolve` tests. Deferring to `leaveFile` keeps same-file detections fully live.
+2. **`isDetachableCall` dropped the `declaration() != null` check.** That check (from the abandoned
+ Phase-1 *filter* rationale) is null for every cross-file call (binary-classpath resolution), so it
+ would have made cross-file calls non-detachable — silently defeating the heap goal.
+3. **Cross-file reporting via internal `SonarComponents` + reflection.** The spec assumed
+ `reportIssue` was off the path; it is not (`report()` yields issues). Public reporting is
+ tree-based (AST-pinning), so the internal `SonarComponents` path was required.
+4. **No explicit `JavaTranslator` branch for `DetachedSyntaxToken`.** Unnecessary — it flows through
+ `getDetectionContextFrom`'s generic token path correctly.
+
+---
+
+## Remaining
+
+- **Heap measurement (deferred):** scan a large compiled project (e.g. keycloak via `mvn
+ sonar:sonar`, or supply `sonar.java.libraries`) under a constrained `MAVEN_OPTS="-Xmx"`;
+ capture `jmap -histo` before/after and record the drop in retained `CallContext`/AST and peak
+ heap, versus the ~179k / ~7 GB baseline in
+ `docs/superpowers/plans/2026-07-05-callstack-hooks-heap-reduction.md`. This is the piece that
+ quantifies the win and should run before merge.
+
+## Follow-ups worth a reviewer's eye
+
+- `JavaDetachedIssueReporter` reflectively reads an internal sonar-java field — fragile across major
+ upgrades (graceful null fallback mitigates). Consistent with the existing `ExpressionUtils` usage.
+- Enum accesses, Python and Go remain on the always-retained path (unchanged) by design.
diff --git a/docs/superpowers/plans/2026-07-06-callstack-heap-attribution.md b/docs/superpowers/plans/2026-07-06-callstack-heap-attribution.md
new file mode 100644
index 000000000..d7e0381ca
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-06-callstack-heap-attribution.md
@@ -0,0 +1,457 @@
+# Call-stack Heap Attribution (H1) Implementation Plan
+
+> **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.
+
+**Goal:** Attribute the post-detach heap floor (observed ~1.6 → ~3.4 GB over a scan) to its actual sources — retained CBOM `INode`s vs. detached call-stack records vs. hooks — so a written decision can gate the deferred call-stack trim (H2).
+
+**Architecture:** Purely additive observability. A small immutable summary record composes the three population *counts* already reachable in-process; `OutputFileJob` logs it once at end-of-scan; the perf harness prints the CBOM-node count alongside its existing `CallContextStats` line. True *byte* attribution is a documented manual `jmap -histo:live` runbook on a constrained-heap Keycloak scan. No engine API changes, no detection-behavior changes.
+
+**Tech Stack:** Java 17, Maven multi-module, SLF4J, JUnit 5 + AssertJ, SonarQube PostJob API, `jmap`.
+
+## Global Constraints
+
+- Java 17; changes confined to `sonar-cryptography-plugin` (main + test), `java` test/perf, and `docs/`. No `engine` or language-support API changes in H1.
+- Apache 2.0 license header in every new `.java` file — copy verbatim from a neighbor (e.g. `sonar-cryptography-plugin/src/main/java/com/ibm/plugin/OutputFileJob.java` lines 1–19), updating the year to 2026 for new files.
+- Run `mvn spotless:apply` before every commit. If Spotless truncates `JsonCipherSuites`, restore it before committing (known issue).
+- `mvn test -pl sonar-cryptography-plugin` and `mvn test -pl java` stay green throughout.
+- H2 (eligibility filter + retention cap) is explicitly **out of scope** — this plan ends with the attribution decision that decides whether/how H2 proceeds. Do not add the filter or cap here.
+
+## Scope Check
+
+This is a single, self-contained observability + measurement deliverable (one subsystem: end-of-scan reporting). It does not need decomposition.
+
+## File Structure
+
+```
+sonar-cryptography-plugin/src/main/java/com/ibm/plugin/
+ HeapAttributionSummary.java (CREATE) immutable record of the three population counts + a format() string
+ OutputFileJob.java (MODIFY) build the summary and DEBUG-log it once, before reset()
+ ScannerManager.java (MODIFY) heapAttribution() — read the counts from the aggregators/lang-support
+sonar-cryptography-plugin/src/test/java/com/ibm/plugin/
+ HeapAttributionSummaryTest.java (CREATE) unit test for the record + format()
+ ScannerManagerTest.java (CREATE) test heapAttribution() reflects added nodes
+java/src/test/java/com/ibm/plugin/perf/
+ CallStackHeapPerfTest.java (MODIFY) add detectedNodes count to the printed report line
+docs/
+ PERFORMANCE_TESTING.md (MODIFY) jmap attribution runbook + results-table template + decision rule
+```
+
+**Responsibilities:**
+- `HeapAttributionSummary` — pure value + formatting; the single testable unit, no statics.
+- `ScannerManager.heapAttribution()` — the one place that reads the static aggregators / language support and constructs the summary.
+- `OutputFileJob` — end-of-scan trigger; logs the summary.
+- `CallStackHeapPerfTest` — fast in-process signal for local iteration.
+- `PERFORMANCE_TESTING.md` — the decisive manual byte-attribution procedure + where the H1 decision is recorded.
+
+---
+
+## Task 1: `HeapAttributionSummary` record + `format()`
+
+**Files:**
+- Create: `sonar-cryptography-plugin/src/main/java/com/ibm/plugin/HeapAttributionSummary.java`
+- Test: `sonar-cryptography-plugin/src/test/java/com/ibm/plugin/HeapAttributionSummaryTest.java`
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces: `record HeapAttributionSummary(int detectedNodes, int detachedCalls, int totalCalls, int callStackBuckets)` with instance method `String format()`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `sonar-cryptography-plugin/src/test/java/com/ibm/plugin/HeapAttributionSummaryTest.java` (copy the license header from `OutputFileJob.java`, year 2026):
+
+```java
+package com.ibm.plugin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.junit.jupiter.api.Test;
+
+class HeapAttributionSummaryTest {
+
+ @Test
+ void formatContainsAllFourPopulationCounts() {
+ HeapAttributionSummary summary = new HeapAttributionSummary(1200, 179000, 630000, 15800);
+ String line = summary.format();
+ assertThat(line)
+ .contains("detectedNodes=1200")
+ .contains("detachedCalls=179000")
+ .contains("totalCalls=630000")
+ .contains("callStackBuckets=15800");
+ }
+
+ @Test
+ void formatIsStableForZeroPopulations() {
+ assertThat(new HeapAttributionSummary(0, 0, 0, 0).format())
+ .isEqualTo(
+ "[heap-attribution] detectedNodes=0 detachedCalls=0 "
+ + "totalCalls=0 callStackBuckets=0");
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -pl sonar-cryptography-plugin -Dtest=HeapAttributionSummaryTest`
+Expected: FAIL — `HeapAttributionSummary` does not exist (compilation error).
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `sonar-cryptography-plugin/src/main/java/com/ibm/plugin/HeapAttributionSummary.java` (license header from `OutputFileJob.java`, year 2026):
+
+```java
+package com.ibm.plugin;
+
+import javax.annotation.Nonnull;
+
+/**
+ * End-of-scan snapshot of the three heap populations whose relative size decides the H1
+ * attribution: retained CBOM nodes, detached call-stack records, and the call-stack bucket count.
+ * Counts only — byte attribution is the manual {@code jmap} runbook in {@code
+ * docs/PERFORMANCE_TESTING.md}.
+ *
+ * @param detectedNodes retained CBOM {@code INode}s (Java aggregator)
+ * @param detachedCalls tree-free {@code DetachedCall}s in the call stack
+ * @param totalCalls total recorded calls (detached + retained-with-tree)
+ * @param callStackBuckets number of hash buckets in the call stack
+ */
+public record HeapAttributionSummary(
+ int detectedNodes, int detachedCalls, int totalCalls, int callStackBuckets) {
+
+ @Nonnull
+ public String format() {
+ return "[heap-attribution] detectedNodes="
+ + detectedNodes
+ + " detachedCalls="
+ + detachedCalls
+ + " totalCalls="
+ + totalCalls
+ + " callStackBuckets="
+ + callStackBuckets;
+ }
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -pl sonar-cryptography-plugin -Dtest=HeapAttributionSummaryTest`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Format and commit**
+
+```bash
+mvn spotless:apply -pl sonar-cryptography-plugin
+git add sonar-cryptography-plugin/src/main/java/com/ibm/plugin/HeapAttributionSummary.java \
+ sonar-cryptography-plugin/src/test/java/com/ibm/plugin/HeapAttributionSummaryTest.java
+git commit -m "feat(plugin): heap-attribution summary record for scan-floor analysis"
+```
+
+---
+
+## Task 2: `ScannerManager.heapAttribution()` + end-of-scan log
+
+**Files:**
+- Modify: `sonar-cryptography-plugin/src/main/java/com/ibm/plugin/ScannerManager.java`
+- Modify: `sonar-cryptography-plugin/src/main/java/com/ibm/plugin/OutputFileJob.java:39-55`
+- Test: `sonar-cryptography-plugin/src/test/java/com/ibm/plugin/ScannerManagerTest.java`
+
+**Interfaces:**
+- Consumes: `HeapAttributionSummary(int, int, int, int)` from Task 1; `JavaAggregator.getDetectedNodes()`, `JavaAggregator.getLanguageSupport().callContextStats()` (returns `com.ibm.engine.callstack.CallContextStats` with `detached()`, `total()`, `buckets()`), `JavaAggregator.addNodes(List)`, `JavaAggregator.reset()`.
+- Produces: `ScannerManager.heapAttribution()` returning `HeapAttributionSummary`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `sonar-cryptography-plugin/src/test/java/com/ibm/plugin/ScannerManagerTest.java` (license header from `OutputFileJob.java`, year 2026):
+
+```java
+package com.ibm.plugin;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.ibm.mapper.model.INode;
+import com.ibm.mapper.model.algorithms.AES;
+import com.ibm.mapper.utils.DetectionLocation;
+import java.util.List;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class ScannerManagerTest {
+
+ @BeforeEach
+ @AfterEach
+ void clearAggregators() {
+ JavaAggregator.reset();
+ }
+
+ @Test
+ void heapAttributionReportsZeroPopulationsOnAFreshScanner() {
+ HeapAttributionSummary summary = new ScannerManager(null).heapAttribution();
+ assertThat(summary.detectedNodes()).isZero();
+ assertThat(summary.totalCalls()).isZero();
+ assertThat(summary.callStackBuckets()).isZero();
+ }
+
+ @Test
+ void heapAttributionCountsRetainedDetectedNodes() {
+ DetectionLocation location =
+ new DetectionLocation("Test.java", 1, 0, List.of("aes"), () -> "test");
+ JavaAggregator.addNodes(List.of(new AES(128, location)));
+
+ HeapAttributionSummary summary = new ScannerManager(null).heapAttribution();
+ assertThat(summary.detectedNodes()).isEqualTo(1);
+ }
+}
+```
+
+Note: `new AES(int keyLength, DetectionLocation)` and the `DetectionLocation(String, int, int, List, IBundle)` record are verified real; `IBundle` is a functional interface, so `() -> "test"` supplies it (matching the `mapper` module's own test fixtures). The assertion (`detectedNodes() == 1`) is what matters, not which node type.
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `mvn test -pl sonar-cryptography-plugin -Dtest=ScannerManagerTest`
+Expected: FAIL — `ScannerManager.heapAttribution()` does not exist (compilation error).
+
+- [ ] **Step 3: Add `heapAttribution()` to `ScannerManager`**
+
+In `ScannerManager.java`, add these imports near the existing ones:
+
+```java
+import com.ibm.engine.callstack.CallContextStats;
+```
+
+Add this method after `getAggregatedNodes()` (around line 70):
+
+```java
+ /**
+ * Snapshot of the heap populations whose relative size attributes the post-detach scan-floor
+ * growth: retained CBOM nodes vs. detached call-stack records (see the H1 attribution in {@code
+ * docs/PERFORMANCE_TESTING.md}). Java is the measured heap driver; Python/Go call stacks are
+ * not included.
+ */
+ @Nonnull
+ public HeapAttributionSummary heapAttribution() {
+ int detectedNodes = JavaAggregator.getDetectedNodes().size();
+ CallContextStats stats = JavaAggregator.getLanguageSupport().callContextStats();
+ return new HeapAttributionSummary(
+ detectedNodes, stats.detached(), stats.total(), stats.buckets());
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `mvn test -pl sonar-cryptography-plugin -Dtest=ScannerManagerTest`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Wire the log into `OutputFileJob`**
+
+In `OutputFileJob.java`, replace the body of `execute` (lines 39–55) so the attribution logs once, before `reset()`, regardless of whether results exist:
+
+```java
+ @Override
+ public void execute(PostJobContext postJobContext) {
+ final String cbomFilename =
+ postJobContext
+ .config()
+ .get(Constants.CBOM_OUTPUT_NAME)
+ .orElse(Constants.CBOM_OUTPUT_NAME_DEFAULT);
+ ScannerManager scannerManager = new ScannerManager(new CBOMOutputFileFactory());
+ if (scannerManager.hasResults()) {
+ final File cbom = new File(cbomFilename + ".json");
+ scannerManager.getOutputFile().saveTo(cbom);
+ LOGGER.info("CBOM was successfully generated '{}'.", cbom.getAbsolutePath());
+ scannerManager.getStatistics().print(LOGGER::info);
+ } else {
+ LOGGER.info("No cryptography assets were detected. CBOM will not be generated.");
+ }
+ LOGGER.debug(scannerManager.heapAttribution().format());
+ scannerManager.reset();
+ }
+```
+
+- [ ] **Step 6: Run the plugin module tests**
+
+Run: `mvn test -pl sonar-cryptography-plugin`
+Expected: PASS (existing `OutputFileJobTest`, `PluginTest`, `JavaFileCheckRegistrarTest` plus the two new tests).
+
+- [ ] **Step 7: Format and commit**
+
+```bash
+mvn spotless:apply -pl sonar-cryptography-plugin
+git add sonar-cryptography-plugin/src/main/java/com/ibm/plugin/ScannerManager.java \
+ sonar-cryptography-plugin/src/main/java/com/ibm/plugin/OutputFileJob.java \
+ sonar-cryptography-plugin/src/test/java/com/ibm/plugin/ScannerManagerTest.java
+git commit -m "feat(plugin): log heap-attribution populations at end of scan"
+```
+
+---
+
+## Task 3: Add CBOM-node count to the perf harness report line
+
+**Files:**
+- Modify: `java/src/test/java/com/ibm/plugin/perf/CallStackHeapPerfTest.java:103-118`
+
+**Interfaces:**
+- Consumes: `JavaAggregator.getDetectedNodes()` (already imported in this test), the existing `CallContextStats stats` local.
+- Produces: nothing (test-only reporting).
+
+- [ ] **Step 1: Add the detectedNodes count to the printed report**
+
+In `CallStackHeapPerfTest.detachesRecordedCallsAtScale`, the report block currently prints `retainedWithTree/detached/total/buckets/ratio`. Add the retained CBOM-node count so the harness shows both heap populations side by side. Replace the `System.out.printf(...)` call (lines 106–117) with:
+
+```java
+ System.out.printf(
+ "%n[callstack-perf] files=%d units=%d time=%dms heapDeltaMB=%d "
+ + "detectedNodes=%d "
+ + "retainedWithTree=%d detached=%d total=%d buckets=%d ratio=%.3f%n",
+ sources.size(),
+ units,
+ elapsedMs,
+ (heapAfter - heapBefore) / (1024L * 1024L),
+ JavaAggregator.getDetectedNodes().size(),
+ stats.retainedWithTree(),
+ stats.detached(),
+ stats.total(),
+ stats.buckets(),
+ stats.detachedRatio());
+```
+
+(No new import — `com.ibm.plugin.JavaAggregator` is already imported at line 27.)
+
+- [ ] **Step 2: Run the perf harness and eyeball the new field**
+
+Run: `mvn test -pl java -DexcludedGroups= -Dtest=CallStackHeapPerfTest`
+Expected: PASS; the printed line now includes `detectedNodes=`. Note it reads `detectedNodes=0`
+in this harness — the `CheckVerifier`/`TestBase` path constructs rules with `isInventory=false`
+(`JavaBaseDetectionRule.java:52`), so `JavaAggregator.addNodes` never fires here; the count is
+non-zero only on a real inventory scan (Keycloak). The existing assertions (`total()` positive,
+`detachedRatio >= 0.9`, `retainedWithTree <= 10`) still hold.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add java/src/test/java/com/ibm/plugin/perf/CallStackHeapPerfTest.java
+git commit -m "test(java): report detectedNodes count in call-stack perf harness"
+```
+
+---
+
+## Task 4: Byte-attribution runbook + decision in `PERFORMANCE_TESTING.md`
+
+**Files:**
+- Modify: `docs/PERFORMANCE_TESTING.md`
+
+**Interfaces:** none (documentation).
+
+- [ ] **Step 1: Append the attribution section**
+
+Add a new top-level section to `docs/PERFORMANCE_TESTING.md` (after the existing Keycloak end-to-end section). Paste it verbatim:
+
+````markdown
+---
+
+## C. Post-detach floor attribution (H1)
+
+AST-detach removed the dominant AST-pinning heap term, but the post-GC floor still grows over a
+scan (observed ~1.6 → ~3.4 GB). This procedure attributes that residual to one of three
+populations so we know whether the call-stack still needs trimming (H2) or whether retained CBOM
+nodes dominate (a separate follow-up).
+
+### Two signals
+
+**1. In-process population counts (cheap).** At end of scan the plugin logs, at DEBUG:
+
+```
+[heap-attribution] detectedNodes= detachedCalls= totalCalls= callStackBuckets=
+```
+
+Enable DEBUG for the plugin (e.g. `-Dsonar.log.level=DEBUG` on the scanner, or the analysis
+`sonar.verbose=true`) and read the line from the scanner log. The fast local proxy is
+`CallStackHeapPerfTest`, whose report line now also prints `detectedNodes=`.
+
+Counts size the *populations*, not their bytes — a small count of heavy objects can still
+dominate. Use them to spot which population grows, then confirm bytes with the histogram below.
+
+**2. Byte attribution via `jmap` (decisive).** During a constrained-heap Keycloak scan (see
+section B), capture a live histogram near the end of analysis:
+
+```bash
+# find the scanner JVM pid (the surefire/scanner java process running the analysis)
+jps -l
+# live histogram (forces a GC first), sorted by retained bytes
+jmap -histo:live > histo.txt
+```
+
+Bucket the top entries of `histo.txt` into the three sources:
+
+| Bucket | Classes to sum in `histo.txt` |
+|---|---|
+| Retained CBOM nodes | `com.ibm.mapper.model.**` (e.g. `Algorithm`, `Key`, `Property`, `MessageDigest`, …) and their child `HashMap`/`HashMap$Node` share |
+| Detached call-stack | `com.ibm.engine.callstack.DetachedCall`, `...callstack.ArgSnapshot`, `...callstack.ResolvedSnapshotValue` |
+| Residual Tree / hooks | `org.sonar.**Tree*` still live + `com.ibm.engine.hooks.**` |
+
+Sample two or three histograms as the scan progresses to see which bucket *grows* (the floor is
+about accumulation, not a one-time cost).
+
+### Decision (fill in after measuring)
+
+| Population | count (log) | approx. retained bytes (jmap) |
+|---|---|---|
+| Retained CBOM nodes | | |
+| Detached call-stack | | |
+| Residual Tree / hooks | | |
+
+Record the outcome here, then route H2 accordingly:
+
+- **Call-stack dominates** → proceed with H2: eligibility filter **and** retention cap.
+- **CBOM nodes dominate** → build only H2's eligibility filter (still a lossless trim + CPU
+ assist); open a separate spec for `detectedNodes` dedup / incremental emission.
+- **Hooks dominate** → open a hooks-subscription-pruning spec.
+
+> Note on H2's eligibility filter: the predicate cannot be derived from `methodSymbol().declaration()`
+> — cross-file *user* calls resolve via `sonar.java.binaries` and have a null declaration, exactly
+> like library calls (see the comment in `JavaLanguageSupport.isDetachableCall`). The discriminator
+> must be pinned empirically against the `crossfile/` fixtures before the filter is written.
+````
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/PERFORMANCE_TESTING.md
+git commit -m "docs: post-detach floor attribution runbook (H1) + H2 decision rule"
+```
+
+---
+
+## Task 5: Full-suite regression + measurement run
+
+**Files:** none (verification).
+
+- [ ] **Step 1: Run the two touched module suites**
+
+Run: `mvn test -pl sonar-cryptography-plugin && mvn test -pl java`
+Expected: both green (the `java` default run excludes `@Tag("performance")`, so `CallStackHeapPerfTest` does not run here).
+
+- [ ] **Step 2: Capture the in-process signal**
+
+Run: `mvn test -pl java -DexcludedGroups= -Dtest=CallStackHeapPerfTest`
+Expected: report line prints `detectedNodes=` and `detached=`; note both.
+
+- [ ] **Step 3: Run the decisive measurement**
+
+Follow `docs/PERFORMANCE_TESTING.md` section C on a Keycloak scan: read the `[heap-attribution]`
+DEBUG line and capture at least two `jmap -histo:live` snapshots. Fill in the decision table and
+write the one-paragraph outcome + H2 routing in the doc.
+
+- [ ] **Step 4: Commit the recorded decision**
+
+```bash
+git add docs/PERFORMANCE_TESTING.md
+git commit -m "docs: record H1 floor-attribution results and H2 routing decision"
+```
+
+---
+
+## Self-Review notes
+
+- **Spec coverage:** H1's two signals (in-process counts + jmap byte attribution) → Tasks 1–4; the written decision + routing → Task 4 template and Task 5. H2 filter/cap deliberately excluded per the H1-only scope decision; the doc note preserves the corrected-predicate finding so H2 isn't rebuilt on the wrong premise.
+- **No placeholders:** the only unfilled content is the runtime measurement table (Task 4/5), which is inherent to a measurement deliverable — the *procedure*, commands, and jmap class buckets are fully concrete.
+- **Type consistency:** `HeapAttributionSummary(int detectedNodes, int detachedCalls, int totalCalls, int callStackBuckets)` is used identically in Tasks 1–2; `CallContextStats.detached()/total()/buckets()` match the record defined in `engine/.../callstack/CallContextStats.java`.
diff --git a/docs/superpowers/plans/2026-07-06-callstack-heap-perf-harness.md b/docs/superpowers/plans/2026-07-06-callstack-heap-perf-harness.md
new file mode 100644
index 000000000..840b01fb6
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-06-callstack-heap-perf-harness.md
@@ -0,0 +1,711 @@
+# Call-Stack Heap/Perf Harness Implementation Plan
+
+> **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.
+
+**Goal:** Add a self-contained, manual-on-demand JUnit test that generates a large synthetic cross-file crypto corpus, scans it in-process, and deterministically asserts that recorded calls are detached (per-file ASTs released) at `leaveFile` — the signature of the `feat/callstack-ast-detach` fix — while reporting heap/time for manual inspection.
+
+**Architecture:** Add a small read-only introspection accessor (`CallContextStats`) threaded down the existing `CallStackAgent → Handler → ILanguageSupport → JavaLanguageSupport` chain (the same chain that already carries `detachCallsForFile`/`notifyLeaveFile`). A test-only `CryptoCorpusGenerator` writes N wrapper/caller `.java` unit pairs to a temp dir; a `@Tag("performance")` test compiles them to a classpath dir and scans them with sonar-java `CheckVerifier` (the proven `CrossFileHookDetachTest` driving path — compiling + classpath is what makes callee types resolve so cross-file detections fire and calls get recorded). After the scan it reads `CallContextStats` and asserts a structural invariant (tree-pinning calls stay bounded; detached ratio high). The tag is excluded from the default build via surefire.
+
+**Tech Stack:** Java 17, Maven multi-module, JUnit 5 + AssertJ + Mockito 5.17, sonar-java `CheckVerifier` (java-checks-testkit 8.15), Google Java Format (Spotless, AOSP), Checkstyle.
+
+## Global Constraints
+
+- **Spec:** `docs/superpowers/specs/2026-07-06-callstack-heap-perf-harness-design.md`.
+- **Before every commit:** `mvn spotless:apply` then `mvn checkstyle:check` must succeed. Apache 2.0 license header required in every new `.java` file (Spotless applies it from the configured header; run `spotless:apply` and commit the result). `@Override` required where overriding; no unused imports; camelCase params.
+- **Watch out — `spotless:apply` intermittently truncates `mapper/.../JsonCipherSuites.java`.** If `git status` after formatting shows that file changed and you did not touch it, restore it (`git checkout -- mapper/src/main/resources/**/JsonCipherSuites.json` / the generated file) before committing. Do not commit a truncated `JsonCipherSuites`.
+- **Never weaken or delete an assertion to make a build pass.** The perf test's thresholds are tuned once against observed numbers (Task 4) and then fixed.
+- **The perf test must never gate CI** — it is `@Tag("performance")` and excluded from the default build. Only `mvn test` staying green (perf skipped) is required for CI; the perf test is run manually.
+- **Determinism:** the perf test asserts only on `CallContextStats` counts (object-variant counts in the call stack), never on measured heap MB or wall-time. Heap/time are printed, never asserted.
+- **DetectionLocation for engine tests (if needed):** `new DetectionLocation("testfile", 1, 1, List.of("test"), () -> "TEST")`.
+
+---
+
+### Task 1: `CallContextStats` record + counting logic
+
+Add the immutable stats record and its pure counting factory in the `engine` module. This is the data the perf test asserts on. The factory is the only non-trivial logic, so it is what the unit test targets.
+
+**Files:**
+- Create: `engine/src/main/java/com/ibm/engine/callstack/CallContextStats.java`
+- Test: `engine/src/test/java/com/ibm/engine/callstack/CallContextStatsTest.java`
+
+**Interfaces:**
+- Consumes: existing `CallContext` (sealed), `RetainedCall` (`@Nonnull T tree()`), `DetachedCall` (`tree()` returns `null`), all in `com.ibm.engine.callstack`.
+- Produces:
+ - `public record CallContextStats(int retainedWithTree, int detached, int total, int buckets)`
+ - `public static final CallContextStats CallContextStats.EMPTY`
+ - `public double CallContextStats.detachedRatio()` — `detached/total`, or `1.0` when `total == 0`.
+ - `public static CallContextStats CallContextStats.from(Collection>> buckets)`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `engine/src/test/java/com/ibm/engine/callstack/CallContextStatsTest.java` (license header added by `spotless:apply` in Step 4):
+
+```java
+package com.ibm.engine.callstack;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+import static org.mockito.Mockito.mock;
+
+import com.ibm.engine.language.IScanContext;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+
+class CallContextStatsTest {
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void countsRetainedAndDetachedAcrossBuckets() {
+ IScanContext