From 6c8739ee9c5ee119ead4e3cfda0cead353183a5e Mon Sep 17 00:00:00 2001 From: Romuald Lemesle Date: Fri, 28 Aug 2026 09:45:58 +0200 Subject: [PATCH 1/3] fix(multi-tenancy): scope inject listings so inject_type resolves (#7605) The inject read and single-inject mutation endpoints were @Transactional without a TxCtx parameter, so app.current_tenants was never set and can_access_tenant fails closed on the v2-scoped injectors table. Inject#getType() and the inject_type criteria selection both resolve the injector through that table, so they returned null and the frontend drew the generic "unknown" icon on the execution screens. --- .../rest/inject/ScenarioInjectApi.java | 14 +++++++++++-- .../rest/inject/SimulationInjectApi.java | 21 +++++++++++++++---- .../TenantScopedEntrypointsTxCtxArchTest.java | 16 ++++++++++++++ 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java index 26e6c67b67e..e2fde6ac322 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java @@ -61,7 +61,11 @@ public class ScenarioInjectApi extends RestBehavior { resourceType = ResourceType.SCENARIO) @Transactional(readOnly = true) public Iterable scenarioInjectsSimple( - @PathVariable @NotBlank final String scenarioId) { + // The TxCtx parameter is not used directly; it signals the transaction aspect to set the + // tenant scope in the DB session. Every inject read resolves its injector on the v2-scoped + // injectors table: without the scope the join fails closed and inject_type comes back null, + // which the frontend renders as the generic "unknown" icon (#7605, #7621). + TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { return injectSearchService.injects(fromScenario(scenarioId)); } @@ -75,6 +79,7 @@ public Iterable scenarioInjectsSimple( resourceType = ResourceType.SCENARIO) @Transactional(readOnly = true) public Iterable scenarioInjectsSimple( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @RequestBody @Valid final SearchPaginationInput searchPaginationInput) { Map> joinMap = new HashMap<>(); @@ -101,7 +106,8 @@ public Iterable scenarioInjectsSimple( resourceId = "#scenarioId", actionPerformed = Action.READ, resourceType = ResourceType.SCENARIO) - public Iterable scenarioInjects(@PathVariable @NotBlank final String scenarioId) { + public Iterable scenarioInjects( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { return this.injectRepository.findByScenarioId(scenarioId).stream() .sorted(Inject.executionComparator) .toList(); @@ -117,6 +123,7 @@ public Iterable scenarioInjects(@PathVariable @NotBlank final String sce actionPerformed = Action.READ, resourceType = ResourceType.SCENARIO) public Inject scenarioInject( + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String injectId) { Scenario scenario = this.scenarioService.scenario(scenarioId); @@ -242,6 +249,9 @@ public InjectOutput updateInjectForScenario( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectActivationForScenario( + // Same as the reads above: the returned Inject serializes inject_type, so the response + // needs the tenant scope too or it blanks the field in the frontend store. + TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String injectId, @Valid @RequestBody InjectUpdateActivationInput input) { diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java index 7cd2272d182..92467ac643c 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java @@ -98,7 +98,11 @@ public class SimulationInjectApi extends RestBehavior { resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) public Iterable exerciseInjectsSimple( - @PathVariable @NotBlank final String exerciseId) { + // The TxCtx parameter is not used directly; it signals the transaction aspect to set the + // tenant scope in the DB session. Every inject read resolves its injector on the v2-scoped + // injectors table: without the scope the join fails closed and inject_type comes back null, + // which the frontend renders as the generic "unknown" icon (#7605, #7621). + TxCtx ctx, @PathVariable @NotBlank final String exerciseId) { return injectSearchService.injects(fromSimulation(exerciseId)); } @@ -112,6 +116,7 @@ public Iterable exerciseInjectsSimple( resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) public Iterable exerciseInjectsSimple( + TxCtx ctx, @PathVariable @NotBlank final String exerciseId, @RequestBody @Valid final SearchPaginationInput searchPaginationInput) { Map> joinMap = new HashMap<>(); @@ -139,7 +144,8 @@ public Iterable exerciseInjectsSimple( resourceId = "#exerciseId", actionPerformed = Action.READ, resourceType = ResourceType.SIMULATION) - public Iterable exerciseInjects(@PathVariable @NotBlank final String exerciseId) { + public Iterable exerciseInjects( + TxCtx ctx, @PathVariable @NotBlank final String exerciseId) { return injectRepository.findByExerciseId(exerciseId).stream() .sorted(Inject.executionComparator) .toList(); @@ -156,6 +162,7 @@ public Iterable exerciseInjects(@PathVariable @NotBlank final String exe resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) public Page searchExerciseInjects( + TxCtx ctx, @PathVariable final String exerciseId, @RequestBody @Valid SearchPaginationInput searchPaginationInput) { return injectSearchService.getPageOfInjectResults(exerciseId, searchPaginationInput); @@ -171,7 +178,8 @@ public Page searchExerciseInjects( actionPerformed = Action.READ, resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) - public List exerciseInjectsResults(@PathVariable final String exerciseId) { + public List exerciseInjectsResults( + TxCtx ctx, @PathVariable final String exerciseId) { return injectSearchService.getListOfInjectResults(exerciseId); } @@ -354,6 +362,9 @@ public InjectStatus executeInject( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectActivationForExercise( + // Same as the reads above: the returned Inject serializes inject_type, so the response + // needs the tenant scope too or it blanks the field in the frontend store. + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectUpdateActivationInput input) { @@ -370,7 +381,7 @@ public Inject updateInjectActivationForExercise( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectTrigger( - @PathVariable String exerciseId, @PathVariable String injectId) { + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId) { return simulationInjectService.triggerInjectForSimulation(exerciseId, injectId); } @@ -384,6 +395,7 @@ public Inject updateInjectTrigger( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject setInjectStatus( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectUpdateStatusInput input) { @@ -400,6 +412,7 @@ public Inject setInjectStatus( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectTeams( + TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId, @Valid @RequestBody InjectTeamsInput input) { diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java index 18de39a5901..21c49aa99d5 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java @@ -295,6 +295,22 @@ class TenantScopedEntrypointsTxCtxArchTest { "io.openaev.rest.atomic_testing.AtomicTestingApi#collectorsFromAtomicTesting", // inject: updateInject calls injectService.runChecks -> securityPlatformCollectors "io.openaev.rest.inject.InjectApi#updateInject", + // inject listings and single-inject mutations: every one of them serializes inject_type, + // resolved through the contract's injector link on the v2-scoped injectors table. Losing + // the scope does not fail loudly - inject_type just comes back null and the frontend + // draws the generic "unknown" icon on the execution screens (#7605, #7621). + "io.openaev.rest.inject.SimulationInjectApi#exerciseInjects", + "io.openaev.rest.inject.SimulationInjectApi#exerciseInjectsSimple", + "io.openaev.rest.inject.SimulationInjectApi#searchExerciseInjects", + "io.openaev.rest.inject.SimulationInjectApi#exerciseInjectsResults", + "io.openaev.rest.inject.SimulationInjectApi#updateInjectActivationForExercise", + "io.openaev.rest.inject.SimulationInjectApi#updateInjectTrigger", + "io.openaev.rest.inject.SimulationInjectApi#setInjectStatus", + "io.openaev.rest.inject.SimulationInjectApi#updateInjectTeams", + "io.openaev.rest.inject.ScenarioInjectApi#scenarioInjects", + "io.openaev.rest.inject.ScenarioInjectApi#scenarioInjectsSimple", + "io.openaev.rest.inject.ScenarioInjectApi#scenarioInject", + "io.openaev.rest.inject.ScenarioInjectApi#updateInjectActivationForScenario", // simulation injects: runChecks path "io.openaev.rest.inject.SimulationInjectApi#exerciseInject", "io.openaev.rest.inject.SimulationInjectApi#createInjectForExercise", From dd1674fa3435c5ae60b87414c1ee478ab72359c9 Mon Sep 17 00:00:00 2001 From: Romuald Lemesle Date: Fri, 28 Aug 2026 10:05:38 +0200 Subject: [PATCH 2/3] fix(multi-tenancy): drop the inline comments on the TxCtx parameters --- .../rest/inject/ScenarioInjectApi.java | 6 ------ .../rest/inject/SimulationInjectApi.java | 6 ------ .../TenantScopedEntrypointsTxCtxArchTest.java | 20 ++++++++----------- 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java index e2fde6ac322..fb4abb1a26a 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/ScenarioInjectApi.java @@ -61,10 +61,6 @@ public class ScenarioInjectApi extends RestBehavior { resourceType = ResourceType.SCENARIO) @Transactional(readOnly = true) public Iterable scenarioInjectsSimple( - // The TxCtx parameter is not used directly; it signals the transaction aspect to set the - // tenant scope in the DB session. Every inject read resolves its injector on the v2-scoped - // injectors table: without the scope the join fails closed and inject_type comes back null, - // which the frontend renders as the generic "unknown" icon (#7605, #7621). TxCtx ctx, @PathVariable @NotBlank final String scenarioId) { return injectSearchService.injects(fromScenario(scenarioId)); } @@ -249,8 +245,6 @@ public InjectOutput updateInjectForScenario( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectActivationForScenario( - // Same as the reads above: the returned Inject serializes inject_type, so the response - // needs the tenant scope too or it blanks the field in the frontend store. TxCtx ctx, @PathVariable @NotBlank final String scenarioId, @PathVariable @NotBlank final String injectId, diff --git a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java index 92467ac643c..fb8169579b6 100644 --- a/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/inject/SimulationInjectApi.java @@ -98,10 +98,6 @@ public class SimulationInjectApi extends RestBehavior { resourceType = ResourceType.SIMULATION) @Transactional(readOnly = true) public Iterable exerciseInjectsSimple( - // The TxCtx parameter is not used directly; it signals the transaction aspect to set the - // tenant scope in the DB session. Every inject read resolves its injector on the v2-scoped - // injectors table: without the scope the join fails closed and inject_type comes back null, - // which the frontend renders as the generic "unknown" icon (#7605, #7621). TxCtx ctx, @PathVariable @NotBlank final String exerciseId) { return injectSearchService.injects(fromSimulation(exerciseId)); } @@ -362,8 +358,6 @@ public InjectStatus executeInject( actionPerformed = Action.WRITE, resourceType = ResourceType.INJECT) public Inject updateInjectActivationForExercise( - // Same as the reads above: the returned Inject serializes inject_type, so the response - // needs the tenant scope too or it blanks the field in the frontend store. TxCtx ctx, @PathVariable String exerciseId, @PathVariable String injectId, diff --git a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java index 21c49aa99d5..026f6059205 100644 --- a/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java +++ b/openaev-api/src/test/java/io/openaev/architecture/TenantScopedEntrypointsTxCtxArchTest.java @@ -295,10 +295,14 @@ class TenantScopedEntrypointsTxCtxArchTest { "io.openaev.rest.atomic_testing.AtomicTestingApi#collectorsFromAtomicTesting", // inject: updateInject calls injectService.runChecks -> securityPlatformCollectors "io.openaev.rest.inject.InjectApi#updateInject", - // inject listings and single-inject mutations: every one of them serializes inject_type, - // resolved through the contract's injector link on the v2-scoped injectors table. Losing - // the scope does not fail loudly - inject_type just comes back null and the frontend - // draws the generic "unknown" icon on the execution screens (#7605, #7621). + // simulation injects: runChecks path + "io.openaev.rest.inject.SimulationInjectApi#exerciseInject", + "io.openaev.rest.inject.SimulationInjectApi#createInjectForExercise", + "io.openaev.rest.inject.SimulationInjectApi#duplicateInjectForExercise", + // scenario injects: runChecks path + "io.openaev.rest.inject.ScenarioInjectApi#createInjectForScenario", + "io.openaev.rest.inject.ScenarioInjectApi#duplicateInjectForScenario", + "io.openaev.rest.inject.ScenarioInjectApi#updateInjectForScenario", "io.openaev.rest.inject.SimulationInjectApi#exerciseInjects", "io.openaev.rest.inject.SimulationInjectApi#exerciseInjectsSimple", "io.openaev.rest.inject.SimulationInjectApi#searchExerciseInjects", @@ -311,14 +315,6 @@ class TenantScopedEntrypointsTxCtxArchTest { "io.openaev.rest.inject.ScenarioInjectApi#scenarioInjectsSimple", "io.openaev.rest.inject.ScenarioInjectApi#scenarioInject", "io.openaev.rest.inject.ScenarioInjectApi#updateInjectActivationForScenario", - // simulation injects: runChecks path - "io.openaev.rest.inject.SimulationInjectApi#exerciseInject", - "io.openaev.rest.inject.SimulationInjectApi#createInjectForExercise", - "io.openaev.rest.inject.SimulationInjectApi#duplicateInjectForExercise", - // scenario injects: runChecks path - "io.openaev.rest.inject.ScenarioInjectApi#createInjectForScenario", - "io.openaev.rest.inject.ScenarioInjectApi#duplicateInjectForScenario", - "io.openaev.rest.inject.ScenarioInjectApi#updateInjectForScenario", // health-check streams: runChecks -> securityPlatformCollectors "io.openaev.rest.scenario.ScenarioApi#streamHealthChecks", "io.openaev.rest.exercise.ExerciseApi#streamHealthChecks", From eba696a5201961959b72d23ac05f7691a6521fb8 Mon Sep 17 00:00:00 2001 From: Corinne Krych Date: Fri, 28 Aug 2026 14:00:38 +0200 Subject: [PATCH 3/3] chore(skills): update AI skills for API v2 migration --- .github/skills/activate-tenant-table/SKILL.md | 82 +++++++++++++++++++ .github/skills/review-multi-tenancy/SKILL.md | 11 +++ 2 files changed, 93 insertions(+) diff --git a/.github/skills/activate-tenant-table/SKILL.md b/.github/skills/activate-tenant-table/SKILL.md index 2ac3d6a930e..e4928c08c91 100644 --- a/.github/skills/activate-tenant-table/SKILL.md +++ b/.github/skills/activate-tenant-table/SKILL.md @@ -675,6 +675,72 @@ Do not defer this phase to "later regression pass" — an association missed here degrades silently (200 OK, empty array) exactly like #7026, so nothing in Phase 8's regression run will catch it unless the new test from 3b.3 exists. +**3b.4 — computed getters and DTO mappers that resolve the activated table +(the #7605 / #7621 shape).** An association accessor is not the only silent +reader. A COMPUTED getter on an unrelated entity — a `@JsonProperty` method +with no column of its own that walks a relation to `{Entity}` to derive a +scalar — reads the activated table on every serialization, and it is invisible +to all three greps above: it names neither `{EntityRepository}` nor `{table}`, +and it is not an `@OneToMany`/`@ManyToOne` field. `Inject#getType()` is the +reference: `@JsonProperty("inject_type")` resolving +`injectorContract.getFirstInjector().getType()` on the v2-scoped `injectors` +table. Its callers are three DTO mappers (`InjectMapper#toInjectOutput`, +`InjectMapper#toInjectResultOverviewOutput`, +`InjectStatusMapper#toInjectTestStatusOutput`) plus direct entity +serialization, so EVERY endpoint returning `Inject`, `InjectOutput`, +`InjectResultOverviewOutput`, `InjectResultOutput` or `InjectTestStatusOutput` +reads `injectors`. The activation wired the obvious inject endpoints and +missed the rest; they shipped 200 OK with `inject_type: null`, which the +frontend renders as the generic "unknown" icon on the whole Execution screen +(time-based AND chaining) — a silent regression found in production, not in +CI. + +Walk it in two directions, and treat BOTH as part of the closure: + +```bash +# 3b.4.a - computed @JsonProperty getters anywhere in the model that resolve +# {Entity} without naming {table} or {EntityRepository} +grep -rn -B3 "get{Entity}()\|get{Entities}()\|getFirst{Entity}()" \ + openaev-model/src/main/java/io/openaev/database/model --include="*.java" | grep -n "@JsonProperty" -B3 + +# 3b.4.b - every caller of each computed getter found (mappers included) +grep -rn "\.{computedGetter}()" openaev-api/src/main/java openaev-model/src/main/java --include="*.java" + +# 3b.4.c - THE SINK SWEEP: once a DTO/entity is known to carry the computed +# value, enumerate EVERY endpoint whose return type is that DTO/entity, and +# check TxCtx on each one. This is the step that was skipped in #7605. +grep -rn "public .*\b{SinkType}\b\|Page<{SinkType}>\|List<{SinkType}>\|Iterable<{SinkType}>" \ + openaev-api/src/main/java --include="*Api.java" +``` + +Rules for this sub-phase: + +- The unit of enumeration is the RESPONSE TYPE, not the API package. A + computed getter leaks through `TeamApi`, `PlayerApi`, `OrganizationApi`, + `AssetGroupApi`, `EndpointApi`, ... simply because they return the same DTO; + none of them mentions the activated table anywhere. Sweep by sink type + across all controllers, then diff that list against `TX_SCOPED_ENTRYPOINTS`: + every endpoint returning a sink type must appear in one of the two lists + (wired, or explicitly justified as never serializing the computed value). +- A criteria/JPA projection that SELECTs the derived column + (`injectorJoin.get("type").alias("inject_type")`) is the same sink: it joins + the activated table inside the query, so its endpoints need `TxCtx` exactly + like the lazy-getter path. +- `@Transactional(propagation = Propagation.SUPPORTS)` handlers (bulk + update/delete, massive-operation wrappers) are a trap: with no inbound + transaction the aspect has nothing to scope, so adding `TxCtx` alone does + NOT fix them. Either the service opens the scoped transaction, or the + handler is switched to a real `@Transactional` boundary — decide and write + it down, do not leave a `TxCtx` parameter that silently does nothing. +- Deprecated endpoints returning the sink type count (they still ship): the + `/api/exercise/{id}/injects/test` variant is as live as its + `/injects/test/search` successor. +- Pin the sweep: for each sink type, add one production-like test + (`@TestPropertySource(properties = "openaev.tenant.active-tables={table}")`) + asserting the computed field is NON-NULL on a representative endpoint per + controller family, not just on the table's own API. A null-valued scalar is + the failure mode; an empty-array assertion will not catch it. + ### Phase 4 — RED then GREEN: write attribution The inspector cannot attribute `INSERT ... VALUES`. Attribution is application @@ -1209,6 +1275,22 @@ Before marking the issue done, write down: - [ ] association-accessor scan run for every entity holding a reference to the activated entity, regardless of whether the activated table has its own API (eager/lazy loads bypass the repository grep either way) +- [ ] computed-getter scan run (Phase 3b.4): every `@JsonProperty` getter that + derives a scalar from the activated table (model: `Inject#getType()` → + `injectors`) found, its DTO mappers and JPA projections listed, and the + SINK SWEEP done — every endpoint returning one of those sink types + (entity or DTO), in ANY controller, diffed against + `TX_SCOPED_ENTRYPOINTS` so none is left unwired (#7605/#7621: the inject + endpoints were wired, the `InjectResultOutput` / + `InjectTestStatusOutput` / `InjectResultOverviewOutput` endpoints on + team, player, organization, asset-group and atomic-testing were not, and + shipped `inject_type: null`) +- [ ] every `@Transactional(propagation = SUPPORTS)` handler returning a sink + type is explicitly resolved: either the service opens the scoped + transaction or the handler gets a real transaction boundary — no + `TxCtx` parameter left on a SUPPORTS handler where the aspect cannot fire +- [ ] one production-like test per sink type asserts the computed field is + NON-NULL (a null scalar, not an empty array, is this regression's shape) - [ ] isolation test written first and seen red for the mechanism, then green; raw red/green outputs captured in the report - [ ] reads: own row visible, cross-tenant 404, path and header selectors diff --git a/.github/skills/review-multi-tenancy/SKILL.md b/.github/skills/review-multi-tenancy/SKILL.md index 1a15cb99d0a..227a8c84a24 100644 --- a/.github/skills/review-multi-tenancy/SKILL.md +++ b/.github/skills/review-multi-tenancy/SKILL.md @@ -72,6 +72,17 @@ Classify each touched table as one of: - a native `@Query` that `JOIN`s a v2-active table anywhere in the codebase (not just its own repository) is pulled into the fail-closed rewrite — check its FROM/JOIN shape against `TenantStatementInspectorTest` (#7007) + - a new endpoint (in ANY controller) whose response type serializes a + computed value derived from a v2-active table needs `TxCtx` too, even + though nothing in the file names the table: `Inject#getType()` + (`@JsonProperty("inject_type")`) resolves `injectors`, so every endpoint + returning `Inject`, `InjectOutput`, `InjectResultOutput`, + `InjectResultOverviewOutput` or `InjectTestStatusOutput` reads it. Missing + `TxCtx` here is 🔴 CRITICAL and completely silent: 200 OK with the scalar + `null` (#7605/#7621 — missing injector icons across the Execution screen). + Sweep by RESPONSE TYPE, not by API package. + - `@Transactional(propagation = SUPPORTS)` + `TxCtx` is a false fix: with no + inbound transaction the aspect never fires. Flag it 🟠 HIGH. - **v1 (still `@Filter`-based)** — not in `active-tables`. Isolation is Hibernate `@Filter` + `TenantBaseListener`, ambient via `TenantContext.getCurrentTenant()`. For these tables, Steps 2-7 below