Skip to content

MOD-FILTER — per-field filters + server-side sort · MOD-PERCENT — the form layer didn't know percent existed - #109

Merged
ibuilder merged 2 commits into
mainfrom
feat/mod-filters-pr
Jul 30, 2026
Merged

MOD-FILTER — per-field filters + server-side sort · MOD-PERCENT — the form layer didn't know percent existed#109
ibuilder merged 2 commits into
mainfrom
feat/mod-filters-pr

Conversation

@ibuilder

Copy link
Copy Markdown
Owner

Two commits. The second is a live regression fix for #108 and can be prioritised independently if you'd rather split them.

This is item 1 on #108's own "not done" list — in flight, not open.

1. MOD-FILTER — per-field filters and server-side sort

A register could be narrowed by exactly two things: full-text q and workflow_state. On a twenty-field register that means no filtering by discipline, vendor, cost code or date range. And sorting ran in the browser over whichever page had been fetched — "sort by amount" on a 500-row register ordered 200 rows and presented the top of those as the largest values in the register. Nothing looked wrong; it was the wrong 200 rows.

?f.discipline=Structural&f.amount.gte=1000&f.amount.lte=5000&sort=amount&sort_dir=desc

eq · ne · gte · lte · contains · in · empty · nonempty, composing with state and q, bounded at 12 per request, applied in SQL before the LIMIT. count_records takes filters too — a total that ignores a filter the list applied is the number a user trusts without checking.

Two properties carry the design.

A field name is never taken from the caller. _resolve_field looks it up in the module's declared fields and 400s otherwise. This isn't hygiene: _json_text interpolates the key into a SQLite JSON path, and its docstring already said the key must be module-defined — this is what enforces it. Refusing rather than ignoring matters separately: a silently dropped filter returns more rows than were asked for, which reads as data rather than as a bug.

A number is compared as a number. JSON extraction yields text, where 9 > 10 and 1000 sorts before 900.

The test for that second property was wrong first, and the mutation caught it. My fixtures posted ints, and the cast could be deleted with every assertion still passing. SQLite types json_extract by what was stored: a JSON number returns integer and compares numerically unaided, so an int fixture never exercises the cast. A JSON string returns text, and '9' >= 10 is TRUE. Strings are the normal case — an HTML form posts a string, a CSV import posts a string, and validate_record only checks that a numeric field parses. Fixtures re-posted as strings; the mutation now fails with nine leaking into amount >= 10.

A range is two clauses on one field, so the client filter type is a list, not a map keyed by field. My first version was a map, where amount.gte and amount.lte collide and the second overwrites the first — a range silently collapsing to a single bound, i.e. a narrower result that looks entirely correct.

