Skip to content

MOD-BACKFILL — fill the sweep's reference fields, and refuse the rest out loud - #113

Open
ibuilder wants to merge 1 commit into
mainfrom
feat/mod-backfill
Open

MOD-BACKFILL — fill the sweep's reference fields, and refuse the rest out loud#113
ibuilder wants to merge 1 commit into
mainfrom
feat/mod-backfill

Conversation

@ibuilder

Copy link
Copy Markdown
Owner

Base 6c07a068 (i.e. on top of the merged #109), git merge-tree CLEAN as of that SHA. Item 3 of the field sweep's "not done" list. Surface is one new engine file, one route, one test — no module.json edits, so it doesn't stack conflicts on #111 or #112.

The problem

The sweep added 54 reference fields beside their text twins rather than converting in place — because a converted field's legacy string would have rendered as a clickable link labelled with its first eight characters, across 56 modules, with every gate green. That left the references empty. This fills them.

The design is about which matches it refuses

The failure modes are not symmetric:

An empty reference is visibly empty — somebody sees a blank and fills it.
A wrong one resolves, opens a real record, and shows a plausible name. So it is never questioned, and it silently attributes an RFI to the wrong subcontractor or a defect to the wrong room. Nothing downstream can tell, because the value is structurally perfect.

So the matcher is deliberately timid, and each limit is a decision rather than an omission:

rule why
exact after normalisation case, whitespace and one trailing legal suffix fold (Acme Co., Inc. == ACME). Nothing else — no edit distance, no abbreviation expansion, no prefix match. Acme Electrical and Acme Electric are different companies
unique or nothing two records answering to one name is a question for a human. Breaking that tie by id or created_at is a coin flip wearing a suit
never overwrite a hand-set link is better evidence than a string match, even when the string disagrees
dry run by default apply=False returns exactly what it would do and writes nothing
every skip reported with its reason and a count. A backfill that quietly does 60% and says "done" leaves the other 40% invisible

Admin-gated (it writes across every register in one call). party=None on the write, so it cannot advance a workflow state or satisfy a party gate; actor system:backfill keeps it identifiable in the audit trail.

Two hollow greens caught in my own test

Neither would have failed — both are the "check with no input" shape:

  1. pairs_for() at import time returned []. REGISTRY populates when the app starts, so every assertion under it — including all(t == "company" for ...) — passed over an empty list. Moved inside the TestClient block.
  2. The admin-gate assertion ran with AEC_RBAC unset, so role enforcement was off and the non-admin call returned 200. It failed loudly only because the assertion was written as a refusal; had it been "an admin can call it", it would have passed while testing nothing. A security check run with the security disabled is the emptiest kind of green.

The test asserts the refusals, not the matches: ambiguous is reported with its count, no-match with how many records were indexed, a blank source is not reported (nothing to match on is not a shortfall), a hand-set value survives an apply that would have matched it, a second apply links nothing, and ambiguity stays refused until the data changes — never because the matcher picked.

Verification

⚠️ Unrelated: test_schema_diag fails on any clean checkout

Reported separately to its author. test_shipped_samples_have_zero_structural_errors needs five .ifc files that are not trackedgit ls-files samples/ returns three entries (README.md + two .mass), and git archive HEAD samples/ confirms that's all a fresh clone gets. They exist only as ignored files on this machine.

It's the same defect .gitignore line 49 records: samples/ was ignored outright until v0.3.769, and the old test_samples"whose comment read 'samples/ is committed'" — passed locally because the directory happened to exist. Its own anti-vacuous guard is what made this visible instead of iterating zero files and reporting success.

🤖 Generated with Claude Code

…refuse the rest out loud

The field sweep found 74 text fields naming a concept that already had its own register
(`vendor`, `location`, `spec_section`, `system`, `inspector`) and added 54 reference fields
BESIDE them rather than converting in place — because a converted field's legacy string would
have rendered as a clickable link labelled with its first eight characters, across 56 modules,
with every gate green. That left the references empty. This fills them.

THE WHOLE DESIGN IS ABOUT WHICH MATCHES IT REFUSES TO MAKE, because the failure modes are not
symmetric:

  an EMPTY reference is visibly empty. Somebody sees a blank and fills it.
  a WRONG one resolves, opens a real record and shows a plausible name — so it is never
  questioned, and it silently attributes an RFI to the wrong subcontractor or a defect to the
  wrong room. Nothing downstream can tell, because the value is structurally perfect.

So the matcher is deliberately timid, and each limit is a decision rather than an omission:

  exact after normalisation   case, whitespace, and ONE trailing legal suffix fold
                              ("Acme Co., Inc." == "ACME"). Nothing else: no edit distance, no
                              abbreviation expansion, no prefix match. "Acme Electrical" and
                              "Acme Electric" are different companies and it will not pretend
  unique or nothing           two records answering to one name is a question for a human.
                              Breaking that tie by id or created_at is a coin flip wearing a suit
  never overwrite             a hand-set link is better evidence than a string match, even when
                              the string disagrees
  dry run by default          `apply=False` returns exactly what it would do and writes nothing
  every skip REPORTED         with its reason and a count. A backfill that quietly does 60% of
                              the work and says "done" leaves the other 40% invisible, which is
                              worse than one that does 60% and names the rest

Admin-gated: it writes across every register in a project in one call. `party=None` on the
write, so it cannot advance a workflow state or satisfy a party gate; the actor string
`system:backfill` keeps it identifiable in the audit trail.

TWO HOLLOW GREENS CAUGHT IN MY OWN TEST, both worth recording because neither would have failed:

1. `pairs_for()` at import time returned []. `REGISTRY` is populated when the app STARTS, so
   every assertion under it — including `all(t == "company" for ...)` — passed over an empty
   list. Same shape as `all()` over []: a check with no input cannot fail. Moved inside the
   TestClient block.
2. The admin-gate assertion ran with `AEC_RBAC` unset, so role enforcement was off entirely and
   the non-admin call returned 200. It failed loudly only because the assertion was written as a
   REFUSAL; had it been "an admin can call it", it would have passed while testing nothing.
   A security check run with the security disabled is the emptiest kind of green.

The test asserts the refusals, not the matches: ambiguous is reported with its count, no-match is
reported with how many records were indexed, a blank source is NOT reported (there was nothing to
match on, so it is not a shortfall), a hand-set value survives an apply that would have matched
it, a second apply links nothing, and ambiguity stays refused until the DATA changes — never
because the matcher picked.

No version files or CHANGELOG — the release lane batches those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for be1ce48.


Reviewed by Strix
Re-run review · Configure security review settings

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

MOD-BACKFILL: Admin backfill of additive reference fields via exact, safe matching

✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Add an admin-only endpoint to backfill empty reference fields from existing text values.
• Match conservatively (normalized exact + unique only), never overwrite, and report every refusal.
• Add end-to-end tests for dry-run default, idempotency, ambiguity handling, and RBAC gating.
Diagram

graph TD
  A["Admin caller"] --> B["POST /modules/backfill-references"] --> C["ref_backfill.backfill"] --> D["pairs_for() via REGISTRY"] --> E["list_records() reads"] --> F[("DB")]
  C --> G{"apply=true?"} --> H["modules.update_record"] --> F

  subgraph Legend
    direction LR
    _actor["Caller"] ~~~ _api["API route"] ~~~ _svc["Engine/service"] ~~~ _dec{"Decision"} ~~~ _db[("Database")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Ship as one-off CLI/admin script (no HTTP route)
  • ➕ Reduces API attack surface area and accidental invocation risk
  • ➕ Simpler operational story for a backfill: run once, log output
  • ➖ Harder to standardize auth/audit behavior compared to the API stack
  • ➖ More friction for operators (deploy + shell access) vs calling an endpoint
2. Add a supervised resolution mode for ambiguous matches
  • ➕ Improves completion rate without sacrificing correctness
  • ➕ Keeps human judgment explicit for ties (UI/CSV prompts)
  • ➖ More product/UX and workflow work; not a quick backfill
  • ➖ Risk of scope creep compared to a strict reporting tool
3. Use fuzzy matching / token similarity
  • ➕ Higher match rate when legacy text is inconsistent
  • ➖ Increases the probability of plausible-but-wrong links (highest risk failure mode)
  • ➖ Hard to justify thresholds and hard to audit why a match was chosen

Recommendation: Keep the PR’s conservative strategy (exact normalized + unique-only + never overwrite + dry-run default) because the dominant risk is silently incorrect links. If operationally this needs to be run repeatedly, consider additionally offering a CLI wrapper around the same engine, but avoid fuzzy matching unless you add an explicit human-in-the-loop review step.

Files changed (4) +352 / -2

Enhancement (2) +196 / -1
ref_backfill.pyNew reference backfill engine with conservative matching and full reporting +175/-0

New reference backfill engine with conservative matching and full reporting

• Introduces the MOD-BACKFILL engine to populate additive reference fields from existing text twins. Implements strict normalization + exact matching, refuses ambiguous matches, never overwrites existing references, defaults to dry-run, and returns a detailed report of links and skips.

services/api/src/aec_api/ref_backfill.py

modules.pyAdd admin-only backfill endpoint for module reference fields +21/-1

Add admin-only backfill endpoint for module reference fields

• Adds 'POST /projects/{pid}/modules/backfill-references', gated by the admin role. Exposes dry-run-by-default backfill behavior and supports optional scoping to a single module.

services/api/src/aec_api/routers/modules.py

Tests (2) +156 / -1
run_tests.pyWire ref-backfill test into the API test gate +1/-1

Wire ref-backfill test into the API test gate

• Adds 'test_ref_backfill' to the explicit test list so it runs in the standard CI/test gate.

services/api/run_tests.py

test_ref_backfill.pyEnd-to-end tests for matching refusals, dry-run/apply, and RBAC gating +155/-0

End-to-end tests for matching refusals, dry-run/apply, and RBAC gating

• Adds integration-style coverage for normalization semantics, pairing discovery via module config, dry-run default behavior, apply consistency, idempotency, ambiguity refusal, non-reporting of blank sources, and admin-only enforcement (with RBAC explicitly enabled in env).

services/api/test_ref_backfill.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (2)

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Action required

1. test_ref_backfill sets AEC_RBAC 📜 Skill insight ≡ Correctness
Description
The new DB-backed test in services/api/test_ref_backfill.py violates the test-environment
isolation rules by hard-setting environment variables at import/startup: it sets
os.environ["AEC_RBAC"] = "1" instead of removing it and also unconditionally overrides
DATABASE_URL and STORAGE_DIR. This breaks the run_tests.py per-test sandbox/cleanup contract
and can cause RBAC-dependent behavior and persistent DB/storage state to leak across runs in CI or
on developer machines.
Code

services/api/test_ref_backfill.py[R17-25]

+os.environ["DATABASE_URL"] = "sqlite:///./test_ref_backfill.db"
+os.environ["STORAGE_DIR"] = "./test_storage_ref_backfill"
+os.environ["AEC_TRUST_XUSER"] = "1"
+# WITHOUT THIS, ROLE ENFORCEMENT IS OFF AND THE ADMIN-GATE ASSERTION TESTS NOTHING. The first version
+# of this file omitted it, and the non-admin call returned 200 — which failed the assertion loudly,
+# but would have passed silently had the assertion been written the other way round ("an admin can
+# call it"). A security check run with the security disabled is the emptiest kind of green.
+os.environ["AEC_RBAC"] = "1"
+
Relevance

●● Moderate

Test env-var isolation concern plausible, but no close historical precedent found.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2374337 explicitly requires DB-backed API tests to remove RBAC configuration via
os.environ.pop("AEC_RBAC", None), but the added test instead sets AEC_RBAC to 1, ensuring
RBAC-dependent behavior is forced on. Separately, the test runner is designed to supply unique
DATABASE_URL/STORAGE_DIR values for each test and then delete the corresponding paths, yet
test_ref_backfill.py overwrites those env vars to fixed values and only cleans up the fixed DB
file, which bypasses the runner’s isolation strategy and can leave persistent state outside the
intended per-test sandbox.

services/api/test_ref_backfill.py[17-25]
services/api/test_ref_backfill.py[17-28]
services/api/run_tests.py[81-96]
Skill: backend-tests

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`services/api/test_ref_backfill.py` is not respecting the test runner’s isolation contract and compliance requirements for DB-backed tests: it sets `AEC_RBAC` instead of removing it and unconditionally overrides `DATABASE_URL` and `STORAGE_DIR`, which can leak RBAC behavior and persistent DB/storage state across runs.

## Issue Context
- PR Compliance ID 2374337 requires DB-backed API tests to remove `AEC_RBAC` from the environment (use `os.environ.pop("AEC_RBAC", None)`) and to use `test_*-` prefixed local paths.
- `services/api/run_tests.py` provides per-test `DATABASE_URL` and `STORAGE_DIR` values and cleans up those paths after each test; unconditionally overriding them in the test bypasses that isolation/cleanup and can reduce determinism.
- Prefer deriving any cleanup from the effective environment values so the test behaves correctly both when run via `run_tests.py` and when run standalone.

## Fix Focus Areas
- services/api/test_ref_backfill.py[15-28]
- services/api/run_tests.py[81-96]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. normalize() regex lacks caps 📜 Skill insight ⛨ Security
Description
normalize() applies regexes with unbounded quantifiers to free-text input without capping input
length before matching/substitution. This violates the ReDoS prevention requirement for bounded
regex quantifiers and capped scan length.
Code

services/api/src/aec_api/ref_backfill.py[R49-69]

+_LEGAL_SUFFIX = re.compile(
+    r"[\s,]+(inc|inc\.|llc|l\.l\.c\.|ltd|ltd\.|limited|co|co\.|corp|corp\.|corporation|company|"
+    r"plc|lp|llp|pllc|gmbh|pty|sa|nv|bv)$", re.I)
+
+
+def normalize(s: Any) -> str:
+    """The one normalisation both sides go through. Anything this does not fold is a real difference.
+
+    Deliberately shallow: lowercase, collapse whitespace, drop a single trailing legal suffix, strip
+    surrounding punctuation. It does NOT strip internal punctuation ("J&J" vs "J and J"), expand
+    abbreviations ("Bldg" vs "Building") or do edit-distance — each of those turns a refusal into a
+    guess, and a guess here produces a link that looks right.
+    """
+    t = re.sub(r"\s+", " ", str(s or "")).strip().strip(".,;:-–—").strip()
+    if not t:
+        return ""
+    prev = None
+    while prev != t:                       # "Acme Co., Inc." -> "Acme"
+        prev = t
+        t = _LEGAL_SUFFIX.sub("", t).strip().strip(".,;:-–—").strip()
+    return t.lower()
Relevance

●● Moderate

Mixed precedent on regex caps/ReDoS; unclear if applies here.

PR-#97
PR-#101

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2374349 requires bounded quantifiers and capped input length before applying regex
to user-supplied/free-text. The code defines and applies patterns using + quantifiers and does not
truncate input prior to running re.sub() / _LEGAL_SUFFIX.sub() in normalize().

services/api/src/aec_api/ref_backfill.py[49-69]
Skill: security-monitoring

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`services/api/src/aec_api/ref_backfill.py` uses regexes with unbounded quantifiers (e.g., `\s+`, `[\s,]+`) and does not cap the input length before applying them in `normalize()`, which violates the ReDoS compliance rule.

## Issue Context
Even if the current patterns are relatively simple, the compliance rule requires both bounded quantifiers and a maximum input length (e.g., `text[:20_000]`) before regex processing.

## Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[49-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Unknown module returns success 🐞 Bug ☼ Reliability
Description
The new /modules/backfill-references endpoint accepts any module string and passes it through;
unknown module keys are silently skipped and return HTTP 200 with an empty report. This can make an
operator believe a backfill ran (or was safely reviewed) when nothing happened.
Code

services/api/src/aec_api/routers/modules.py[R65-83]

+@router.post("/projects/{pid}/modules/backfill-references")
+def backfill_references(pid: str, module: str | None = None, apply: bool = False,
+                        db: Session = Depends(get_db),
+                        _: str = Depends(require_role("admin"))):
+    """MOD-BACKFILL — fill empty reference fields from the text twin already on the record.
+
+    The field sweep added 54 references BESIDE their text fields rather than converting in place;
+    this populates them. Exact match after normalisation, unique matches only, existing values never
+    overwritten — and **`apply` defaults to False**, so the default call is a report.
+
+    A wrong auto-link is worse than an empty one: it resolves, opens a real record and shows a
+    plausible name, so nobody questions it. An empty reference is visibly empty and gets filled by
+    the next person to look. Every skip is returned with its reason rather than left as a silent
+    shortfall.
+
+    `admin` because it writes across every register in the project in one call.
+    """
+    return ref_backfill.backfill(db, pid, module_keys=[module] if module else None, apply=apply)
+
Relevance

●● Moderate

No precedent found for rejecting/accepting 200-on-unknown-module vs 4xx validation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The router passes the user-supplied module key directly into the backfill engine, and the engine
treats unknown keys as empty config and skips them, producing a successful-looking empty report.

services/api/src/aec_api/routers/modules.py[65-83]
services/api/src/aec_api/ref_backfill.py[72-88]
services/api/src/aec_api/ref_backfill.py[119-129]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
When the caller provides `module=<typo>`, `backfill()` will process that key, but `pairs_for()` returns an empty list for unknown modules and the tool returns a normal-looking 200 response with no explicit error.

### Issue Context
The endpoint is an admin operational tool meant to be "refusal-forward"; a typo should fail loudly.

### Fix Focus Areas
- services/api/src/aec_api/routers/modules.py[65-83]
- services/api/src/aec_api/ref_backfill.py[72-88]
- services/api/src/aec_api/ref_backfill.py[119-129]

### Suggested fix
- In the router, if `module` is provided and `module not in mod_engine.REGISTRY` (or `ref_backfill.REGISTRY`), raise `HTTPException(404, "unknown module ...")`.
- Alternatively (or additionally), in `backfill()` validate all `module_keys` up front and include an explicit `unknown_module` entry in the report (but do not silently skip).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Suffix stripping overly broad 🐞 Bug ≡ Correctness
Description
normalize() strips a trailing "company" token as a legal suffix, which can conflate real names where
“Company” is part of the identity (e.g., “The Company” vs “The”). That increases the chance of
creating a wrong but plausible unique match.
Code

services/api/src/aec_api/ref_backfill.py[R48-51]

+#: Trailing company-form tokens that carry no identity — "Acme Inc" and "Acme" are one firm.
+_LEGAL_SUFFIX = re.compile(
+    r"[\s,]+(inc|inc\.|llc|l\.l\.c\.|ltd|ltd\.|limited|co|co\.|corp|corp\.|corporation|company|"
+    r"plc|lp|llp|pllc|gmbh|pty|sa|nv|bv)$", re.I)
Relevance

●● Moderate

Correctness tradeoff; no close precedent on legal-suffix set including “company”.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The regex explicitly removes “company” as a trailing suffix, and normalize() repeatedly applies that
regex; this can collapse distinct names to the same key and therefore change match uniqueness
decisions.

services/api/src/aec_api/ref_backfill.py[48-69]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_LEGAL_SUFFIX` includes `company`, so `normalize("Acme Company") == normalize("Acme")`. Because backfill links on *exact normalized equality*, this broadens the match surface and can create incorrect unique matches.

### Issue Context
This backfill explicitly optimizes for “refuse rather than guess”; broad normalization rules increase guessing.

### Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[48-69]

### Suggested fix
- Remove `company` from `_LEGAL_SUFFIX` (keeping `co`/`corp`/`inc`/etc.), **or**
- Add a stricter rule (e.g., only strip `company` when it appears as part of a known legal form pattern), **and**
- Add a focused test case demonstrating the desired behavior for names ending in "Company" so future changes don’t reintroduce accidental conflation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

5. 5000-row truncation mislinks 🐞 Bug ≡ Correctness
Description
ref_backfill._index() (and the per-module scan) fetches at most 5,000 rows via
list_records(limit=5000) without pagination, so the index can miss additional matching targets
beyond that boundary. When that happens, the code can incorrectly treat a name as a unique match and
write a plausible-but-wrong link instead of refusing it as ambiguous.
Code

services/api/src/aec_api/ref_backfill.py[R97-107]

+    mod = REGISTRY.get(target) or {}
+    tf = mod.get("title_field")
+    idx: dict[str, list[str]] = {}
+    rows = list_records(db, target, project_id, limit=5000)
+    for r in rows:
+        data = r.get("data") or {}
+        for candidate in (data.get(tf) if tf else None, r.get("ref")):
+            key = normalize(candidate)
+            if key:
+                idx.setdefault(key, []).append(r["id"])
+    return idx, len(rows)
Relevance

● Weak

Similar LIMIT-based truncation/correctness concern previously rejected.

PR-#96

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The backfill engine hard-caps both indexing and scanning to 5,000 rows, but list_records enforces
that cap with SQL LIMIT; this can hide additional matching candidates and incorrectly turn an
ambiguous match into a unique match.

services/api/src/aec_api/ref_backfill.py[90-107]
services/api/src/aec_api/ref_backfill.py[125-143]
services/api/src/aec_api/modules_query.py[172-198]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ref_backfill._index()` and `backfill()` use `list_records(..., limit=5000)` without pagination. If a target module has >5,000 records, a second record with the same normalized name can exist beyond the first page, causing the matcher to see exactly one hit and write a wrong (but structurally valid) reference link.

### Issue Context
`modules_query.list_records()` applies SQL `LIMIT/OFFSET`, so `limit=5000` is a hard cap unless you loop with offsets.

### Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[90-163]
- services/api/src/aec_api/modules_query.py[172-198]

### Suggested fix
1. Add a small helper in `ref_backfill.py` to fetch **all** records for a module in pages (e.g., `page=1000`), incrementing `offset` until `len(rows) < page`.
2. Use that helper both for `_index()` and for the source `records` scan.
3. If you don’t want to page indefinitely, at minimum detect truncation (`len(rows) == limit`) and **refuse to apply** (or return a report flag like `truncated: true`) so the tool can’t silently violate the “unique-or-nothing” promise.
4. Update `records_scanned` to reflect the true scanned count when paginating (or explicitly label it as `records_scanned_capped`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +17 to +25
os.environ["DATABASE_URL"] = "sqlite:///./test_ref_backfill.db"
os.environ["STORAGE_DIR"] = "./test_storage_ref_backfill"
os.environ["AEC_TRUST_XUSER"] = "1"
# WITHOUT THIS, ROLE ENFORCEMENT IS OFF AND THE ADMIN-GATE ASSERTION TESTS NOTHING. The first version
# of this file omitted it, and the non-admin call returned 200 — which failed the assertion loudly,
# but would have passed silently had the assertion been written the other way round ("an admin can
# call it"). A security check run with the security disabled is the emptiest kind of green.
os.environ["AEC_RBAC"] = "1"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. test_ref_backfill sets aec_rbac 📜 Skill insight ≡ Correctness

The new DB-backed test in services/api/test_ref_backfill.py violates the test-environment
isolation rules by hard-setting environment variables at import/startup: it sets
os.environ["AEC_RBAC"] = "1" instead of removing it and also unconditionally overrides
DATABASE_URL and STORAGE_DIR. This breaks the run_tests.py per-test sandbox/cleanup contract
and can cause RBAC-dependent behavior and persistent DB/storage state to leak across runs in CI or
on developer machines.
Agent Prompt
## Issue description
`services/api/test_ref_backfill.py` is not respecting the test runner’s isolation contract and compliance requirements for DB-backed tests: it sets `AEC_RBAC` instead of removing it and unconditionally overrides `DATABASE_URL` and `STORAGE_DIR`, which can leak RBAC behavior and persistent DB/storage state across runs.

## Issue Context
- PR Compliance ID 2374337 requires DB-backed API tests to remove `AEC_RBAC` from the environment (use `os.environ.pop("AEC_RBAC", None)`) and to use `test_*-` prefixed local paths.
- `services/api/run_tests.py` provides per-test `DATABASE_URL` and `STORAGE_DIR` values and cleans up those paths after each test; unconditionally overriding them in the test bypasses that isolation/cleanup and can reduce determinism.
- Prefer deriving any cleanup from the effective environment values so the test behaves correctly both when run via `run_tests.py` and when run standalone.

## Fix Focus Areas
- services/api/test_ref_backfill.py[15-28]
- services/api/run_tests.py[81-96]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +49 to +69
_LEGAL_SUFFIX = re.compile(
r"[\s,]+(inc|inc\.|llc|l\.l\.c\.|ltd|ltd\.|limited|co|co\.|corp|corp\.|corporation|company|"
r"plc|lp|llp|pllc|gmbh|pty|sa|nv|bv)$", re.I)


def normalize(s: Any) -> str:
"""The one normalisation both sides go through. Anything this does not fold is a real difference.

Deliberately shallow: lowercase, collapse whitespace, drop a single trailing legal suffix, strip
surrounding punctuation. It does NOT strip internal punctuation ("J&J" vs "J and J"), expand
abbreviations ("Bldg" vs "Building") or do edit-distance — each of those turns a refusal into a
guess, and a guess here produces a link that looks right.
"""
t = re.sub(r"\s+", " ", str(s or "")).strip().strip(".,;:-–—").strip()
if not t:
return ""
prev = None
while prev != t: # "Acme Co., Inc." -> "Acme"
prev = t
t = _LEGAL_SUFFIX.sub("", t).strip().strip(".,;:-–—").strip()
return t.lower()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. normalize() regex lacks caps 📜 Skill insight ⛨ Security

normalize() applies regexes with unbounded quantifiers to free-text input without capping input
length before matching/substitution. This violates the ReDoS prevention requirement for bounded
regex quantifiers and capped scan length.
Agent Prompt
## Issue description
`services/api/src/aec_api/ref_backfill.py` uses regexes with unbounded quantifiers (e.g., `\s+`, `[\s,]+`) and does not cap the input length before applying them in `normalize()`, which violates the ReDoS compliance rule.

## Issue Context
Even if the current patterns are relatively simple, the compliance rule requires both bounded quantifiers and a maximum input length (e.g., `text[:20_000]`) before regex processing.

## Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[49-69]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +65 to +83
@router.post("/projects/{pid}/modules/backfill-references")
def backfill_references(pid: str, module: str | None = None, apply: bool = False,
db: Session = Depends(get_db),
_: str = Depends(require_role("admin"))):
"""MOD-BACKFILL — fill empty reference fields from the text twin already on the record.

The field sweep added 54 references BESIDE their text fields rather than converting in place;
this populates them. Exact match after normalisation, unique matches only, existing values never
overwritten — and **`apply` defaults to False**, so the default call is a report.

A wrong auto-link is worse than an empty one: it resolves, opens a real record and shows a
plausible name, so nobody questions it. An empty reference is visibly empty and gets filled by
the next person to look. Every skip is returned with its reason rather than left as a silent
shortfall.

`admin` because it writes across every register in the project in one call.
"""
return ref_backfill.backfill(db, pid, module_keys=[module] if module else None, apply=apply)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Unknown module returns success 🐞 Bug ☼ Reliability

The new /modules/backfill-references endpoint accepts any module string and passes it through;
unknown module keys are silently skipped and return HTTP 200 with an empty report. This can make an
operator believe a backfill ran (or was safely reviewed) when nothing happened.
Agent Prompt
### Issue description
When the caller provides `module=<typo>`, `backfill()` will process that key, but `pairs_for()` returns an empty list for unknown modules and the tool returns a normal-looking 200 response with no explicit error.

### Issue Context
The endpoint is an admin operational tool meant to be "refusal-forward"; a typo should fail loudly.

### Fix Focus Areas
- services/api/src/aec_api/routers/modules.py[65-83]
- services/api/src/aec_api/ref_backfill.py[72-88]
- services/api/src/aec_api/ref_backfill.py[119-129]

### Suggested fix
- In the router, if `module` is provided and `module not in mod_engine.REGISTRY` (or `ref_backfill.REGISTRY`), raise `HTTPException(404, "unknown module ...")`.
- Alternatively (or additionally), in `backfill()` validate all `module_keys` up front and include an explicit `unknown_module` entry in the report (but do not silently skip).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +48 to +51
#: Trailing company-form tokens that carry no identity — "Acme Inc" and "Acme" are one firm.
_LEGAL_SUFFIX = re.compile(
r"[\s,]+(inc|inc\.|llc|l\.l\.c\.|ltd|ltd\.|limited|co|co\.|corp|corp\.|corporation|company|"
r"plc|lp|llp|pllc|gmbh|pty|sa|nv|bv)$", re.I)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Suffix stripping overly broad 🐞 Bug ≡ Correctness

normalize() strips a trailing "company" token as a legal suffix, which can conflate real names where
“Company” is part of the identity (e.g., “The Company” vs “The”). That increases the chance of
creating a wrong but plausible unique match.
Agent Prompt
### Issue description
`_LEGAL_SUFFIX` includes `company`, so `normalize("Acme Company") == normalize("Acme")`. Because backfill links on *exact normalized equality*, this broadens the match surface and can create incorrect unique matches.

### Issue Context
This backfill explicitly optimizes for “refuse rather than guess”; broad normalization rules increase guessing.

### Fix Focus Areas
- services/api/src/aec_api/ref_backfill.py[48-69]

### Suggested fix
- Remove `company` from `_LEGAL_SUFFIX` (keeping `co`/`corp`/`inc`/etc.), **or**
- Add a stricter rule (e.g., only strip `company` when it appears as part of a known legal form pattern), **and**
- Add a focused test case demonstrating the desired behavior for names ending in "Company" so future changes don’t reintroduce accidental conflation.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant