# Activate Tenant Table

> Activates one table on multi-tenancy v2 (statement inspector + can_access_tenant), test-first, following the import_mappers pilot (PR #6255). Use when asked to switch a table from v1 @Filter isolation to v2. Covers HTTP paths and, since the background transaction primitive (#6398), background writers (scheduler jobs, consumers) once they are converted to the primitive. Covers eligibility gates, code-path inventory, TDD isolation tests, write attribution, the background conversion, the one-commit go-live, and the full regression pass.

- Skill: `openaev-platform/activate-tenant-table` (Agent Skill)
- Install (CLI): `npx skillmds@latest add openaev-platform/activate-tenant-table`
- Raw SKILL.md: https://api.skillmd.com/api/skills/openaev-platform/activate-tenant-table/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: openaev-platform (https://skillmd.com/u/openaev-platform)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/openaev-platform/activate-tenant-table

---


# Activate a Table on Multi-Tenancy v2

Switch one table from v1 isolation (Hibernate `@Filter` on the entity) to v2
(SQL rewriting by `TenantStatementInspector` + the `can_access_tenant` function).

The reference implementation is the pilot, `import_mappers`, in PR #6255.
Every template in this skill points to a real pilot file. Open it and copy the
pattern; do not invent new mechanisms.

For the full mechanism (read path, write path, exact names), read the pilot
PR #6255 description once before starting, plus the javadoc of
`TenantStatementInspector`, `TenantWriteScopeResolver` and
`TenantScopeTransactionAspect`.

If the table has a background writer (a scheduler job, queue consumer or
startup task that writes it), read the background transaction primitive too:
`TenantScopedTransaction` (`openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java`).
The HTTP path carries its scope through `@Transactional` + `TxCtx` + the aspect;
the background path must NOT use `@Transactional` (its self-invocation trap
silently skips both the transaction and the scope) and opens transactions through
the primitive instead. Phase 5b (this runbook's background-writer conversion
phase, defined below between Phase 5 and the go-live) converts those writers;
it is the prerequisite for activating any table a background job writes.

## Inputs

- The table name (e.g. `mitigations`) and its API class (e.g. `MitigationApi`).
- The activation issue (e.g. #6400). Its Context block names the mapped
  table(s) and the current state.

## Hard rules

These are not style preferences. Each one prevents a security or availability
incident. Do not trade them away to make a test pass.

1. **TDD, strictly.** Write the isolation test first and run it. It must fail
   for the expected reason before you write any production code. Never weaken,
   delete or `@Disabled` an existing test to get green, with one exception
   introduced in Phase 2 and resolved in Phase 6 (the documented go-live guard).

   **Red for the intended reason is not enough.** A test that goes red when you
   remove your fix may only be pinning that fix, not the behaviour it is meant
   to protect. Before calling it done, break the behaviour a SECOND way, on a
   different line or in a different layer, and confirm it fails again.

   Lot C's gauge tests are the example. They were red without their fix and
   green with it, then stayed green when the gauge was re-wired to the unscoped
   repository: they called the scoped method directly instead of the supplier
   the gauge actually registers.
2. **Background writers go through the primitive, never `@Transactional`.** A
   scheduler job, queue consumer, connector-side path or startup task that
   writes the table is NOT an automatic stop anymore (it was before #6398).
   It becomes activable ONCE every such writer is converted to
   `TenantScopedTransaction` (Phase 5b), carrying a real per-tenant scope. Until
   that conversion is done and tested, an unconverted background writer is still
   a hard blocker: activating the table under it would make the writer read and
   write zero rows, silently.
   ONE documented waiver exists: a writer that only INSERTs fresh rows (VALUES
   inserts are not blocked by the inspector), never READS the table, and
   attributes `tenant_id` correctly (listener + `TenantContext`, or explicit) may
   ship unconverted IF a test pins that write shape under activation AND the
   conversion is a tracked follow-up. The tenant-provisioning datapack writing
   `cwes` was the original example; it has since been converted (it now writes
   under the primitive scope MigrationProcessor sets), so no active waiver relies
   on this today. Use it only for a genuinely INSERT-only, never-reads writer you
   cannot convert in the same PR.
   A background READER, or any read-then-write path, gets no waiver. Background code must never use `@Transactional`
   for the write (self-invocation trap) and must never open raw transactions;
   both are guarded by the ArchUnit rules in
   `openaev-api/src/test/java/io/openaev/architecture/TenantBackgroundTransactionRules.java`.
3. **Strict tables only.** If `tenant_id` is nullable (dual-scope: `roles`,
   `groups`, `parameters`, ...), STOP and report. Platform-row writes are an
   open policy question (Q7); this skill does not cover them.
4. **One-commit go-live.** Removing the v1 `@Filter` and adding the table to
   `openaev.tenant.active-tables` happen in the same commit, never split.
5. **No hardcoded Flyway numbers.** Refer to migrations by name. Never pin a
   version number in code comments, docs or tests.
6. **Fail-closed is the point.** If a query returns zero rows after wiring,
   the fix is to pass the scope correctly, never to bypass the inspector,
   never to add raw JDBC, never to widen the scope.
7. **Full regression before done.** The activation rewrites the SQL of every
   query touching the table. The full API suite must pass, not just your new
   tests.
8. **Evidence over claims.** Every red and every green leaves a trace: keep
   the raw test output (the failing assertion for the red, the passing
   summary for the green) and paste it into the Phase 8 report. A TDD step
   without its output did not happen.
9. **Do not improvise on drift.** If a model file referenced by this skill
   does not exist anymore, stop and find its successor
   (`git log --oneline --follow --all -- <path>`). Never substitute an
   invented pattern for a missing reference.
10. **`TxCtx` position is fixed.** In every new or modified method signature
    that carries `TxCtx`, it must be the FIRST parameter (annotations allowed,
    e.g. `@RequireTenantSelector TxCtx ctx`). Keep this order through service
    call chains too, so wiring remains grep-auditable and consistent.
11. **No v1 tenant context in v2/integration tests.** For API v2 activation
    tests and integration tests, do not add `TenantContext.getCurrentTenant()`,
    `TenantContext.setCurrentTenant(...)`, or `enableFilter("tenantFilter")`.
    Use explicit tenant ids + `TxCtx`/`TenantScopedTransaction` helpers.

## Baseline: controller entrypoints already carry `TxCtx`

Every `@Transactional` method under `io.openaev.api/**` and
`io.openaev.rest/**` declares a bare `TxCtx ctx` parameter, added in one pass
across the codebase, with five deliberate exclusions that carry
`@NoTenantScope` instead: `UserApi.login`, `UserApi.passwordReset`,
`UserApi.changePasswordReset` and `UserApi.validatePasswordResetToken`
(permitAll, pre-auth), and `StreamApi.streamFlux` (`propagation = NEVER`, so
there is no transaction to scope). That changes what a single-table
activation needs to do:

- **Phase 1 no longer hunts exhaustively for missing `TxCtx` on controller
  entrypoints**, though it still spot-checks the ones it needs (see below).
  That search (the biggest source of the regressions cited throughout this
  skill — #6409, #6410, #7026, #7605/#7621) is done, once, for the whole
  codebase. Phase 1 is now scoped to what the blanket wiring does NOT cover:
  background paths (Phase 5b), non-`@Transactional` handlers, native/raw SQL
  shapes, and OSIV/lazy-serialization sinks (Phase 3b) — a `TxCtx` parameter
  on a method signature does not by itself fix a lazy association or computed
  getter resolved by Jackson AFTER the transaction has already closed.
- **A `TxCtx` parameter is not inert.** It resolves a scope and sets it on the
  transaction whether or not the table it touches is active, and
  `TenantScopeTransactionAspect` throws when a nested `@Transactional` method
  tries to redefine a scope already set in the same transaction. Adding or
  removing one is a behaviour change, not a signature change: assume it can
  break a caller, and re-run the suite.
- **This is a point-in-time fact, not a self-enforcing invariant**, until the
  default-secure compile rule (`EndpointTxScopeRule`, #7726) is enabled for
  `openaev-api`; it currently ships disabled. A NEW controller endpoint added
  after this baseline, or one that was not yet `@Transactional` at the time,
  may still be missing it — spot-check the entrypoints this activation
  actually needs (Phase 1) rather than assuming.
- **Phase 5's `TX_SCOPED_ENTRYPOINTS` registration is now mostly
  documentation and drift-detection, not discovery.** Most entries you would
  have added by hand already exist; add a new one only for a genuinely new
  gap (a Phase 3b force-initialize fix, a handler that just became
  `@Transactional`, or a converted background path).

## Procedure

### Phase 0 — Eligibility gate (stop conditions)

Replace `{table}`, `{Entity}`, `{EntityRepository}`, `{Api}` below with your
target (e.g. `mitigations`, `Mitigation`, `MitigationRepository`,
`MitigationApi`).

```bash
# 0.1 The entity must be strict tenant-scoped: implements TenantBase, not DualScopeBase
grep -n "TenantBase\|DualScopeBase" openaev-model/src/main/java/io/openaev/database/model/{Entity}.java

# 0.2 The tenant column must be non-nullable. The entity mapping is the
# reliable static check; tenant_id was added to most tables by bulk
# migrations that loop over a table list, so grepping migrations for
# "{table}" + "tenant_id" on one line finds nothing.
grep -n -A2 'name = "tenant_id"' openaev-model/src/main/java/io/openaev/database/model/{Entity}.java
# expect: @JoinColumn(name = "tenant_id", ..., nullable = false)
# authoritative check when a DB is running:
#   SELECT is_nullable FROM information_schema.columns
#   WHERE table_name = '{table}' AND column_name = 'tenant_id';

# 0.3 Unique constraints must be tenant-aware. A global unique index on a
# business key (external id, name, key) means two tenants cannot hold the
# same value: activation would turn that into cross-tenant interference.
grep -rn -i "unique" openaev-api/src/main/java/io/openaev/migration/ | grep -i "{table}"
# the grep misses multi-line definitions; on a live DB, `\d {table}` in psql
# lists every index and constraint and is authoritative
```

0.4 Hot-path check. The inspector wraps every active table in a filtered
sub-query, which can change query plans. If the table sits in frequent joins
or heavy list endpoints, look at the rewritten SQL before go-live: captured
real SQL can be replayed through the inspector with
`openaev-api/src/test/java/io/openaev/config/TenantSqlReplayMeasurementTest.java`
(gated by `-Dtenant.sql.replay.file`), and the heaviest query deserves an
`EXPLAIN` with the rewrite applied. For a small config table this is a
one-line note in the report; for a hot table it is a real measurement.

There is no reliable one-line grep for "no background writer" (a keyword
filter on scheduler/job/consumer misses renamed packages and matches string
literals). The authoritative classification is the Phase 1 inventory: read
every hit.

STOP conditions, report instead of continuing:
- entity implements `DualScopeBase`, or the tenant column is nullable →
  dual-scope, out of scope (hard rule 3)
- Phase 1 finds any non-HTTP path that WRITES the table → NOT an automatic stop
  since #6398, but it moves the table into the background-writer track: every
  such writer must be converted to the primitive in Phase 5b before go-live. If
  the conversion is out of scope for this run (e.g. the writer is a large
  execution-surface job you are not converting now), STOP and report it as a
  blocker; the table cannot be activated while an unconverted writer touches it.
  A background READ-only hit (e.g. a telemetry counter) is not a blocker but
  must be listed in the report as a documented degradation: once the table is
  active it reads zero rows unless that reader also carries a scope.
  `ProductInventoryMetricCollector` (below) is the single, always-present
  instance of this shape — check it on every activation, not only when Phase 1
  happens to surface it.
- 0.3 finds a unique index on a business key that does not include
  `tenant_id` → the schema needs a prep migration first (model: the existing
  `__Update_unique_constraints_for_tenants` migration in
  `openaev-api/src/main/java/io/openaev/migration/`; same pattern, new migration).
  `CREATE UNIQUE INDEX mitigations_unique ON mitigations (mitigation_external_id)`,
  global, so two tenants cannot both hold MITRE mitigation M1013. Fix the
  constraint (add `tenant_id` to it) in its own reviewed change BEFORE the
  activation, and only if per-tenant duplication is the intended semantics;
  if the rows are meant to be platform-shared reference data, the table may
  not be a good activation candidate at all. Report and let a human decide.

When a stop condition is hit, still run Phase 1 (the inventory is what makes
the stop useful), then produce a stop report instead of code and post it on
the table's activation issue. Format:

```markdown
## activate-tenant-table skill run: STOPPED at eligibility (gate <n>)

**Gates**
- 0.1 PASS/STOP: <entity check result>
- 0.2 PASS/STOP: <tenant column nullability>
- 0.3 PASS/STOP: <unique constraints; quote the offending index if any>
- 0.4: <hot-path note>

**Blocker to decide before activation**
<the failed gate, the options this skill lists for it, and what each option
needs (e.g. prep migration modeled on V4_82 vs shared-reference-data
discussion)>

**Inventory (phase 1)**
<repository users and their classification; child tables; other APIs>

**Side findings**
<anything found on the way that someone should look at, one line of impact each>
```

The evidence rule (hard rule 8) applies here too: quote the actual grep
output or file lines behind each gate verdict, do not paraphrase them.

### Phase 1 — Inventory what the mass `TxCtx` wiring does NOT already cover

Quick hygiene checks on changed files before deeper inventory:

```bash
# Any signature carrying TxCtx with non-first position must be fixed
grep -rn "(.*,[[:space:]]*[@A-Za-z0-9_ ]*TxCtx[[:space:]]+[a-zA-Z_][a-zA-Z0-9_]*" \
  openaev-api/src/main/java openaev-model/src/main/java --include="*.java"

# In API v2 / integration tests, block v1 tenant-context/filter idioms
grep -rn "TenantContext.getCurrentTenant\|TenantContext.setCurrentTenant\|enableFilter(\"tenantFilter\")" \
  openaev-api/src/test/java --include="*.java"
```

Because of the baseline above, Phase 1 no longer hunts for missing `TxCtx` on
controller entrypoints. It is still fully required for everything the
blanket wiring cannot fix by construction:

1. **Background paths** (scheduler jobs, queue consumers, startup runners,
   `@Async` tasks) — untouched by the mass wiring, since none of it is a
   `@RestController`. Every writer found here is a Phase 5b conversion; every
   reader is a documented degradation (Phase 0) or gets an explicit scope.
2. **Non-`@Transactional` handlers.** The aspect only fires on `@Transactional`
   methods, so a handler that wasn't `@Transactional` at baseline time was
   correctly left untouched. If its call graph reaches `{table}`, make it
   `@Transactional` and add `TxCtx` now (Phase 2).
3. **OSIV / lazy-serialization sinks (Phase 3b).** A `TxCtx` parameter on an
   entrypoint's signature does not fix a lazy association or computed getter
   resolved by Jackson AFTER the transaction (and its GUC) has already ended.
   Phase 3b is unaffected by the baseline and remains mandatory.
4. **Native/raw SQL shapes** that `JOIN {table}` — orthogonal to `TxCtx`
   entirely, since the inspector inspects SQL text, not method signatures.
5. **Drift since the baseline.** A controller method added, or made
   `@Transactional`, after the mass-wiring PR may still be missing `TxCtx`.
   Spot-check the entrypoints this activation actually needs rather than
   assuming full coverage.
6. **Query shapes that stop being valid SQL once the table is wrapped.** The
   inspector rewrites `FROM {table} t` into a derived table. PostgreSQL's
   functional-dependency rule — selecting ungrouped columns is legal when the
   `GROUP BY` covers the table's primary key — applies to BASE TABLES only, so
   any `GROUP BY` relying on it becomes invalid SQL. See the GROUP BY section
   below; this one is not a `TxCtx` problem at all and no amount of wiring
   fixes it.

```bash
grep -rln "{EntityRepository}" openaev-api/src/main/java openaev-model/src/main/java
grep -rn "{table}" openaev-api/src/main/java --include="*.java" | grep -v "^Binary"
```

The table-name grep matches string literals too (e.g. "apply mitigations"
inside seeded CVE descriptions). Read each hit before classifying it; a
textual match is not a code path.

Classify every hit:
- the table's own API and service → verified in Phase 2 (should already carry
  `TxCtx`; wire it only if genuinely missing)
- another API or service that reads the table → verify it already carries
  `TxCtx` (Phase 5 is now mostly a confirmation, not new wiring)
- background reader → documented degradation (Phase 0), or give it a scope too
  (wrap its read in `tenantTx.execute(scope, …)`) if it must keep seeing rows
- background writer → convert to the primitive in Phase 5b. If you are not
  converting it in this run, it is a blocker: stop and report (Phase 0)

#### Removing the v1 `@Filter` silently disarms every isolation test that does not activate the table

The test profile declares no `openaev.tenant.active-tables`, so the inspector never fires in a test
context unless that context sets it. Every isolation test therefore needs
`@TestPropertySource(properties = "openaev.tenant.active-tables={table}")`, which the RED phase
above already tells you to write.

The trap is the other direction, and it is about tests you did NOT write. A suite that asserted this
table's isolation **before** the activation was relying on the v1 `@Filter`. Removing that filter
takes its isolation away, and because the inspector is not active in its context either, the
assertions keep running against nothing. They do not fail loudly: a cross-tenant read simply starts
returning the other tenant's rows, and only an assertion precise enough to notice will catch it.

That is what happened on the `assets` activation (#6438). `EndpointApiTest`'s `TenantIsolation`
nested class had four cross-tenant tests and no `@TestPropertySource`. Removing `Asset`'s `@Filter`
turned one of them red (`given_endpointInTenantX_should_notAppearInTenantYSearch` returned tenant X's
endpoint to a search under tenant Y) while the other three stayed green **without isolating
anything**. Attribution was correct throughout; only the read was unprotected.

Find them before go-live:

```bash
# test classes that assert something about this table's isolation, and whether they activate it
grep -rln "{Entity}\|{table}" openaev-api/src/test --include="*.java"   | xargs grep -ln "Tenant\|tenant"   | xargs grep -Ln "TestPropertySource"
```

Read every hit. A class that asserts cross-tenant behaviour and does not set `active-tables` is
either proving nothing or about to break.

**Put the annotation on the outermost test class, not on a `@Nested` one.** A `@TestPropertySource`
on a nested class builds a second Spring context, and the mock user provisioned by
`WithMockUserTestExecutionListener` lives in the parent's `TestUserHolder`; the nested context gets
an empty one and every test fails on "The given id must not be null" before reaching its assertion.

#### Code that relied on the v1 `@Filter` breaks silently, and it is not only tests

The previous section is about test suites. The same removal takes isolation away from **production**
paths that never carried a `TxCtx` and were scoped by the v1 filter alone, enabled on every
`@Transactional` method by `HibernateFilterTransactionAspect` from the thread-local.

`NotificationMatchingService.matches` is the case to remember. It is `@Transactional` with no
`TxCtx`, reached from an `@Async` `@TransactionalEventListener` - a pool thread, after commit, no
ambient transaction - and `NotificationEngineService` states the dependency in its own comment:
*"runs with the trigger's tenant so the Hibernate tenant filter scopes every query correctly"*.
Activating `assets`, `asset_groups` or `findings` removes that filter, so every LIVE notification
trigger with a non-empty filter on those resources counted zero rows and stopped firing. No log, no
exception: `matches` catches and returns false.

`session.disableFilter("tenantFilter")` is the same family read from the other side. It is how v1
code declares "this read is deliberately cross-tenant", and it is **inert** under v2: it does nothing
to `app.current_tenants`, and the inspector never consults the Hibernate filter.

```bash
# both directions, before go-live
rg -n 'disableFilter\("tenantFilter"\)' --type java
rg -n 'HibernateFilterTransactionAspect|tenant filter' --type java openaev-api/src/main
# then, for each hit, answer: does this path reach {table}, and what sets its scope now?
```

Follow the call chain to the tables it actually reaches, not the imports of the class in front of
you. `QueueChainingJob` was classified as safe because its own imports name only `steps`,
`workflow_runs` and `step_delay_queue`; four levels down, `createReadySteps` reaches
`ScopeService.getValidAssets` -> `assetService.assets(ids)` on the activated `assets` table.

#### The inspector rewrites `UPDATE` and `DELETE`, not only `SELECT`

`rewriteUpdate` adds `can_access_tenant(...)` to an UPDATE's WHERE. With no scope the statement
updates **zero rows and reports success**. On the `findings` activation this hit the test-only date
setters (`endpointRepository.setCreationDate`): they silently did nothing, every endpoint kept
`now()` as its creation date, and three date-range dashboard assertions counted all of them. The
symptom looked like over-counting across tenants, which is the wrong diagnosis entirely.

Any `@Modifying` query on the table needs a scope, in tests as much as in production.

#### A `@Transactional` MockMvc test keeps the scope the request resolved

The tenant aspect is `@Before`-only and writes `set_config(..., true)`, which is transaction-local.
In a `@Transactional` test the handler joins the test transaction, so after `mvc.perform` returns,
the scope the request resolved is **still set**. Anything the test then reads through a repository
sees it.

That silently couples the expectation to the response. `FindingApiTest` compared its HTTP response
against `findingRepository.findAll()` taken in that same transaction: if the search ever failed
closed, both sides came back empty and `[] == []` held. Eight assertions were affected.

Either materialise the expectation from the fixtures you seeded, read it through raw JDBC (which the
inspector never rewrites), or at minimum assert it is non-empty - that single guard is what turns
`[] == []` back into a failure.
#### Telemetry gauge check: `ProductInventoryMetricCollector`

`openaev-api/src/main/java/io/openaev/telemetry/metric_collectors/ProductInventoryMetricCollector.java`
registers a platform-wide `{table}_total` gauge for most entities, evaluated by
a `Supplier` lambda OUTSIDE any HTTP request — no `TxCtx` from the mass wiring
ever reaches it, so it is always a background reader in the Phase 1 sense
above, and it is the single, recurring, always-present instance of that shape:
check it on every activation regardless of whether the earlier greps surfaced
it. Its own javadoc documents the exact failure mode: with no scope open,
`TenantStatementInspector` fails closed and the gauge silently reports 0, not
an error.

```bash
grep -n "{table}\|{entity}Repository" \
  openaev-api/src/main/java/io/openaev/telemetry/metric_collectors/ProductInventoryMetricCollector.java
```

- No hit at all → nothing to do, the table has no gauge.
- A hit using `safeCount({entity}Repository::count)` directly (the plain,
  un-scoped form) → this is a go-live blocker for that one gauge line, not
  the whole activation: it must be converted to the scoped form BEFORE
  go-live, following the pattern already used for three other v2-active
  tables in the same file, `countAssetGroups()` / `countChannels()` /
  `countImportMappers()` (model fix for `challenges`, #6416):

  ```java
  // registration: this::count{Entities} instead of {entity}Repository::count
  metricRegistry.registerGauge(
      "{table}_total", "Number of {entities}", () -> safeCount(this::count{Entities}));

  /** Counts {entities} across the whole platform ({table} is v2-active, #<issue>). */
  long count{Entities}() {
    return countAcrossAllTenants({entity}Repository::count);
  }
  ```
- A hit already wrapped in `countAcrossAllTenants(...)` → already correct,
  nothing to do; note it in the Phase 9 report as verified, not skipped.

There is no test in CI that would catch a regression here on its own: the
gauge only degrades silently in a real deployment (no assertion fails, no
exception is thrown). Treat this grep as mandatory evidence for the Phase 9
report even when the answer is "no hit" — a claimed activation with no note
on this file is unverified, not verified-empty.


#### GROUP BY on a wrapped table: valid SQL before activation, a 500 after

The inspector rewrites `FROM {table} t` into
`FROM (SELECT * FROM {table} t WHERE can_access_tenant(t.tenant_id)) AS t`.
PostgreSQL lets a query select ungrouped columns when the `GROUP BY` covers the
table's primary key, but that rule holds for **base tables only**. A derived
table has no primary key to infer the dependency from, so the same query stops
being valid:

```sql
-- base table: accepted
SELECT ag.asset_group_id, ag.asset_group_name FROM asset_groups ag GROUP BY 1;

-- wrapped exactly as the inspector wraps it: refused
SELECT ag.asset_group_id, ag.asset_group_name
FROM (SELECT * FROM asset_groups ag WHERE can_access_tenant(ag.tenant_id, true)) AS ag
GROUP BY 1;
ERROR: column "ag.asset_group_name" must appear in the GROUP BY clause
```

Hibernate's criteria layer emits exactly that shape whenever a query helper
groups on `root.get("id")` alone and multiselects other columns, which is the
normal way list and search endpoints are written here.

**This fails in production and passes in CI.** The test profile ships an empty
`active-tables`, so the inspector never fires and the query keeps its base-table
form. The symptom is a 500 on a search or list endpoint, after go-live.

Find every site before activating:

```bash
# every GROUP BY in code that can reach the table, then read each one:
# does it group on the id alone while multiselecting other columns?
grep -rn "groupBy(" openaev-api/src/main/java --include="*.java"
```

Fix by listing every non-aggregated projected column in the `GROUP BY`. It is
equivalent for the planner and does not depend on the FROM item being a base
table. Worked example, `AssetGroupQueryHelper` in the `asset_groups`
activation (#6435):

```java
cq.groupBy(
    List.of(
        assetGroupRoot.get("id"),
        assetGroupRoot.get("name"),
        assetGroupRoot.get("description"),
        dynamicFilterAsJsonb));
```

A column of a type PostgreSQL cannot group on directly (`json`, for instance)
needs a groupable expression on both sides: project `to_jsonb(...)` and group on
that same expression, not on the raw column.

**Do not wait for a fix in the rewriter to skip this step** (tracked in #7843). Making the
inspector keep the primary FROM item as a base table (moving its predicate into
the `WHERE`) would only cover columns of that primary table. Joined tables stay
wrapped, so a query grouping on a joined table's id while selecting its other
columns breaks the same way as soon as that joined table is activated in turn:
`UserQueryHelper` selects the organization's name while grouping only on its id.
Listing the columns is what covers both cases.

**Still walk the transitive closure of callers — but now for background
paths, association/computed-getter sinks, and other non-controller code, not
for missing `TxCtx` on REST entrypoints.** A single hop only finds direct
callers of the repository; it misses a shared utility
(`InjectUtils.resolveInjector`, `CollectorService.getCollectorRelationsId`,
...) called from several unrelated places, each a separate hop. Most of the
historical regressions this walk used to catch were exactly "a REST
controller sibling was never re-grepped once one caller looked wired" — the
`injectors` #7026-class gap (`AtomicTestingService.createOrUpdate`, a second,
never-visited caller of `InjectUtils.resolveInjector` two hops from
`InjectService`), the `executors` #6409 gap (`ExerciseApi#changeExerciseStatus`,
a third, unenumerated caller of `throwIfExerciseNotLaunchable`), and the
`injectors` #6410 gap (`ThreatArsenalApi`'s sibling callers of
`InjectorContractService.searchInjectorContracts`, never re-grepped once
`InjectorContractApi#injectorContracts` looked done) were all controller
entrypoints missing `TxCtx` — the exact failure mode the baseline now
prevents by construction. The risk that remains is the OTHER shape: a shared
symbol whose new caller is NOT a `@Transactional` controller method, so the
baseline never touched it:

- `collectors` (#7026): `SecurityPlatform#collectors`, a lazy association
  serialized by a custom serializer AFTER the transaction returned — no
  amount of `TxCtx` on the controller method fixes this; it needs a
  force-initialize inside the transaction (Phase 3b).
- a background job, queue consumer, or startup task calling the same shared
  utility — invisible to the baseline entirely, and a Phase 5b conversion if
  it writes, a documented degradation or explicit scope if it only reads.
- a DEPRECATED controller sharing the same service as an already-wired one
  (the cwes activation had to wire `CveApi`, deprecated since 1.19, still
  deployed, alongside `VulnerabilityApi`) — this one IS a REST entrypoint, so
  it should already carry `TxCtx` per the baseline; treat a miss here as
  baseline drift to fix directly, not as a new discovery to wire by hand.

The point of the BFS is no longer REST controllers — it's finding the
NON-controller leaves the baseline cannot reach. Use a worklist/BFS over
caller edges (an IDE's "Find Usages"/"Call Hierarchy" or a code-intelligence
tool, if available, is more reliable than chained greps; fall back to
`grep -rn "\.{symbol}("` per newly found symbol otherwise) and keep expanding
each newly found caller until it resolves to one of two outcomes:
- a `@RestController` `@Transactional` method → a cheap, immediate stop: it
  already carries `TxCtx` per the baseline, nothing to do. This is the ONLY
  leaf type the walk can skip without further action.
- anything else — a `@RestController` method that is NOT `@Transactional`, a
  `@Scheduled`/`@RabbitListener`/background entrypoint, or a lazy/computed
  serialization sink — is real work: Phase 2 (make it `@Transactional`),
  Phase 5b (convert the background writer), or Phase 3b (force-initialize the
  sink) respectively.

Never stop at "a service I already expected to see" — that is exactly the
trap that hid `AtomicTestingService.createOrUpdate` and `SecurityPlatform`'s
collectors association above. For every shared helper or service method
found while walking, grep every call site of that exact method name
codebase-wide, walk each one up to its enclosing method, and repeat until
every hit is a real entrypoint. Finding and fixing the first caller is a
signal that the symbol is shared, never a signal to stop.

Also list child tables (FKs pointing at `{table}`). A child without its own
`tenant_id` rides along with the parent and is NOT added to `active-tables`.
A child with its own `tenant_id` is a separate activation; report it.

**Association and computed-getter accessors** (`entity.get{Entities}()`, or a
computed `@JsonProperty` getter deriving a scalar from `{table}`) bypass the
repository grep entirely and are NOT fixed by the mass `TxCtx` wiring even
when their controller already carries the parameter — this is exactly the
OSIV timing problem above. Do the full scan in Phase 3b now; do not defer it
or duplicate it here.

**Native query shape scan (#7007).** Activating `{table}` does not just gate
the queries that read `{table}` itself — it pulls into the fail-closed
`TenantStatementInspector` rewrite EVERY native `@Query` that so much as
mentions `{table}` in a `JOIN`, however unrelated to the rest of that query's
predicates. `TenantStatementInspector` only accepts a closed list of
FROM/JOIN shapes; anything else is refused with `TENANT_FILTERING_REFUSED`,
even a shape that has nothing to do with tenant isolation. The #6751
(collectors) activation shipped this exact regression to production: adding
`collectors` to `active-tables` pulled `findAgentlessExpectationsNotFilledForSource`
into rewriting because it had `JOIN collectors c`, and its unrelated
`NOT EXISTS (SELECT 1 FROM jsonb_array_elements(...) r ...)` predicate — a
table-function FROM item without the `LATERAL` prefix — was refused fail-closed,
breaking the AI defense collector endpoint on every call (see #7007 / PR #7008).

```bash
# every native @Query that JOINs {table}, anywhere in the codebase - not just
# the table's own repository
grep -rln "JOIN {table}\|join {table}" openaev-model/src/main/java openaev-api/src/main/java --include="*.java"
```

For every hit, read the FULL query text (not just the `JOIN {table}` line)
and check every FROM/JOIN item against what `TenantStatementInspector`
already accepts (see `TenantStatementInspectorTest`). The recurring offender
is a table-function FROM item (`jsonb_array_elements`, `jsonb_each`,
`unnest`, ...) missing `LATERAL`: `LATERAL` is a noise word for a
function-call FROM item in PostgreSQL (identical semantics and plan) but is
exactly the marker the inspector uses to accept it — add it. If the query
uses a FROM/JOIN shape the inspector does not cover at all, that is a
blocker: stop and report (Phase 0), do not attempt to teach the inspector a
new shape inside a table-activation PR.

Also avoid CTE and alias names that match real active table names (for
example `WITH tags AS (...)` once `tags` is active). PostgreSQL can resolve
that shadowing, but the inspector's relation-name matching may treat the CTE
as the active table and fail-close the read. Prefer explicit names such as
`scenario_tags_agg`.

Pin the fix with a regression test in `TenantStatementInspectorTest` using
the REAL production SQL (read the `@Query` value via reflection off the
repository method, as PR #7008 does), not a hand-simplified paraphrase — the
whole point is to catch the exact shape that broke in production. Also note
for the record: `openaev-api/src/test/resources/application.properties`
ships an EMPTY `openaev.tenant.active-tables`, so `IntegrationTest`-based API
tests never exercise the rewriter for `{table}` and cannot catch this class
of regression — `TenantStatementInspectorTest` (constructed directly with
`{table}` in its active-table set) is the only test layer that does.

**Test compatibility scan — now scoped to genuinely NEW wiring only.** The
mass-wiring baseline already fixed every test broken by adding a `TxCtx`
parameter to an already-`@Transactional` controller method (`standaloneSetup`/
`@WebMvcTest` MockMvc tests missing a `TxCtxArgumentResolver`, direct Java
calls to the controller method missing the argument). This scan is needed
only for an entrypoint THIS activation newly makes `@Transactional` (item 2
above), and for the v1-filter case, which is unaffected by the baseline: any
test that calls `session.enableFilter("tenantFilter")` and then asserts a
`findAll()`-style read on the entity silently changes meaning once the filter
is removed at go-live and the test context keeps the allowlist empty — the
read returns EVERY tenant's rows. Found on the cwes activation:
`TenantServiceTest` expected 7 cwes and saw 14.

```bash
# tests relying on the v1 filter over the entity you are activating
grep -rln 'enableFilter("tenantFilter")' openaev-api/src/test/java | xargs grep -l "{EntityRepository}\|{Entity}"
```

Fix by asserting on explicit attribution instead
(`.filteredOn(e -> tenantId.equals(e.getTenant().getId()))`), never by
re-adding the filter. If item 2 above did convert a handler to
`@Transactional` for the first time, also register the
`TxCtxArgumentResolver` on any `standaloneSetup` test hitting that URL (or
migrate it to the full-context `IntegrationTest` base class) and add the
`TxCtx` arg to any direct Java call to that method.

### Phase 2 — RED: write the HTTP isolation test first

**Before writing the test, map the controller on both URIs** — this is a
structural, per-table change the `TxCtx` baseline does not touch:
`@RequestMapping({{Api}.URI, {Api}.TENANT_URI})` with
`TENANT_URI = TenantUriUtils.TENANT_PREFIX + "/..."`. Without both mappings
the tenant-path assertions below have no route to hit.

Per the baseline, every already-`@Transactional` handler on `{Api}` should
already declare `TxCtx ctx`; confirm it while you're in the file rather than
assuming it (baseline drift, or a handler just made `@Transactional` in
Phase 1 item 2, are the two ways it can be missing). The aspect only fires on
`@Transactional` methods — a `TxCtx` parameter without the annotation is
silently ignored and the endpoint stays fail-closed, so if a handler that
touches the table isn't `@Transactional`, make it so and add `TxCtx ctx` with
the pilot's one-line comment explaining the parameter (so a reviewer doesn't
delete the "unused" argument). A handler that provably never touches the
table (works on other tables, or on transient objects never persisted) needs
neither.

Model: `openaev-api/src/test/java/io/openaev/rest/mapper/ImportMapperHttpIsolationTest.java`.
If the table has NO API of its own and is reached through another aggregate's
association, prove isolation through THAT aggregate's real endpoints instead;
model: `openaev-api/src/test/java/io/openaev/rest/vulnerability/CweHttpIsolationTest.java`
(cwes proven through the vulnerability endpoints: own-path read exposes the
row, cross-tenant read sees an empty association, ground truth by raw JDBC).
Such a test cannot be `@Transactional` when it must touch two tenant paths:
each request needs its own transaction (see the model's javadoc). Everything it
creates is therefore COMMITTED: clean the table rows explicitly in `@AfterEach`,
then remove the tenants with
`TenantIsolationTestHelper#deleteCommittedTenants` (null-safe, handles the one
non-cascading tenant child).
Place the new test next to the API under test
(`openaev-api/src/test/java/io/openaev/rest/{domain}/`).
Copy the model's structure. Key elements that must all be present:

```java
@Transactional
@TestPropertySource(properties = "openaev.tenant.active-tables={table}")
@WithMockUser(isAdmin = true)
class {Entity}HttpIsolationTest extends IntegrationTest {

  @Autowired private MockMvc mvc;
  @Autowired private TenantIsolationTestHelper tenantHelper;

  @BeforeEach
  void seedTwoTenantsWithOneRowEach() throws Exception {
    tenantA = tenantHelper.createTenantWithCurrentUser("http-iso-a").getId();
    tenantB = tenantHelper.createTenantWithCurrentUser("http-iso-b").getId();
    // seed one row per tenant with a native INSERT carrying an explicit tenant_id
  }
}
```

Notes that make or break the test:
- `@TestPropertySource` activates the table for this test only. The test
  classpath keeps the allowlist empty on purpose; never add your table to the
  test-wide properties.
- **Leave `@WithMockUser` at its default (`autoJoinDefaultTenant` stays
  `false`) on this class.** The class-level mock user must resolve to exactly
  the tenants `tenantHelper.createTenantWithCurrentUser(...)` granted it —
  nothing more. Setting `autoJoinDefaultTenant = true` here silently adds a
  second tenant membership, which (a) defeats the "create with no
  selector → 400" assertion below (the fallback selector now sees an
  unambiguous default tenant instead of an ambiguous multi-tenant scope) and
  (b) can trip `TenantScopeTransactionAspect`'s "scope already set for this
  transaction" guard t

…(truncated)