UI: a collapsed filter bar, one control per type — options dropdown for a select, from/to for a date, min/max for a number, a lazily-populated record picker for a reference, case-insensitive contains otherwise. The toggle carries the active count, because a page showing a subset while looking unfiltered is indistinguishable from missing data. Header sort now asks the server; serverSortField maps the four pseudo-columns (ref, statusworkflow_state, assignee, title→the module's title_field) and returns null otherwise rather than sending a name the server would rightly reject. Paged sort keeps created_at as the tiebreak so equal values can't swap between pages and show a row twice or never.

2. MOD-PERCENT — a live regression #108 introduced

Found by Massing Core reviewing #108. Three places asked f.type === "number" || f.type === "currency" and percent was in none. Survivable while 2 fields were typed percent; #108 converted 21 more — every retainage, fee, overhead and contingency percentage:

site effect
EDITABLE those 21 fields stopped being inline-editable, with no error
record form rendered <input type="text"> — no numeric keypad, no step, no validation
save path stored the string "5"

The third is the one that matters. validate_record calls float(value) so it passed, every consumer coerces, so nothing failed — the column just accumulated a mix of 5 and "5" depending on which form last touched the record. It also feeds the bug MOD-FILTER's cast exists to survive: numbers stored as text are what make SQL comparison go lexicographic. The two halves of this PR are the same defect from opposite ends.

Fixed as one idea, not three ternaries: NUMERIC_FIELD_TYPES named once, referenced at all three sites, so the next numeric type is one edit rather than three that can be done two-thirds of the way — which is exactly how this happened. A declared unit now also renders beside the input, not only in table cells.

The gate is a coverage test, not a DOM test. Rendering a form and asserting one input is numeric would prove percent works and nothing about the next type. What failed was completeness — a hand-maintained list with no check it covered the types in use. fieldTypeCoverage.test.ts asserts the partition: every type the 133 modules declare is either editable or deliberately read-only, none is in both, every magnitude-holding type is numeric, and nothing in module_schema.FIELD_TYPES is unrenderable. READ_ONLY_FIELD_TYPES carries a reason per entry so "not editable" is a decision on the record. Mutation-checked: removing percent fails 4 of 6 assertions from four independent angles.

Verification

  • Backend 447/448. The one failure is test_esign, which passes solo — parallel-sqlite pollution, partly residue from three suite runs I killed. Caveat worth stating: that local run used the working-tree run_tests.py, which in a shared checkout also carried another session's test_seal_identity registration; this branch's committed manifest has 447.
  • Web 1011/1012. The one failure is pdfVendor timing out at 20s under load; passes solo in 5.69s.
  • tsc --noEmit clean for these files. git merge-tree against current main reports no conflicts.
  • No new client methods — moduleRecordsFiltered gained parameters, so the api surface ratchet is untouched and surface.test.ts passes.

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

Note on the sibling branch

origin/feat/mod-filters contains these two commits plus 785462c5, an unrelated seal-identity commit from another session that landed on my branch through the shared checkout and which my push published. Nothing is lost and I have not rewritten it — this PR is raised from feat/mod-filters-pr, which holds only the two commits above. 785462c5 should be cherry-picked to main by its author.

🤖 Generated with Claude Code

@strix-security

strix-security Bot commented Jul 30, 2026

Copy link
Copy Markdown

Strix Security Review

No security issues found.

Updated for 71ede28.


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

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Module registers: server-side per-field filters/sort + fix percent form handling

✨ Enhancement 🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Add server-side per-field filtering and register-wide sorting for module record lists.
• Wire Portal register UI to send filters/sort to the API and add a collapsible filter bar.
• Fix percent field type numeric/editability handling and add coverage + API regression tests.
Diagram

graph TD
  A["PortalUI register"] --> B["Web API client"] --> C["Modules router"] --> D["modules_query"] --> E[("DB")]
  F["Vitest coverage"] --> A
  G["API filter tests"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Send filters as a JSON-encoded query param (or POST body) instead of f.* params
  • ➕ Avoids custom parsing of f.. keys and reduces ambiguity around repeated params
  • ➕ Naturally represents multiple clauses on one field (ranges) without client-side key suffix hacks
  • ➖ Less readable/bookmarkable URLs; harder to tweak by hand and less friendly in logs
  • ➖ Requires additional validation/parsing logic anyway; may complicate caching and tooling
2. Model UI filter state as a list of clauses end-to-end (no `__op` keying)
  • ➕ Eliminates control-key suffixing and the flattening step to wire format
  • ➕ Matches the backend’s conceptual model (list of clauses) and avoids accidental overwrites
  • ➖ More UI code churn (render/update/delete) vs. current map-based state
  • ➖ Harder to update individual controls without searching/removing items from a list

Recommendation: Keep the PR’s current approach: f.* query params are user/log/bookmark friendly, and strict server-side validation (_resolve_field + 400-on-unknown) is the right security/correctness boundary given JSON-path interpolation. If the filter UI grows substantially, consider migrating the Portal filter state to a first-class clause list to remove the __op keying/flattening step, but it’s not required to safely ship this feature.

Files changed (9) +765 / -18

Enhancement (5) +476 / -17
modules.tsSupport per-field filters and server-side sort in moduleRecordsFiltered() +34/-2

Support per-field filters and server-side sort in moduleRecordsFiltered()

• Extends module record listing options to include a list of per-field filters and sort/sort_dir. Serializes filters into repeated f.<field>[.<op>] query params (using append to preserve ranges) and skips empty values for non-predicate operators.

apps/web/src/api/modules.ts

types.tsAdd ModuleFilterOp union type for register filtering +4/-0

Add ModuleFilterOp union type for register filtering

• Introduces a shared type for allowed filter operators (eq/ne/gte/lte/contains/in/empty/nonempty). Documents that invalid operators are rejected by the server rather than ignored.

apps/web/src/api/types.ts

portal.tsAdd register filter bar + server-side sort wiring; fix percent numeric handling +266/-7

Add register filter bar + server-side sort wiring; fix percent numeric handling

• Threads a richer register filter state through openModule(), forwards per-field filters and mapped sort columns to the API, and disables client-side sorting when the server can sort that column. Introduces shared NUMERIC_FIELD_TYPES/EDITABLE_FIELD_TYPES/READ_ONLY_FIELD_TYPES and uses them to render percent as numeric input and coerce saved values to numbers.

apps/web/src/portal/portal.ts

modules_query.pyImplement validated per-field filters and server-side sort over JSON data +129/-5

Implement validated per-field filters and server-side sort over JSON data

• Adds field resolution against the module registry, safe JSON extraction, numeric casting for number/currency/percent, and filter operator support (eq/ne/gte/lte/contains/in/empty/nonempty). list_records() and count_records() accept filters and apply them (plus stable ORDER BY with created_at tiebreak) before LIMIT/OFFSET.

services/api/src/aec_api/modules_query.py

modules.pyParse f.* filters and expose sort params on modules list endpoint +43/-3

Parse f.* filters and expose sort params on modules list endpoint

• Adds query param parsing for repeated f.<field>[.<op>] filters with a MAX_FILTERS bound, and plumbs sort/sort_dir through the GET /projects/{pid}/modules/{key} route. Leaves field validation to the query layer to avoid drift.

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

Tests (2) +274 / -0
fieldTypeCoverage.test.tsAdd coverage test to ensure UI supports all module field types (incl. percent) +109/-0

Add coverage test to ensure UI supports all module field types (incl. percent)

• Adds a Vitest that reads real module.json files and the server’s FIELD_TYPES to ensure every in-use/declared type is either editable or explicitly read-only. Also asserts magnitude-like types are treated as numeric and that 'percent' is editable/numeric/in use.

apps/web/src/portal/fieldTypeCoverage.test.ts

test_module_filters.pyAdd end-to-end API tests for numeric-safe filters and stable sorting +165/-0

Add end-to-end API tests for numeric-safe filters and stable sorting

• Creates COR records with numeric values posted as strings to reproduce SQLite lexicographic comparison pitfalls. Asserts numeric comparisons/sorts are cast correctly, operators compose with q/state, unknown fields/operators are rejected (400), and pagination remains stable via a deterministic tiebreak.

services/api/test_module_filters.py

Other (2) +15 / -1
style.cssStyle the collapsible per-field filter bar +14/-0

Style the collapsible per-field filter bar

• Adds grid-based layout and sizing rules for the new filter row/body/cells, keeping the filter UI collapsed by default and consistent with existing toolbar active-state styling.

apps/web/src/style.css

run_tests.pyWire new module filter tests into the API test gate +1/-1

Wire new module filter tests into the API test gate

• Adds test_module_filters to the explicit test list so filter/sort regressions are covered in CI runs using this runner.

services/api/run_tests.py

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Action required

1. test_module_filters doesn't pop AEC_RBAC 📜 Skill insight ≡ Correctness
Description
The new DB-backed API test sets a local test database and storage paths but does not call
os.environ.pop("AEC_RBAC", None), so RBAC can remain enabled from the environment and make the
test non-deterministic across machines/CI. This violates the requirement that DB-backed tests
explicitly remove RBAC from the environment.
Code

services/api/test_module_filters.py[R23-28]

+import os
+
+os.environ["DATABASE_URL"] = "sqlite:///./test_mod_filters.db"
+os.environ["STORAGE_DIR"] = "./test_storage_mod_filters"
+os.environ["AEC_TRUST_XUSER"] = "1"
+
Relevance

●●● Strong

Test determinism/environment hygiene issue; easy, low-risk fix to align with DB-test conventions.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2374337 requires DB-backed API tests to set DATABASE_URL/STORAGE_DIR/IFC_DIR
(if applicable) to ./test_*-prefixed paths and to call os.environ.pop("AEC_RBAC", None). The new
test sets DATABASE_URL and STORAGE_DIR but does not pop AEC_RBAC on the environment-setup
lines.

services/api/test_module_filters.py[23-28]
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_module_filters.py` is a DB-backed API test but it does not call `os.environ.pop("AEC_RBAC", None)`.

## Issue Context
Per the compliance rule, DB-backed API tests must use `test_*-prefixed` local paths *and* pop `AEC_RBAC` to ensure RBAC does not leak into the test environment.

## Fix Focus Areas
- services/api/test_module_filters.py[23-32]

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


2. Bad system columns allowlist 🐞 Bug ☼ Reliability
Description
SYSTEM_COLUMNS includes updated_at and ball_in_court, but _field_expr() treats these as real
SQL columns (t.c[name]), which will raise at runtime because the module tables don’t define them.
A request like ?sort=updated_at or ?f.updated_at.gte=... can therefore produce a server 500
instead of a controlled 400.
Code

services/api/src/aec_api/modules_query.py[R87-112]

+#: Columns that live on the row rather than inside `data`, and may be filtered/sorted directly.
+SYSTEM_COLUMNS = {"workflow_state", "created_at", "updated_at", "ref", "assignee", "ball_in_court"}
+
+#: The operators a filter may use. `contains` is a case-insensitive substring; `in` takes a
+#: comma-separated list; `empty`/`nonempty` ignore the value.
+FILTER_OPS = {"eq", "ne", "gte", "lte", "contains", "in", "empty", "nonempty"}
+
+_NUMERIC_FIELD_TYPES = {"number", "currency", "percent"}
+
+
+def _resolve_field(mod: dict, name: str) -> dict:
+    """The DECLARED definition of `name`, or HTTP 400. Never trust a caller-supplied field name."""
+    if name in SYSTEM_COLUMNS:
+        return {"name": name, "type": "text", "_system": True}
+    for f in mod.get("fields", []):
+        if f.get("name") == name:
+            return f
+    raise HTTPException(400, f"unknown filter/sort field {name!r} for this module")
+
+
+def _field_expr(db: Session, t, mod: dict, name: str):
+    """A comparable SQL expression for a declared field — cast to float when the field is numeric."""
+    f = _resolve_field(mod, name)
+    if f.get("_system"):
+        return t.c[name], f
+    expr = _json_text(db, t.c.data, name)
Relevance

●●● Strong

Likely runtime 500 from nonexistent columns; reliability bug worth fixing with small allowlist
change.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
modules_query.SYSTEM_COLUMNS is treated as a direct table-column allowlist, but the module tables
defined in modules_registry._table() do not contain updated_at or ball_in_court. Because
_field_expr() returns t.c[name] for system columns, those values can crash lookup.

services/api/src/aec_api/modules_query.py[87-112]
services/api/src/aec_api/modules_registry.py[55-81]

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

## Issue description
`SYSTEM_COLUMNS` contains names that are not actual columns in the module tables (`updated_at`, `ball_in_court`). Because `_field_expr()` returns `t.c[name]` for any name in `SYSTEM_COLUMNS`, these inputs can crash the request path instead of returning a 400.

## Issue Context
Module table columns are defined in `modules_registry._table()`. It uses `modified_at` (not `updated_at`) and does not include `ball_in_court`.

## Fix Focus Areas
- services/api/src/aec_api/modules_query.py[87-112]
- services/api/src/aec_api/modules_registry.py[55-81]

## Suggested fix
- Remove non-existent names from `SYSTEM_COLUMNS` (e.g., drop `ball_in_court`, change `updated_at` -> `modified_at` if you intend to expose it).
- Add a guard in `_field_expr()` for system columns: if `name` is not present in `t.c`, raise `HTTPException(400, ...)` rather than dereferencing `t.c[name]`.
- Consider keeping the allowlist derived from/validated against the table schema to prevent future drift.

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


3. ESM test uses __dirname 🐞 Bug ☼ Reliability
Description
fieldTypeCoverage.test.ts uses __dirname, but apps/web is configured as ESM (`"type":
"module", TS module: "ESNext"), where __dirname is undefined. This can throw ReferenceError:
__dirname is not defined` before any tests execute.
Code

apps/web/src/portal/fieldTypeCoverage.test.ts[R32-33]

+const MODULES_DIR = resolve(__dirname, "../../../../services/api/modules");
+const SCHEMA_PY = resolve(__dirname, "../../../../services/api/src/aec_api/module_schema.py");
Relevance

●●● Strong

Deterministic ESM runtime failure risk; teams usually fix obvious test-breakers.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test references __dirname directly, while the web package is explicitly ESM; in ESM contexts
__dirname is not defined unless manually created.

apps/web/src/portal/fieldTypeCoverage.test.ts[32-34]
apps/web/package.json[1-8]
apps/web/tsconfig.json[1-18]

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

## Issue description
The new test file uses `__dirname` to locate repo files. In this package, Node runs in ESM mode, so `__dirname` is not available and the test module can crash on import.

## Issue Context
`apps/web/package.json` sets `type: module` and `apps/web/tsconfig.json` uses `module: ESNext`.

## Fix Focus Areas
- apps/web/src/portal/fieldTypeCoverage.test.ts[32-34]
- apps/web/package.json[1-8]
- apps/web/tsconfig.json[1-18]

## Suggested fix
Replace `__dirname` usage with an ESM-safe equivalent:
- `import { fileURLToPath } from "node:url";`
- `import { dirname, resolve } from "node:path";`
- `const here = dirname(fileURLToPath(import.meta.url));`
- `const MODULES_DIR = resolve(here, "../../../../services/api/modules");`
(and same for `SCHEMA_PY`).

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



Remediation recommended

4. Search overrides explicit sort 🐞 Bug ≡ Correctness
Description
On Postgres, list_records() applies ORDER BY ts_rank(...) DESC when q is present, then
_apply_sort() adds another order_by which becomes secondary rather than primary. This means
sort=<field> will not actually be the primary ordering for search results on Postgres (it only
breaks ties).
Code

services/api/src/aec_api/modules_query.py[R185-193]

        stmt = stmt.where(_search_filter(db, t, q))
        if _is_postgres(db) and (tsq := _pg_tsquery(q)):
            stmt = stmt.order_by(func.ts_rank(_pg_document(t), func.to_tsquery("english", tsq)).desc())
+    # MOD-FILTER — per-field narrowing, applied in SQL BEFORE the limit for the same reason `q` is:
+    # filtering a page after fetching it answers a different question than the user asked, and looks
+    # identical to the right answer.
+    if filters:
+        stmt = _apply_filters(db, stmt, t, REGISTRY.get(key) or {}, filters)
+    stmt, _explicit_sort = _apply_sort(db, stmt, t, REGISTRY.get(key) or {}, sort, sort_dir)
Relevance

●● Moderate

Could be intentional relevance-first ordering for search; changing primary sort may alter UX
semantics.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The code adds a Postgres ts_rank order_by when q is present, then applies _apply_sort() after;
because both are order_by() calls, the first ordering remains primary and the explicit sort
becomes secondary.

services/api/src/aec_api/modules_query.py[182-197]

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 `q` is present on Postgres, `list_records()` adds a relevance `ORDER BY` first, and the new `_apply_sort()` appends the requested sort afterwards. That makes explicit sort secondary during search.

## Issue Context
SQLAlchemy Core’s `order_by()` appends additional ordering criteria by default; it doesn’t replace prior orderings unless cleared.

## Fix Focus Areas
- services/api/src/aec_api/modules_query.py[182-197]

## Suggested fix
Decide on intended behavior and implement explicitly:
- If explicit `sort` should override relevance ordering, clear existing ordering before applying sort (e.g., `stmt = stmt.order_by(None)` when `sort` is provided, then apply `_apply_sort()`), then append `created_at` tiebreak.
- If you intentionally want relevance-first even with explicit sort, document that contract and consider renaming parameter/behavior accordingly to avoid surprising callers.

ⓘ 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 +23 to +28
import os

os.environ["DATABASE_URL"] = "sqlite:///./test_mod_filters.db"
os.environ["STORAGE_DIR"] = "./test_storage_mod_filters"
os.environ["AEC_TRUST_XUSER"] = "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_module_filters doesn't pop aec_rbac 📜 Skill insight ≡ Correctness

The new DB-backed API test sets a local test database and storage paths but does not call
os.environ.pop("AEC_RBAC", None), so RBAC can remain enabled from the environment and make the
test non-deterministic across machines/CI. This violates the requirement that DB-backed tests
explicitly remove RBAC from the environment.
Agent Prompt
## Issue description
`services/api/test_module_filters.py` is a DB-backed API test but it does not call `os.environ.pop("AEC_RBAC", None)`.

## Issue Context
Per the compliance rule, DB-backed API tests must use `test_*-prefixed` local paths *and* pop `AEC_RBAC` to ensure RBAC does not leak into the test environment.

## Fix Focus Areas
- services/api/test_module_filters.py[23-32]

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

Comment on lines +87 to +112
#: Columns that live on the row rather than inside `data`, and may be filtered/sorted directly.
SYSTEM_COLUMNS = {"workflow_state", "created_at", "updated_at", "ref", "assignee", "ball_in_court"}

#: The operators a filter may use. `contains` is a case-insensitive substring; `in` takes a
#: comma-separated list; `empty`/`nonempty` ignore the value.
FILTER_OPS = {"eq", "ne", "gte", "lte", "contains", "in", "empty", "nonempty"}

_NUMERIC_FIELD_TYPES = {"number", "currency", "percent"}


def _resolve_field(mod: dict, name: str) -> dict:
"""The DECLARED definition of `name`, or HTTP 400. Never trust a caller-supplied field name."""
if name in SYSTEM_COLUMNS:
return {"name": name, "type": "text", "_system": True}
for f in mod.get("fields", []):
if f.get("name") == name:
return f
raise HTTPException(400, f"unknown filter/sort field {name!r} for this module")


def _field_expr(db: Session, t, mod: dict, name: str):
"""A comparable SQL expression for a declared field — cast to float when the field is numeric."""
f = _resolve_field(mod, name)
if f.get("_system"):
return t.c[name], f
expr = _json_text(db, t.c.data, name)

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. Bad system columns allowlist 🐞 Bug ☼ Reliability

SYSTEM_COLUMNS includes updated_at and ball_in_court, but _field_expr() treats these as real
SQL columns (t.c[name]), which will raise at runtime because the module tables don’t define them.
A request like ?sort=updated_at or ?f.updated_at.gte=... can therefore produce a server 500
instead of a controlled 400.
Agent Prompt
## Issue description
`SYSTEM_COLUMNS` contains names that are not actual columns in the module tables (`updated_at`, `ball_in_court`). Because `_field_expr()` returns `t.c[name]` for any name in `SYSTEM_COLUMNS`, these inputs can crash the request path instead of returning a 400.

## Issue Context
Module table columns are defined in `modules_registry._table()`. It uses `modified_at` (not `updated_at`) and does not include `ball_in_court`.

## Fix Focus Areas
- services/api/src/aec_api/modules_query.py[87-112]
- services/api/src/aec_api/modules_registry.py[55-81]

## Suggested fix
- Remove non-existent names from `SYSTEM_COLUMNS` (e.g., drop `ball_in_court`, change `updated_at` -> `modified_at` if you intend to expose it).
- Add a guard in `_field_expr()` for system columns: if `name` is not present in `t.c`, raise `HTTPException(400, ...)` rather than dereferencing `t.c[name]`.
- Consider keeping the allowlist derived from/validated against the table schema to prevent future drift.

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

Comment on lines 185 to +193
stmt = stmt.where(_search_filter(db, t, q))
if _is_postgres(db) and (tsq := _pg_tsquery(q)):
stmt = stmt.order_by(func.ts_rank(_pg_document(t), func.to_tsquery("english", tsq)).desc())
# MOD-FILTER — per-field narrowing, applied in SQL BEFORE the limit for the same reason `q` is:
# filtering a page after fetching it answers a different question than the user asked, and looks
# identical to the right answer.
if filters:
stmt = _apply_filters(db, stmt, t, REGISTRY.get(key) or {}, filters)
stmt, _explicit_sort = _apply_sort(db, stmt, t, REGISTRY.get(key) or {}, sort, sort_dir)

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

3. Search overrides explicit sort 🐞 Bug ≡ Correctness

On Postgres, list_records() applies ORDER BY ts_rank(...) DESC when q is present, then
_apply_sort() adds another order_by which becomes secondary rather than primary. This means
sort=<field> will not actually be the primary ordering for search results on Postgres (it only
breaks ties).
Agent Prompt
## Issue description
When `q` is present on Postgres, `list_records()` adds a relevance `ORDER BY` first, and the new `_apply_sort()` appends the requested sort afterwards. That makes explicit sort secondary during search.

## Issue Context
SQLAlchemy Core’s `order_by()` appends additional ordering criteria by default; it doesn’t replace prior orderings unless cleared.

## Fix Focus Areas
- services/api/src/aec_api/modules_query.py[182-197]

## Suggested fix
Decide on intended behavior and implement explicitly:
- If explicit `sort` should override relevance ordering, clear existing ordering before applying sort (e.g., `stmt = stmt.order_by(None)` when `sort` is provided, then apply `_apply_sort()`), then append `created_at` tiebreak.
- If you intentionally want relevance-first even with explicit sort, document that contract and consider renaming parameter/behavior accordingly to avoid surprising callers.

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

Comment on lines +32 to +33
const MODULES_DIR = resolve(__dirname, "../../../../services/api/modules");
const SCHEMA_PY = resolve(__dirname, "../../../../services/api/src/aec_api/module_schema.py");

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

4. Esm test uses __dirname 🐞 Bug ☼ Reliability

fieldTypeCoverage.test.ts uses __dirname, but apps/web is configured as ESM (`"type":
"module", TS module: "ESNext"), where __dirname is undefined. This can throw ReferenceError:
__dirname is not defined` before any tests execute.
Agent Prompt
## Issue description
The new test file uses `__dirname` to locate repo files. In this package, Node runs in ESM mode, so `__dirname` is not available and the test module can crash on import.

## Issue Context
`apps/web/package.json` sets `type: module` and `apps/web/tsconfig.json` uses `module: ESNext`.

## Fix Focus Areas
- apps/web/src/portal/fieldTypeCoverage.test.ts[32-34]
- apps/web/package.json[1-8]
- apps/web/tsconfig.json[1-18]

## Suggested fix
Replace `__dirname` usage with an ESM-safe equivalent:
- `import { fileURLToPath } from "node:url";`
- `import { dirname, resolve } from "node:path";`
- `const here = dirname(fileURLToPath(import.meta.url));`
- `const MODULES_DIR = resolve(here, "../../../../services/api/modules");`
(and same for `SCHEMA_PY`).

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

ibuilder and others added 2 commits July 30, 2026 06:12
… every register

A register could be narrowed by exactly two things: the full-text `q` and `workflow_state`. On a
twenty-field register that means no filtering by discipline, vendor, cost code or date range —
and sorting ran in the BROWSER over whichever page had been fetched, so "sort by amount" on a
500-row register ordered 200 rows and presented the top of those as the largest values in the
register. Nothing looked wrong; it was the wrong 200 rows.

Filtering and ordering now happen in SQL, before the LIMIT.

  ?f.discipline=Structural&f.amount.gte=1000&f.amount.lte=5000&sort=amount&sort_dir=desc

Operators: eq · ne · gte · lte · contains · in · empty · nonempty. Filters compose with `state`
and `q`, are bounded at 12 per request, and `count_records` takes them too — a total that
ignores a filter the list applied is the number a user trusts without checking.

TWO PROPERTIES CARRY THE DESIGN.

**A field name is never taken from the caller.** `_resolve_field` looks it up in the module's
declared fields and raises 400 otherwise. This is not hygiene: `_json_text` interpolates the key
into a SQLite JSON path, and its docstring already said the key must be module-defined — this is
what enforces it. An unknown field, an unknown operator and a crafted name are all refused.
Refusing rather than ignoring also matters on its own: a silently dropped filter returns MORE
rows than were asked for, which reads as data rather than as a bug.

**A number is compared as a number.** JSON extraction yields text, and text comparison puts 9
above 10 and sorts 1000 before 900 — a plausible wrong answer rather than an error. Numeric
fields are cast to float; dates stay text because ISO-8601 already sorts correctly.

THE TEST FOR THAT SECOND PROPERTY WAS WRONG FIRST, AND THE MUTATION CAUGHT IT. The fixtures
posted ints, and the cast could be DELETED with every assertion still passing. SQLite types
`json_extract` by what was stored: a JSON number returns `integer` and compares numerically
without help, so an int fixture exercises a path the cast does not affect. A JSON *string*
returns `text`, and `'9' >= 10` is TRUE. Strings are the NORMAL case — an HTML form posts a
string, a CSV import posts a string, and `validate_record` only checks that a numeric field
parses, it does not coerce. Fixtures re-posted as strings; the mutation now fails with `nine`
leaking into `amount >= 10`, which is the bug the test exists for.

A RANGE IS TWO CLAUSES ON ONE FIELD, so the client filter type is a LIST, not a map keyed by
field. The first version was a map, where `amount.gte` and `amount.lte` collide and the second
overwrites the first — a range silently collapsing to a single bound, i.e. a narrower result
that looks entirely correct. The UI keys its controls `<field>__<op>` for the same reason.

UI: a collapsed filter bar, one control per field chosen by type — a dropdown of the declared
options for a select, from/to for a date, min/max for a number, a record picker for a reference
(populated lazily, so six reference columns do not fire six fetches before anyone has expressed
interest in filtering), and case-insensitive `contains` for everything else. The toggle carries
the active count, because a page showing a subset while looking unfiltered is indistinguishable
from missing data.

Column-header sort now asks the server. The existing in-browser comparator is correct and stays,
but only for a column the server cannot order — `serverSortField` maps the four pseudo-columns
(ref, status -> workflow_state, assignee, title -> the module's title_field) and returns null for
anything else rather than sending a name the server would rightly reject.

Paged sorting keeps `created_at` as the last ordering key so equal values cannot swap between
pages and show a row twice or never — the classic pagination hole.

No new client methods: `moduleRecordsFiltered` gained parameters, so the api surface ratchet is
untouched. No version files or CHANGELOG — the release lane batches those.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found by Massing Core reviewing #108, and it is a regression that PR made live.

Three places asked `f.type === "number" || f.type === "currency"` and `percent` was in none
of them. That was survivable while TWO fields in the whole system were typed `percent`; #108
converted 21 more across 16 registers — every retainage, fee, overhead and contingency
percentage — and each site failed differently and quietly:

1. `EDITABLE` omitted it, so those 21 fields stopped being inline-editable overnight. A
   capability removed with no error and no visible cause.
2. The record form rendered `<input type="text">`: no numeric keypad on a phone, no step, no
   browser validation.
3. The save path skipped `Number(v)` and stored the STRING "5". `validate_record` calls
   `float(value)` so it passed, and every consumer coerces, so nothing ever failed — the
   column just accumulated a mixture of `5` and `"5"` depending on which form last touched
   the record. The quietest of the three, and the one that corrupts data rather than UX.

That third one also feeds the bug MOD-FILTER's numeric cast exists to survive: numbers stored
as text are exactly what makes a SQL comparison go lexicographic, where `'9' >= 10` is true.

Fixed as ONE idea rather than three ternaries: `NUMERIC_FIELD_TYPES` is named once and
referenced at all three sites, so the next numeric type is one edit instead of three that can
be done two-thirds of the way — which is precisely how this happened.

Also: a declared `unit` now renders BESIDE the input, not only in table cells. Half of what
declaring a unit is for is the moment somebody is typing the number. Read structurally rather
than via `ModuleField.unit`, because that property is declared on the sweep branch which is
merged to main but cannot be merged into this one right now without git overwriting two files
another session is holding dirty.

WHY IT SURVIVED, WHICH IS THE PART WORTH KEEPING. It was invisible from both directions:
`test_module_fields.py` reads config SHAPE and says nothing about whether a UI can render it,
and the web suite never built a form containing a percent field. Two green suites, one type
nothing handled.

So the new gate is a COVERAGE test, not a DOM test. Rendering a form and asserting one input
is `type="number"` would prove `percent` works and nothing about the next type; what failed
here was completeness — a hand-maintained list with no check that it covered the types in use.
`fieldTypeCoverage.test.ts` asserts the partition instead: every type the 133 shipped modules
declare is either editable or deliberately read-only, no type is in both lists, every
magnitude-holding type is treated as numeric, and nothing in `module_schema.FIELD_TYPES` is
unrenderable. `READ_ONLY_FIELD_TYPES` carries a stated reason per entry, so "not editable" is
a decision on the record rather than an omission nobody noticed.

Mutation-checked: removing `percent` from `NUMERIC_FIELD_TYPES` — the exact pre-fix state —
fails 4 of the 6 assertions, from four independent angles.

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

Copy link
Copy Markdown
Owner Author

Merging. Rebase verified and the combined-tree run is done.

Evidence, with its base SHA — integrated main + #109 + #110 in a dedicated worktree at base 23f02cb7:

  • Backend 454/455. The single failure was my own test_schema_diag, not yours: its
    no-false-positives check asserted against samples/*.ifc, which are gitignored (.gitignore:52).
    Fixed on main as 362fc2c7. Every one of your tests passed, and test_clash_xml_import and
    test_cbs — which my earlier MemoryError had taken down — both pass here.
  • tsc --noEmit clean on the combined tree. This was the assertion I most wanted after your
    JSDoc-boundary finding, and it is the one that matters: refCell (1), REF_RESOLVE_LIMIT (4),
    NUMERIC_FIELD_TYPES (3) and filterRow (1) all present in a file that compiles.
  • Web 919/920, the single failure being a harness artefact of my integration worktree — Vite
    denies node_modules reached through a junction outside the worktree root. Not code.
  • run_tests.py manifest exactly balanced: 455 test files, 455 entries, none unregistered, none
    orphaned.

One correction to my own method, since it is the same trap you flagged. My first manifest union
inserted the five new names at "the last quoted test_* in the file" — which landed them outside
the TESTS list. My whole-file regex reported 455/455 registered; the runner's own guard reported five
unregistered. A count from a different reader answers a different question. Re-done by bracket depth
and verified by exec-ing the real list.

Also worth stating: I earlier cited "web 1012/1012" from the main checkout. Both trees hold 79 test
files, so the main checkout was carrying other sessions' uncommitted tests — that number was measuring
a contaminated tree, exactly the hazard you named. The worktree figure is the honest one.

The browser-side sort finding remains the best thing in this PR: ordering the 200 rows that happened to
be fetched and presenting them as the register's largest values is invisible from the outside and wrong
in the way that matters.

origin/feat/mod-filters (carrying the duplicate 785462c5) can go once this lands — I will delete it.

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