Skip to content

MOD-SWEEP — field-level sweep of all 133 registers - #108

Merged
ibuilder merged 1 commit into
mainfrom
feat/mod-sweep-fields
Jul 30, 2026
Merged

MOD-SWEEP — field-level sweep of all 133 registers#108
ibuilder merged 1 commit into
mainfrom
feat/mod-sweep-fields

Conversation

@ibuilder

Copy link
Copy Markdown
Owner

Field-level sweep of every module: fields, fieldsets, relationships, and CRUD affordances. Full analysis in docs/internal/module-field-sweep.md.

Two live defects on main, and the first gates everything else

A reference column resolved ids against a bounded fetch, then fell back to String(v).slice(0, 8) as a clickable link. Three unrelated situations took that path and all three were indistinguishable from a resolved reference:

  1. the target record was deleted since it was referenced;
  2. the target lies past the 500-record resolve bound;
  3. the value is legacy free text.

Eight characters is the specific harm — short enough to look like an id, long enough to look deliberate. The same truncation existed a second time in fmtCell, on the path taken when a reference is not a resolved column.

refCell now separates the three, because they ask different things of the reader: resolved → the link · a UUID absent from the map → the short id, not a link, marked unresolved · not an id at all → the value verbatim at full length, marked as unlinked text, because that string is the only handle anyone has for re-linking it.

Relationships — 74 strings that name a register

74 text fields named a concept that already had its own register (vendor, location, spec_section, system, inspector, bidder, tenant, carrier), while only 98 reference fields existed across 133 modules and 69 modules had none at all.

None is converted in place. Converting one would have turned "Acme Electrical Inc" into a link reading Acme Ele that opens nothing, across 56 modules, with every gate still green — which is why the renderer fix had to land first. The codebase already held the safe pattern in three places (investor+investor_company, lease+tenant_company, subcontract+vendor_company), so this follows that precedent: 54 references added beside their text twins, each adjacent and sharing its fieldset, both asserted.

20 candidates were rejected and the reasons are the useful part: an AHJ is not a project company (permit.authority); a manufacturer is not a project party; risk.owner is a person and whether that means contact or company genuinely differs per module; and every plural (photos, deficiencies, spec_sections) is a child list needing the table type, where a reference would be wrong in a way that looks right.

Four automated inferences were wrong, and reading the output caught them

capital_plan.planned_year, fca_element.recommended_year and market_assumption.construction_start_year are calendar years, not durations — the suffix rule labelled all three yr. And prime_contract.ld_per_day is dollars per day. The gate now names those three as unit-less so a future bulk pass cannot re-lose the distinction.

Result

before after
reference fields 98 152
modules with no reference at all 69 49
percent-typed fields 2 22
fields declaring a unit 0 67
registers with fewer than 3 columns 55 17
registers surfacing a reference as a column 2 32

list_columns was widened append-only — the first pass chose by type priority and silently dropped human-chosen columns like bid_package.trade, so it was redone to preserve every existing column.

Reviewing this diff

Roughly 900 of the 1771 added lines are formatting. The edits were applied programmatically, so the 90 touched module.json files are normalised to the expanded one-key-per-line style already used by the majority (rfi, coi, submittal). Eight further files whose only change was that reformatting have been reverted — a diff with no semantic content is noise a reviewer has to read.

test_module_fields.py holds each figure as a floor, not an equality: adding a module never requires editing it, undoing the work fails it.

Verification

  • Backend 447/447 (TESTS length 447, test_module_fields registered, manifest guard clean)
  • Web 908/908, tsc --noEmit clean, ruff + eslint clean
  • Base is current main (3f376fff, v0.3.804)

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

Not done, listed rather than half-built

  1. Per-field filters and server-side sort — filtering is q + state only, so on a 20-field register you cannot filter by discipline, vendor, cost code or date range. An API change, not a config change, and the largest remaining CRUD gap.
  2. The table field type — still the blocker for 22 list-in-a-textarea fields and for sov/estimate/bid_submission being documents rather than rows.
  3. Backfilling the 54 new reference fields from their text twins — needs a matcher and review; a wrong auto-link is worse than an empty one.
  4. eticket → the rate libraries. Reference material for this sweep was eManagerNYC/django_emanager, whose ticket composes labor/material/equipment rates into totals; eticket has six fields and links to no rate library.

🤖 Generated with Claude Code

The field vocabulary in use across 1171 fields was eleven attributes long — name, label,
type, fieldset, options, required, module, source_module, source_field, op, help — every one
structural or presentational. Nothing said what a number MEASURED, nothing distinguished a
percentage from a count, and half the concepts that already had their own register were held
as loose strings. Units lived in field NAMES (elevation_ft, expected_life_years, ld_per_day),
where only a human can read them: a name cannot be rendered beside an input, appended to a
cell, converted, or checked.

TWO LIVE DEFECTS IN THE REFERENCE RENDERER, and the first gates everything else.

A reference column resolved ids against a bounded fetch, then fell back to
`String(v).slice(0, 8)` AS A CLICKABLE LINK. Three unrelated situations took that path and all
three were indistinguishable from a resolved reference: the target was deleted, the target
lies past the 500-record resolve bound, or the value is legacy free text. Eight characters is
the specific harm — short enough to look like an id, long enough to look deliberate. The same
truncation existed a second time in `fmtCell`, on the path taken when a reference is not a
resolved column.

`refCell` now separates the three, because they ask different things of the reader: resolved
-> the link; a UUID absent from the map -> the short id, NOT a link, marked unresolved; not an
id at all -> the value verbatim at full length, marked as unlinked text, because that string
is the only handle anyone has for re-linking it. The bound is now a named constant, since it
is a correctness boundary rather than a tuning parameter.

RELATIONSHIPS — 74 text fields named a concept that already had its own register (vendor,
location, spec_section, system, inspector, bidder, tenant, carrier). None is converted in
place. Converting one would have turned "Acme Electrical Inc" into a link reading "Acme Ele"
that opens nothing, across 56 modules, with every gate still green — which is precisely why
the renderer fix had to land first. The codebase already held the safe pattern in three places
(investor+investor_company, lease+tenant_company, subcontract+vendor_company), so this follows
that precedent rather than inventing one: 54 references added BESIDE their text twins, each
adjacent and sharing its fieldset, both asserted — a pair rendered in two different places is
a pair nobody recognises as a pair.

20 candidates were rejected and the reasons are the useful part: an AHJ is not a project
company (permit.authority, entitlement.agency); a manufacturer is not a project party;
risk.owner is a PERSON, and whether that means contact or company genuinely differs per
module; and every plural (photos, deficiencies, spec_sections, assumptions) is a child LIST
needing the table type, where a reference would be wrong in a way that looks right.

FOUR AUTOMATED INFERENCES WERE WRONG AND READING THE OUTPUT CAUGHT THEM.
capital_plan.planned_year, fca_element.recommended_year and
market_assumption.construction_start_year are calendar years, not durations — the suffix rule
labelled all three "yr". prime_contract.ld_per_day is dollars PER day. The gate now names
those three as unit-less so a future bulk pass cannot re-lose the distinction.

  reference fields                   98 -> 152      registers with <3 columns   55 -> 17
  modules with no reference at all   69 -> 49       a reference AS a column      2 -> 32
  percent-typed fields                2 -> 22       fields declaring a unit      0 -> 67

list_columns was widened APPEND-ONLY. The first pass chose by type priority and silently
dropped human-chosen columns such as bid_package.trade; it was redone to preserve every
existing column and append to it.

FORMATTING, STATED RATHER THAN SMUGGLED: the edits were applied programmatically, so the 90
touched module.json files are normalised to the expanded one-key-per-line style already used
by the majority (rfi, coi, submittal). That accounts for roughly 900 of the 1366 added lines.
Eight further files whose ONLY change was that reformatting have been reverted — a diff with
no semantic content is noise a reviewer has to read.

test_module_fields.py holds each figure as a FLOOR, not an equality: adding a module never
requires editing it, undoing the work fails it. Analysis in
docs/internal/module-field-sweep.md — internal, because docs/ is the Pages web root and an
earlier audit of mine was published from it for two releases before another session caught it.

Reference material: eManagerNYC/django_emanager, a Django CM app the user built previously.
Its value is the structural contrast — ForeignKeys throughout where these modules used strings
— plus four ideas still missing here, chief among them that its `ticket` composes the
labor/material/equipment rate libraries into totals, while `eticket` has six fields and links
to no rate library at all.

Not done, and listed in the doc rather than half-built: per-field filters and server-side sort
(filtering is q+state only), the table field type, backfilling the 54 new references, and
eticket -> the rate libraries.

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 265be0f.


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

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

MOD-SWEEP: add units/percent types, safer reference cells, and module field ratchets

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

Grey Divider

AI Description

• Fix reference cells so unresolved UUIDs and legacy text never render as fake links.
• Extend module field schema with unit and enforce percent/units correctness in validation.
• Sweep module configs to add linked reference twins, units, percent types, and wider list columns.
• Add ratchet tests + internal writeup documenting findings and guarding the new floors.
Diagram

graph TD
  A["Module JSON configs"] --> B["API: module_schema.py"] --> C["Gate: test_module_fields.py"]
  A --> D["Web: PortalUI table"] --> E["API: moduleRecordsFiltered"] --> F["Client refMaps (id→label)"] --> G["refCell + fmtCell"] --> H["CSS ref states"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Unbounded or higher-limit reference resolution
  • ➕ Simpler UI (fewer unresolved cases) for small/medium projects
  • ➖ Can blow up list-page performance by pulling huge registers
  • ➖ Still fails on deleted targets and legacy text; doesn’t address fake-link semantics
2. Server-side reference label resolver (batch by ids)
  • ➕ Correctly resolves only ids present on the page
  • ➕ Avoids global bounded fetch and scales to large registers
  • ➖ Requires new API surface + indexing considerations
  • ➖ More moving parts than this sweep; not necessary to fix the current correctness bug
3. In-place conversion of text→reference with backfill
  • ➕ Cleaner schema long-term (single field per concept)
  • ➖ High risk without a vetted matcher; wrong auto-links are worse than blanks
  • ➖ Legacy values would have rendered misleadingly before the renderer fix; still needs staged migration

Recommendation: Current approach is the right sequencing: fix the renderer first so unresolved/unlinked values are honest, then add reference fields additively beside existing text to avoid breaking legacy data. Consider a future server-side batch resolver if reference columns become common on very large registers, but it’s orthogonal to this correctness fix.

Files changed (98) +1771 / -256

Enhancement (2) +25 / -3
types.tsAdd 'unit' to ModuleField schema +8/-0

Add 'unit' to ModuleField schema

• Extends the client ModuleField type with an optional 'unit' string for numeric magnitudes. Includes rationale and context from the sweep so UI formatting can rely on declared units.

apps/web/src/api/types.ts

module_schema.pySupport 'unit' and enforce unit/percent invariants in module validation +17/-3

Support 'unit' and enforce unit/percent invariants in module validation

• Adds 'unit' to FieldDef, defines numeric-type set early for module validation, rejects units on non-numeric fields, and errors when percentage-named fields are typed 'number'. Moves numeric-type constant to support validate_module ordering.

services/api/src/aec_api/module_schema.py

Bug fix (2) +93 / -12
portal.tsMake reference cells truthful; format percent and units +84/-12

Make reference cells truthful; format percent and units

• Introduces REF_RESOLVE_LIMIT and UUID detection, replaces inline reference rendering with a dedicated 'refCell' that distinguishes resolved links from unresolved UUIDs and legacy free text. Fixes the secondary 8-char truncation path in 'fmtCell', adds percent formatting, and appends units when present.

apps/web/src/portal/portal.ts

style.cssStyle unresolved/unlinked references as non-links +9/-0

Style unresolved/unlinked references as non-links

• Adds '.ref-unresolved' and '.ref-unlinked' styles to visually indicate non-navigable reference failures without using link affordances. Keeps presentation intentionally quiet while preventing fake-link cues.

apps/web/src/style.css

Tests (2) +127 / -1
run_tests.pyWire new module-field ratchet test into the gate +1/-1

Wire new module-field ratchet test into the gate

• Adds 'test_module_fields' to the explicit test list so the new sweep invariants run in CI/gate.

services/api/run_tests.py

test_module_fields.pyAdd MOD-SWEEP ratchet tests for field semantics and relationships +126/-0

Add MOD-SWEEP ratchet tests for field semantics and relationships

• Introduces a new test suite asserting floors for percent typing, units-on-numeric-only, minimum list_columns width, surfacing references in list columns, and additive text+reference adjacency + shared fieldset rules. Also asserts specific calendar-year exceptions must carry no unit.

services/api/test_module_fields.py

Documentation (2) +160 / -0
README.mdIndex the new module field sweep report +1/-0

Index the new module field sweep report

• Adds 'module-field-sweep.md' to internal docs index with scope and asserted invariants. Documents that its numbers are enforced as floors by 'test_module_fields.py'.

docs/internal/README.md

module-field-sweep.mdDocument full field-level audit and decisions +159/-0

Document full field-level audit and decisions

• Adds a detailed internal report covering field vocabulary gaps, reference renderer defects, relationship conversion strategy, units/percent typing, CRUD/table column widening, and explicit non-goals. Serves as the narrative companion to the new ratchet tests.

docs/internal/module-field-sweep.md

Other (90) +1366 / -240
module.jsonAdd linked responsible contact; widen list columns +8/-1

Add linked responsible contact; widen list columns

• Adds 'responsible_contact' reference alongside text 'responsible' and expands list columns to include 'meeting'. Keeps the additive text+reference migration pattern.

services/api/modules/action_item/module.json

module.jsonWiden list columns for better scanability +3/-1

Widen list columns for better scanability

• Expands list_columns to include 'drawing' and 'format' so rows show more than status/discipline at a glance.

services/api/modules/as_built/module.json

module.jsonSurface related RFI in the table view +2/-1

Surface related RFI in the table view

• Adds 'rfi' to list_columns to expose the relationship in the register list.

services/api/modules/asi/module.json

module.jsonAdd linked location and declare numeric units +11/-2

Add linked location and declare numeric units

• Adds 'location_loc' reference next to text location and declares units for 'expected_life_years' (yr) and 'elevation_ft' (ft).

services/api/modules/asset_register/module.json

module.jsonWiden bid package list columns +3/-1

Widen bid package list columns

• Appends 'cost_code' and 'discipline' to list_columns to improve at-a-glance identification.

services/api/modules/bid_package/module.json

module.jsonSurface package and due date in list view +3/-1

Surface package and due date in list view

• Adds 'package' and 'due_date' to list_columns so solicitations can be triaged from the table.

services/api/modules/bid_solicitation/module.json

module.jsonAdd linked bidder company reference +7/-0

Add linked bidder company reference

• Adds 'bidder_company' reference field adjacent to the required bidder text field within the same fieldset.

services/api/modules/bid_submission/module.json

module.jsonWiden budget list columns +3/-1

Widen budget list columns

• Adds 'budget' and 'forecast' to list_columns to make the register useful without opening each row.

services/api/modules/budget/module.json

module.jsonDeclare schedule-impact units and widen list columns +5/-2

Declare schedule-impact units and widen list columns

• Adds 'unit: days' to 'schedule_impact_days' and expands list_columns to include 'source_rfi' and 'reason'.

services/api/modules/change_event/module.json

module.jsonAdd linked location; adjust list columns +6/-1

Add linked location; adjust list columns

• Adds 'location_loc' reference alongside location text and simplifies list_columns to avoid overly thin/duplicative display.

services/api/modules/checklist/module.json

module.jsonAdd linked location reference +7/-0

Add linked location reference

• Adds 'location_loc' reference for site location/zone in the Hazard fieldset to support relationship navigation.

services/api/modules/climate_site_risk/module.json

module.jsonAdd linked vendor/carrier; widen list columns +17/-1

Add linked vendor/carrier; widen list columns

• Adds 'vendor_company' and 'carrier_company' references alongside existing text fields and expands list_columns to include key policy fields.

services/api/modules/coi/module.json

module.jsonAdd linked system reference; widen list columns +10/-1

Add linked system reference; widen list columns

• Adds 'system_system' reference beside system text and expands list_columns to include 'asset' and 'test_type'.

services/api/modules/commissioning/module.json

module.jsonAdd vendor reference, percent typing, and list columns +16/-2

Add vendor reference, percent typing, and list columns

• Adds 'vendor_company' reference, retypes retainage to 'percent' with unit, and defines a usable list_columns set for the register.

services/api/modules/commitment/module.json

module.jsonDeclare units for numeric magnitudes +6/-3

Declare units for numeric magnitudes

• Adds units for $/sf and distance fields so numbers can be rendered with explicit meaning.

services/api/modules/comparable/module.json

module.jsonEnsure percent carries unit; widen list columns +5/-2

Ensure percent carries unit; widen list columns

• Adds unit '%' for 'punch_complete_pct' and expands list_columns to include occupancy and punch status context.

services/api/modules/completion_certificate/module.json

module.jsonAdd linked responsible contact and normalize formatting +123/-20

Add linked responsible contact and normalize formatting

• Adds 'responsible_contact' reference beside the responsible text field and reformats workflow/list_columns for clarity without changing semantics.

services/api/modules/compliance_evidence/module.json

module.jsonAdd linked location and widen list columns +9/-1

Add linked location and widen list columns

• Adds 'location_loc' reference field and expands list_columns to include meeting and clash count for better triage.

services/api/modules/coordination_issue/module.json

module.jsonDeclare schedule units and add list columns +9/-2

Declare schedule units and add list columns

• Adds 'unit: days' to schedule field and defines list_columns to surface key commercial context in the table.

services/api/modules/cor/module.json

module.jsonDeclare temperature units +2/-1

Declare temperature units

• Adds 'unit: °F' to the temperature numeric field so it renders with explicit measurement meaning.

services/api/modules/daily_report/module.json

module.jsonDeclare schedule-impact units +2/-1

Declare schedule-impact units

• Adds 'unit: days' to schedule_impact_days to make the magnitude self-describing.

services/api/modules/decision/module.json

module.jsonAdd linked location reference +6/-0

Add linked location reference

• Adds 'location_loc' reference alongside location text so deficiencies can link to the location register.

services/api/modules/deficiency/module.json

module.jsonNormalize icon encoding; add units and percent types +9/-6

Normalize icon encoding; add units and percent types

• Updates icon literal, adds unit 'sf' for area, and retypes efficiency/IRR to 'percent' with unit '%' for correct rendering.

services/api/modules/design_option/module.json

module.jsonWiden list columns to include drawing +2/-1

Widen list columns to include drawing

• Adds 'drawing' to list_columns so reviews are identifiable from the register list.

services/api/modules/design_review/module.json

module.jsonAdd linked vendor company reference +6/-0

Add linked vendor company reference

• Adds 'vendor_company' reference beside vendor text to enable linking without breaking legacy strings.

services/api/modules/direct_cost/module.json

module.jsonAdd list columns for directive register +7/-1

Add list columns for directive register

• Defines list_columns to surface change-event linkage and commercial context in the list view.

services/api/modules/directive/module.json

module.jsonDeclare area and rainfall units +4/-2

Declare area and rainfall units

• Adds unit 'sf' for area and unit 'in' for rainfall depth numeric fields.

services/api/modules/drainage_area/module.json

module.jsonAdd linked location reference +7/-0

Add linked location reference

• Adds 'location_loc' reference under Reading fieldset to turn location strings into navigable relationships.

services/api/modules/environmental_monitoring/module.json

module.jsonAdd list columns for equipment log +6/-1

Add list columns for equipment log

• Defines list_columns to include daily report, date, and hours for usable list scanning.

services/api/modules/equipment_log/module.json

module.jsonWiden estimate list columns +2/-1

Widen estimate list columns

• Adds 'basis' to list_columns so estimates show rationale/context in the table.

services/api/modules/estimate/module.json

module.jsonAdd list columns for eTicket register +7/-1

Add list columns for eTicket register

• Defines list_columns to surface change_event, work_date, and totals in the list view.

services/api/modules/eticket/module.json

module.jsonRetype percent_complete as percent with unit +3/-2

Retype percent_complete as percent with unit

• Converts percent_complete from 'number' to 'percent' and adds unit '%' to prevent ambiguous rendering/formatting.

services/api/modules/evm_snapshot/module.json

module.jsonAdd linked location and expected-life units +9/-1

Add linked location and expected-life units

• Adds 'location_loc' reference and declares unit 'yr' for expected life numeric magnitude.

services/api/modules/fca_element/module.json

module.jsonDeclare deviation units; normalize formatting +122/-17

Declare deviation units; normalize formatting

• Adds unit 'mm' to deviation magnitude and reformats fields/workflow/list_columns for readability while preserving behavior.

services/api/modules/field_verification/module.json

module.jsonDeclare elevation units +6/-3

Declare elevation units

• Adds unit 'ft' to elevation-related numeric fields for self-describing magnitudes.

services/api/modules/flood_risk/module.json

module.jsonDeclare OSHA day-count units +4/-2

Declare OSHA day-count units

• Adds unit 'days' to lost/restricted day numeric fields so the magnitude is explicit in UI rendering.

services/api/modules/incident/module.json

module.jsonWiden list columns for info requirements +3/-1

Widen list columns for info requirements

• Adds 'derives_from' and 'loin_geometry' to list_columns to expose key context in the list view.

services/api/modules/info_requirement/module.json

module.jsonAdd linked location/inspector/spec-section references +21/-0

Add linked location/inspector/spec-section references

• Adds 'location_loc', 'inspector_contact', and 'spec_section_spec' references beside existing text fields within matching fieldsets.

services/api/modules/inspection/module.json

module.jsonRetype ownership/return fields as percent with units +4/-2

Retype ownership/return fields as percent with units

• Converts percentage magnitudes from 'number' to 'percent' and adds unit '%' for correct formatting and meaning.

services/api/modules/investor/module.json

module.jsonAdd linked location reference; adjust list columns +6/-1

Add linked location reference; adjust list columns

• Adds 'location_loc' reference to support navigation and trims overly thin/low-signal list columns.

services/api/modules/issue/module.json

module.jsonAdd linked work-package/spec-section references; normalize formatting +137/-23

Add linked work-package/spec-section references; normalize formatting

• Introduces additive linked references ('work_package_package', 'spec_section_spec') beside text fields and reformats workflow/list_columns for clarity.

services/api/modules/itp/module.json

module.jsonWiden list columns to include unit +2/-1

Widen list columns to include unit

• Adds 'unit' to list_columns so rates can be interpreted correctly from the list view.

services/api/modules/labor_rate/module.json

module.jsonDeclare units and percent types for lease magnitudes +10/-5

Declare units and percent types for lease magnitudes

• Adds unit 'sf' for area, retypes escalation to 'percent' with unit, and declares units for months and $/sf magnitudes.

services/api/modules/lease/module.json

module.jsonAdd linked responsible contact reference +7/-0

Add linked responsible contact reference

• Adds 'responsible_contact' reference beside responsible text in the Tracking fieldset.

services/api/modules/leed_credit/module.json

module.jsonAdd linked vendor company; widen list columns +9/-1

Add linked vendor company; widen list columns

• Adds 'vendor_company' reference and expands list_columns to include subcontract and through_date for better scanning.

services/api/modules/lien_waiver/module.json

module.jsonDeclare $/sf unit +2/-1

Declare $/sf unit

• Adds unit '$ /sf' to price_psf to make the magnitude explicit.

services/api/modules/listing/module.json

module.jsonAdd linked company reference; widen list columns +9/-1

Add linked company reference; widen list columns

• Adds 'company_company' reference and expands list_columns to include daily report and date context.

services/api/modules/manpower_log/module.json

module.jsonRetype escalation as percent and declare duration units +100/-17

Retype escalation as percent and declare duration units

• Keeps construction_start_year as plain number (no unit), adds unit 'mo' to duration, and retypes escalation override to percent with unit '%'. Also normalizes formatting.

services/api/modules/market_assumption/module.json

module.jsonAdd list columns for material rate register +4/-1

Add list columns for material rate register

• Defines list_columns to surface 'rate' in the list view.

services/api/modules/material_rate/module.json

module.jsonAdd list columns for material requests +6/-1

Add list columns for material requests

• Defines list_columns to surface activity, needed_by, and qty in the list view.

services/api/modules/material_request/module.json

module.jsonAdd linked location; widen list columns +9/-1

Add linked location; widen list columns

• Adds 'location_loc' reference and expands list_columns to include next_meeting for improved triage.

services/api/modules/meeting/module.json

module.jsonAdd linked location reference +7/-0

Add linked location reference

• Adds 'location_loc' reference in Rating fieldset so equipment can link to location register.

services/api/modules/mep_equipment/module.json

module.jsonWiden list columns to show meter and cost +3/-1

Widen list columns to show meter and cost

• Expands list_columns to include 'meter' and 'cost' alongside consumption/date.

services/api/modules/meter_reading/module.json

module.jsonDeclare schedule units; widen list columns +5/-2

Declare schedule units; widen list columns

• Adds unit 'days' to schedule_days and expands list_columns to include direction and reason.

services/api/modules/noc/module.json

module.jsonAdd linked location reference +6/-0

Add linked location reference

• Adds 'location_loc' reference alongside location text to support navigation and consistency with other modules.

services/api/modules/observation/module.json

module.jsonAdd linked system/spec-section/responsible references; widen list columns +24/-1

Add linked system/spec-section/responsible references; widen list columns

• Adds additive references ('system_system', 'spec_section_spec', 'responsible_contact') and expands list_columns to include submittal/format context.

services/api/modules/om_manual/module.json

module.jsonAdd linked company reference +6/-0

Add linked company reference

• Adds 'company_company' reference beside company text to allow linking without breaking legacy values.

services/api/modules/orientation/module.json

module.jsonSurface prime contract in list view +2/-1

Surface prime contract in list view

• Adds 'prime_contract' to list_columns to expose the key relationship from the register list.

services/api/modules/owner_invoice/module.json

module.jsonDeclare schedule units; add list columns +9/-2

Declare schedule units; add list columns

• Adds unit 'days' to schedule impact and defines list_columns to surface origin/cost/schedule context.

services/api/modules/pco_request/module.json

module.jsonAdd linked location; widen list columns +8/-1

Add linked location; widen list columns

• Adds 'location_loc' reference and adjusts list_columns to include both taken date and record date for better sorting/triage.

services/api/modules/photo/module.json

module.jsonDeclare time units and widen list columns +7/-3

Declare time units and widen list columns

• Adds unit 'days' to frequency and unit 'hr' to estimated hours, plus expands list_columns to include asset and last_done.

services/api/modules/pm_schedule/module.json

module.jsonNormalize icon encoding; add linked company/contact references +15/-1

Normalize icon encoding; add linked company/contact references

• Updates icon literal and adds 'company_company' and 'contact_contact' references adjacent to their text counterparts in the Company fieldset.

services/api/modules/prequalification/module.json

module.jsonAdd linked vendor company; add list columns +19/-2

Add linked vendor company; add list columns

• Adds 'vendor_company' reference, normalizes select options formatting, and defines list_columns for usable register scanning.

services/api/modules/price_observation/module.json

module.jsonAdd linked owner, percent typing/units, and list columns +27/-9

Add linked owner, percent typing/units, and list columns

• Adds 'owner_company' reference, retypes markup fields to 'percent' with unit, declares 'ld_per_day' as $/day, and defines list_columns for the contract register.

services/api/modules/prime_contract/module.json

module.jsonAdd list columns for production quantities +5/-1

Add list columns for production quantities

• Defines list_columns (cost_code, quantity) to make the register list informative.

services/api/modules/production_quantity/module.json

module.jsonAdd linked location reference +7/-0

Add linked location reference

• Adds 'location_loc' reference in Entry fieldset to turn location strings into navigable relationships.

services/api/modules/productivity_log/module.json

module.jsonAdd linked sponsor contact; normalize formatting +142/-26

Add linked sponsor contact; normalize formatting

• Adds 'sponsor_contact' reference beside sponsor text and reformats fields/workflow/list_columns for readability without changing meaning.

services/api/modules/project_charter/module.json

module.jsonEnsure percent unit declared +2/-1

Ensure percent unit declared

• Adds unit '%' for design_fee_pct to keep percent magnitudes consistently self-describing.

services/api/modules/project_phase/module.json

module.jsonDeclare schedule units; widen list columns +4/-2

Declare schedule units; widen list columns

• Adds unit 'days' to schedule_days and expands list_columns to include cost_code for better list triage.

services/api/modules/proposal/module.json

module.jsonAdd linked responsible contact and duration units +9/-1

Add linked responsible contact and duration units

• Adds 'responsible_contact' reference beside responsible text and declares unit 'days' for duration_days.

services/api/modules/pull_plan_task/module.json

module.jsonAdd linked location and responsible contact references +14/-0

Add linked location and responsible contact references

• Adds 'location_loc' and 'responsible_contact' references adjacent to existing text fields within matching fieldsets.

services/api/modules/punchlist/module.json

module.jsonAdd list columns for responsibility matrix +4/-0

Add list columns for responsibility matrix

• Defines list_columns for phase/category so the register list is informative without opening rows.

services/api/modules/responsibility/module.json

module.jsonAdd linked spec section reference +7/-0

Add linked spec section reference

• Adds 'spec_section_spec' reference beside spec_section text so RFIs can link to the spec section register.

services/api/modules/rfi/module.json

module.jsonDeclare schedule units +2/-1

Declare schedule units

• Adds unit 'days' to schedule_days to make the magnitude explicit in UI rendering.

services/api/modules/risk/module.json

module.jsonAdd linked company reference; widen list columns +8/-1

Add linked company reference; widen list columns

• Adds 'company_company' reference and expands list_columns to include observation for context.

services/api/modules/safety_violation/module.json

module.jsonAdd linked location; retime percent field type +8/-1

Add linked location; retime percent field type

• Adds 'location_loc' reference in Activity fieldset and retypes '% Complete' from number to percent for correct formatting.

services/api/modules/schedule_activity/module.json

module.jsonAdd linked location reference +6/-0

Add linked location reference

• Adds 'location_loc' reference beside location text so selections can link to the location register.

services/api/modules/selection/module.json

module.jsonAdd linked company reference +6/-0

Add linked company reference

• Adds 'company_company' reference beside booked-by company text to enable navigation without breaking stored strings.

services/api/modules/site_logistics/module.json

module.jsonRetype retainage as percent and add list columns +9/-2

Retype retainage as percent and add list columns

• Converts retainage_pct to percent with unit and defines list_columns so SOV rows are meaningful in the table view.

services/api/modules/sov/module.json

module.jsonDeclare area units +2/-1

Declare area units

• Adds unit 'sf' to target area magnitude to make the number self-describing.

services/api/modules/space_program/module.json

module.jsonAdd linked responsible contractor contact reference +6/-0

Add linked responsible contractor contact reference

• Adds 'responsible_contact' reference beside responsible contractor text to support relationship navigation.

services/api/modules/spec_section/module.json

module.jsonAdd linked contact reference; normalize formatting +107/-18

Add linked contact reference; normalize formatting

• Adds 'contact_contact' reference beside contact text and reformats fields/workflow/list_columns for clarity.

services/api/modules/stakeholder/module.json

module.jsonAdd linked vendor reference and percent typing +10/-2

Add linked vendor reference and percent typing

• Adds 'vendor_company' reference and converts retainage_pct to percent with unit '%' for correct meaning/formatting.

services/api/modules/sub_invoice/module.json

module.jsonRetype retainage as percent; add list columns +10/-3

Retype retainage as percent; add list columns

• Converts retainage_pct to percent with unit '%' and defines list_columns including vendor_company to surface relationships in the table.

services/api/modules/subcontract/module.json

module.jsonAdd linked spec section; declare lead time units +9/-1

Add linked spec section; declare lead time units

• Adds 'spec_section_spec' reference and declares unit 'days' for lead_time_days numeric magnitude.

services/api/modules/submittal/module.json

module.jsonAdd linked spec section reference +6/-0

Add linked spec section reference

• Adds 'spec_section_spec' reference beside spec_section text to support navigation and consistent relationship modeling.

services/api/modules/test_record/module.json

module.jsonAdd linked vendor/contact, declare term units, and list columns +23/-2

Add linked vendor/contact, declare term units, and list columns

• Adds 'vendor_company' and 'contact_contact' references, declares unit 'yr' for term_years, and defines list_columns for better table usability.

services/api/modules/warranty/module.json

module.jsonRetype diversion to percent with unit +3/-2

Retype diversion to percent with unit

• Converts diversion_pct to percent and adds unit '%' to ensure correct formatting and interpretation.

services/api/modules/waste_diversion/module.json

module.jsonAdd linked location and labor-hour units +9/-1

Add linked location and labor-hour units

• Adds 'location_loc' reference and declares unit 'hr' for labor_hours to make the magnitude explicit.

services/api/modules/work_order/module.json

module.jsonDeclare units and percent types across zoning magnitudes +24/-12

Declare units and percent types across zoning magnitudes

• Adds units for sf/ft magnitudes and retypes percentage-like fields to 'percent' with unit '%' for correct display semantics.

services/api/modules/zoning/module.json

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

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

Context used
✅ Compliance rules (platform): 55 rules

Grey Divider


Remediation recommended

1. Unresolved UUIDs not openable 🐞 Bug ≡ Correctness
Description
PortalUI.refCell renders UUID-shaped values that are absent from the resolve map as a
non-clickable span, which blocks opening a referenced record even when the UUID is a valid record id
(e.g., when it falls outside REF_RESOLVE_LIMIT or the resolve fetch fails). The API and UI already
support opening records directly by id (openByBriefopenRecord), so this removes a functional
navigation path rather than only avoiding “fake labels”.
Code

apps/web/src/portal/portal.ts[R1811-1817]

+    const span = document.createElement("span");
+    if (UUID_RE.test(v)) {
+      span.className = "ref-unresolved";
+      span.textContent = `${v.slice(0, 8)}…`;
+      span.title = `This ${target} could not be resolved — it may have been deleted, or lie beyond `
+        + `the first ${REF_RESOLVE_LIMIT} records of ${target}. Not a working link.`;
+    } else {
Relevance

●● Moderate

No historical evidence on whether unresolved UUIDs should remain clickable/openable by id.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The table renderer now blocks clicks for UUID-shaped unresolved values, but the UI and API both
support opening records directly by id without any 500-row bound, so UUIDs beyond the client-side
resolve map can still be navigable if allowed.

apps/web/src/portal/portal.ts[1795-1825]
apps/web/src/portal/portal.ts[2681-2685]
services/api/src/aec_api/modules.py[220-225]

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

### Issue description
`refCell()` intentionally avoids rendering a link when a reference value isn’t in the id→label map, but it treats all UUID-shaped values as “not a working link”. UUID-shaped values can still be valid record ids (e.g. beyond `REF_RESOLVE_LIMIT` or when the resolve fetch fails), so making them non-clickable blocks users from navigating to a real linked record.

### Issue Context
- The UI can open records by id via `openByBrief()`/`openRecord()`.
- The API endpoint used by `openRecord()` fetches a record by `id` directly and is not bounded by the 500-record resolve limit.
- The “legacy free-text” case is real and should remain non-link; the fix should preserve that distinction while still allowing UUIDs to be opened.

### Fix Focus Areas
- apps/web/src/portal/portal.ts[1795-1825]
- apps/web/src/portal/portal.ts[2681-2685]
- services/api/src/aec_api/modules.py[220-225]

### Suggested fix
- For `UUID_RE.test(v)` branch, render an `<a>` (or a button/icon) styled with `.ref-unresolved` (so it still *reads* unresolved) but allow click to attempt `openByBrief(c.module!, v)`.
- Add error handling for failed opens (404/permission) to avoid unhandled promise rejections (toast a concise message like “Linked record not found (may be deleted)” and keep the unresolved styling/tooltip).
- Keep the non-UUID branch as plain text (unlinked).

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


2. Currency units never shown 🐞 Bug ≡ Correctness
Description
fmtCell() returns early for currency (and percent) before the generic unit append logic, so
units declared on currency fields (e.g. prime_contract.ld_per_day with unit: "$/day") are
silently ignored in table cells. Additionally, for number fields with units the renderer uses
String(v) (not locale formatting), causing inconsistent numeric display once units are introduced.
Code

apps/web/src/portal/portal.ts[R1829-1838]

    if (v == null || v === "") return "";
    if (f.type === "currency") return `$${Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
+    if (f.type === "percent") return `${Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
    if (f.type === "multiselect" && Array.isArray(v)) return (v as string[]).join(", ");
-    if (f.type === "reference") return String(v).slice(0, 8);
+    // Same defect as the old refCell fallback, in the path taken when a reference is NOT a resolved
+    // column: eight characters of a value, indistinguishable from a resolved label. A UUID at least
+    // announces itself as an id; anything else is shown as the text it is.
+    if (f.type === "reference") return UUID_RE.test(String(v)) ? `${String(v).slice(0, 8)}…` : String(v);
+    if (f.unit) return `${String(v)} ${f.unit}`;
    return String(v).slice(0, 40);
Relevance

●● Moderate

No historical evidence found about rendering units for currency/percent in fmtCell.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new UI formatting path adds unit, but the early return for currency prevents units from ever
rendering; the backend schema explicitly permits currency units and module config now sets at least
one.

apps/web/src/portal/portal.ts[1828-1838]
services/api/modules/prime_contract/module.json[107-113]
services/api/src/aec_api/module_schema.py[22-29]

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

### Issue description
`PortalUI.fmtCell()` adds support for `f.unit`, but because `currency` and `percent` return before the `if (f.unit)` branch, units on those types are never displayed. This makes newly-added units on currency fields ineffective, and number+unit values lose `toLocaleString()` formatting.

### Issue Context
- The schema explicitly allows `unit` on `currency` (`_NUMERIC_TYPES` includes `currency`).
- At least one module declares a unit on a currency field: `prime_contract.ld_per_day` has `"type": "currency"` and `"unit": "$/day"`.

### Fix Focus Areas
- apps/web/src/portal/portal.ts[1828-1838]
- services/api/modules/prime_contract/module.json[107-113]
- services/api/src/aec_api/module_schema.py[22-29]

### Suggested fix
- Refactor `fmtCell()` to compute a base formatted value first (currency/percent/number), then append `unit` if present and non-redundant.
 - Example approach:
   - `let s = ...` where currency uses `$${Number(v).toLocaleString(...)}` and number uses `Number(v).toLocaleString(...)`.
   - Append `unit` for all numeric types, but avoid double symbols:
     - If `f.type === 'percent'` and `f.unit === '%'`, don’t append.
     - For currency, consider storing units like `/day` (not `$/day`) or strip a leading `$` from `f.unit` when the `$` prefix is already in `s`.
- Keep reference/text truncation logic unchanged.

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



Informational

3. refCell() behavior lacks tests 📘 Rule violation ▣ Testability
Description
PortalUI.refCell() introduces a documented behavioral invariant (never rendering unresolved
references as clickable links) and changes reference/percent/unit formatting, but no automated test
is added to prevent regressions. This risks reintroducing the prior fake-link defect without
detection.
Code

apps/web/src/portal/portal.ts[R1772-1837]

+  /**
+   * MOD-SWEEP — a reference cell that never pretends to have resolved.
+   *
+   * The old inline version rendered EVERY non-empty reference as a clickable link labelled
+   * `String(v).slice(0, 8)`, falling back to those eight characters whenever the id was not in the
+   * resolved map. Three different values took that path and all three looked identical to a working
+   * link: a record deleted since it was referenced, a record past `REF_RESOLVE_LIMIT` in a large
+   * register, and — the one that matters for the field sweep — a **legacy free-text value** in a field
+   * that used to be `text`. Converting `coi.vendor` from text to reference would have turned
+   * "Acme Electrical Inc" into a link reading `Acme Ele` that opens nothing.
+   *
+   * So the three cases are now distinguished, because they call for different things from the user:
+   *
+   * - **resolved** → the link, labelled `REF-001 · Title`, navigating on click.
+   * - **an id we could not resolve** (UUID-shaped, absent from the map) → the short id, NOT a link,
+   *   marked as unresolved. The record may be deleted or beyond the fetch bound; either way clicking
+   *   is not the answer and offering it is a lie.
+   * - **not an id at all** → the value verbatim, full length, marked as unlinked text. This is what a
+   *   pre-conversion value looks like, and showing it whole is what lets someone re-link it by hand.
+   *
+   * Truncating to 8 characters was the specific harm in every case: it is short enough to look like an
+   * id and long enough to look deliberate.
+   */
+  private refCell(
+    v: string,
+    c: ModuleDef["fields"][number],
+    map: Map<string, { ref: string; title: string | null }> | undefined,
+  ): HTMLElement {
+    const td = document.createElement("td");
+    const info = map?.get(v);
+    const target = String(c.module ?? "record").replace(/_/g, " ");
+    if (info) {
+      const a = document.createElement("a"); a.href = "#"; a.className = "ref-link";
+      a.textContent = info.title ? `${info.ref} · ${info.title}` : info.ref;
+      a.title = `Open linked ${target} ${info.ref}`;
+      a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); this.openByBrief(c.module!, v); };
+      td.appendChild(a);
+      return td;
+    }
+    const span = document.createElement("span");
+    if (UUID_RE.test(v)) {
+      span.className = "ref-unresolved";
+      span.textContent = `${v.slice(0, 8)}…`;
+      span.title = `This ${target} could not be resolved — it may have been deleted, or lie beyond `
+        + `the first ${REF_RESOLVE_LIMIT} records of ${target}. Not a working link.`;
+    } else {
+      span.className = "ref-unlinked";
+      span.textContent = v;                        // in full: it is the only handle for re-linking
+      span.title = `Plain text, not a link to a ${target} record. Edit the field to pick the `
+        + `${target} it refers to.`;
+    }
+    td.appendChild(span);
+    return td;
+  }
+
  /** Format a field value for a compact table cell. */
  private fmtCell(f: ModuleDef["fields"][number], v: unknown): string {
    if (v == null || v === "") return "";
    if (f.type === "currency") return `$${Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
+    if (f.type === "percent") return `${Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
    if (f.type === "multiselect" && Array.isArray(v)) return (v as string[]).join(", ");
-    if (f.type === "reference") return String(v).slice(0, 8);
+    // Same defect as the old refCell fallback, in the path taken when a reference is NOT a resolved
+    // column: eight characters of a value, indistinguishable from a resolved label. A UUID at least
+    // announces itself as an id; anything else is shown as the text it is.
+    if (f.type === "reference") return UUID_RE.test(String(v)) ? `${String(v).slice(0, 8)}…` : String(v);
+    if (f.unit) return `${String(v)} ${f.unit}`;
Relevance

● Weak

PortalUI changes historically merged without tests (portal.ts edits in PRs #1, #19).

PR-#1
PR-#19

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2382689 requires newly introduced/modified behavioral invariants to be backed by at
least one automated test. The PR adds a detailed invariant comment and new branching behavior in
refCell()/fmtCell() for resolved/unresolved/unlinked references and percent/unit formatting, but
does not add any corresponding test in this change.

Rule 2382689: Critical behavioral invariants must have at least one automated test
apps/web/src/portal/portal.ts[1772-1837]

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

## Issue description
`PortalUI.refCell()` and the updated `fmtCell()` implement a critical UI correctness invariant (resolved vs unresolved UUID vs legacy text) but there is no automated test asserting these cases.

## Issue Context
This PR explicitly documents the prior defect (unresolved values rendered as clickable short-id links) and introduces new rendering states (`ref-unresolved`, `ref-unlinked`) plus new formatting branches for `percent` and `unit`. Without tests, a refactor could silently revert to the broken behavior.

## Fix Focus Areas
- apps/web/src/portal/portal.ts[1772-1837]

ⓘ 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 +1811 to +1817
const span = document.createElement("span");
if (UUID_RE.test(v)) {
span.className = "ref-unresolved";
span.textContent = `${v.slice(0, 8)}…`;
span.title = `This ${target} could not be resolved — it may have been deleted, or lie beyond `
+ `the first ${REF_RESOLVE_LIMIT} records of ${target}. Not a working link.`;
} else {

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

2. Unresolved uuids not openable 🐞 Bug ≡ Correctness

PortalUI.refCell renders UUID-shaped values that are absent from the resolve map as a
non-clickable span, which blocks opening a referenced record even when the UUID is a valid record id
(e.g., when it falls outside REF_RESOLVE_LIMIT or the resolve fetch fails). The API and UI already
support opening records directly by id (openByBriefopenRecord), so this removes a functional
navigation path rather than only avoiding “fake labels”.
Agent Prompt
### Issue description
`refCell()` intentionally avoids rendering a link when a reference value isn’t in the id→label map, but it treats all UUID-shaped values as “not a working link”. UUID-shaped values can still be valid record ids (e.g. beyond `REF_RESOLVE_LIMIT` or when the resolve fetch fails), so making them non-clickable blocks users from navigating to a real linked record.

### Issue Context
- The UI can open records by id via `openByBrief()`/`openRecord()`.
- The API endpoint used by `openRecord()` fetches a record by `id` directly and is not bounded by the 500-record resolve limit.
- The “legacy free-text” case is real and should remain non-link; the fix should preserve that distinction while still allowing UUIDs to be opened.

### Fix Focus Areas
- apps/web/src/portal/portal.ts[1795-1825]
- apps/web/src/portal/portal.ts[2681-2685]
- services/api/src/aec_api/modules.py[220-225]

### Suggested fix
- For `UUID_RE.test(v)` branch, render an `<a>` (or a button/icon) styled with `.ref-unresolved` (so it still *reads* unresolved) but allow click to attempt `openByBrief(c.module!, v)`.
- Add error handling for failed opens (404/permission) to avoid unhandled promise rejections (toast a concise message like “Linked record not found (may be deleted)” and keep the unresolved styling/tooltip).
- Keep the non-UUID branch as plain text (unlinked).

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

Comment on lines 1829 to 1838
if (v == null || v === "") return "";
if (f.type === "currency") return `$${Number(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
if (f.type === "percent") return `${Number(v).toLocaleString(undefined, { maximumFractionDigits: 2 })}%`;
if (f.type === "multiselect" && Array.isArray(v)) return (v as string[]).join(", ");
if (f.type === "reference") return String(v).slice(0, 8);
// Same defect as the old refCell fallback, in the path taken when a reference is NOT a resolved
// column: eight characters of a value, indistinguishable from a resolved label. A UUID at least
// announces itself as an id; anything else is shown as the text it is.
if (f.type === "reference") return UUID_RE.test(String(v)) ? `${String(v).slice(0, 8)}…` : String(v);
if (f.unit) return `${String(v)} ${f.unit}`;
return String(v).slice(0, 40);

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. Currency units never shown 🐞 Bug ≡ Correctness

fmtCell() returns early for currency (and percent) before the generic unit append logic, so
units declared on currency fields (e.g. prime_contract.ld_per_day with unit: "$/day") are
silently ignored in table cells. Additionally, for number fields with units the renderer uses
String(v) (not locale formatting), causing inconsistent numeric display once units are introduced.
Agent Prompt
### Issue description
`PortalUI.fmtCell()` adds support for `f.unit`, but because `currency` and `percent` return before the `if (f.unit)` branch, units on those types are never displayed. This makes newly-added units on currency fields ineffective, and number+unit values lose `toLocaleString()` formatting.

### Issue Context
- The schema explicitly allows `unit` on `currency` (`_NUMERIC_TYPES` includes `currency`).
- At least one module declares a unit on a currency field: `prime_contract.ld_per_day` has `"type": "currency"` and `"unit": "$/day"`.

### Fix Focus Areas
- apps/web/src/portal/portal.ts[1828-1838]
- services/api/modules/prime_contract/module.json[107-113]
- services/api/src/aec_api/module_schema.py[22-29]

### Suggested fix
- Refactor `fmtCell()` to compute a base formatted value first (currency/percent/number), then append `unit` if present and non-redundant.
  - Example approach:
    - `let s = ...` where currency uses `$${Number(v).toLocaleString(...)}` and number uses `Number(v).toLocaleString(...)`.
    - Append `unit` for all numeric types, but avoid double symbols:
      - If `f.type === 'percent'` and `f.unit === '%'`, don’t append.
      - For currency, consider storing units like `/day` (not `$/day`) or strip a leading `$` from `f.unit` when the `$` prefix is already in `s`.
- Keep reference/text truncation logic unchanged.

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

@ibuilder

Copy link
Copy Markdown
Owner Author

Reviewed and merging. The refCell three-way split is the right fix and the reasoning is right: eight characters is precisely the harmful length, and separating resolved / unresolvable id / not an id is what makes the 54 new reference fields safe to add beside their text twins rather than in place of them. Showing the legacy text at full length is the detail that matters — it is the only handle anyone has for re-linking, and truncating it would have destroyed the data while looking tidy.

Verified, not assumed:

  • unit survives serialization. GET /modules is an allowlist — but it passes "fields": m.get("fields", []) whole, so new field keys ride through even though new module keys would be dropped. Checked because that allowlist has silently eaten a new key before.
  • The percent conversion is semantically correct. All 21 fields store whole percent, confirmed against consumers rather than inferred from the _pct suffix: payapp.py:49 divides by 100, capital.py:33 multiplies by 100 to produce the value, proforma.py:899 defaults to 5.0.
  • No new reference field is required: true, so no existing record is invalidated.
  • The pct-in-name to must-be-percent schema rule is a ratchet, not an allowlist. That is the right shape, and the reason given for widening the pattern past a suffix (percent_complete) is the right reason.
  • Path-disjoint from main: main's one extra commit (2851aa65) touches only docs/roadmap.md, which this branch does not. Overlap is empty, so the branch's 447/447 + 908/908 is the merged-tree result — no re-run needed to know that. Worth noting the diff against origin/main reads as if this PR deletes 80 roadmap lines. It does not; that is main's R31 ring absent from the branch. Diffed against the merge-base to be sure.

One real gap, which this PR amplifies 11x rather than causes

The form layer does not know percent exists. On the branch, the only occurrence of "percent" in portal.ts is in fmtCell. Three consequences, all reachable by a user:

  1. EDITABLE = ["text","textarea","number","currency","date","select","checkbox"] (line 1698) — no percent. So 21 fields across 16 registers that were inline-editable as number yesterday are read-only in inline-edit mode after this merge. A capability silently removed.
  2. Record form, line 2149: type = (f.type === "number" || f.type === "currency") ? "number" : ... : "text" — a percent field renders as a free-text input, with no numeric keyboard, no step, no browser validation.
  3. Line 2201: data[f.name] = (f.type === "number" || f.type === "currency") ? Number(v) : v — the value is saved as the string "5", not 5. validate_record does float(value) so it passes, and every consumer coerces, so nothing breaks loudly. It just means retainage_pct, fee_pct, contingency_pct and ownership_pct now hold a mix of numbers and strings in one JSON column depending on which form last touched them.

This is pre-existing — percent was already a legal type — but it applied to 2 fields before this PR and applies to 22 after, and the newly-included ones are the money fields. So the fix has to travel with the conversion.

Neither test_module_fields.py nor the web suite can reach any of the three. The backend gate reads config shape; the web tests never build a record form containing a percent field. This is the usual shape here: the gate proves the config is right and says nothing about whether the UI can render it. Same for unit — 67 fields now declare one, and it appears in table cells only, never beside an input, which is half of what this PR argues a declared unit is for.

I am taking the fix in the release lane rather than asking for a branch revision. portal.ts is Lane A and is currently held dirty by the mod-filters session, so it needs one hand and that hand is already in the file. Also flagging to them that this merge lands portal.ts changes they will need to rebase onto, and that item 1 of the "not done" list (per-field filters + server-side sort) is what they are building right now — so that one is in flight, not open.

Merging.

@ibuilder
ibuilder merged commit ec93087 into main Jul 30, 2026
10 checks passed
@ibuilder
ibuilder deleted the feat/mod-sweep-fields branch July 30, 2026 12:00
ibuilder added a commit that referenced this pull request Jul 30, 2026
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 added a commit that referenced this pull request Jul 30, 2026
…wback that admits it cannot tell

Three R22 items, all Lane C engines plus their routes (Lane G) and one register field
(Lane H). No version files. No module added — the register count stays 133/30.

--- R22-OPTION-OBJECT -------------------------------------------------------------

`design_option` carried `hard_cost` and `irr_pct` as plain typed fields with ZERO
reference to a deal, while proforma/solve.py sizes debt and returns.py computes the
levered IRR right next door. A typed 19.7% and a solved 19.7% rendered identically on
the card, and the typed one is the one that goes stale.

`option_economics.py` gives every figure a basis: `derived` (the option names a scenario
and solve() ran), `declared` (typed, used as given, LABELLED), `unlinked`, `unavailable`.

`unlinked` is the one that matters. Pricing an option from the project's other scenario
would assert that this massing was underwritten under that deal — provenance nobody
recorded. It is refused and the candidate scenarios are named instead.

A declared figure is never promoted to derived and a derived one never overwrites what
was typed, so `declared_disagrees_with_derived` can exist: a scheme whose typed IRR sits
4 points above what its own scenario solves to is the most useful output here, and it is
only visible because both numbers are kept.

--- R22-ITP-NCR (2) ---------------------------------------------------------------

The registers were never the gap and the entry's estimate was wrong in a specific
direction. Verified by reading the files: `itp` carries hold/witness points
(`point_type`, `acceptance_criteria`, planned->active->verified), `ncr` a real
open->dispositioned->closed lifecycle, and quality.py rolls all three up. But zero
GUID fields across all three modules, and turnover.py referenced none of them — so a
project could be certified substantially complete with the quality evidence never
attached to what it inspected.

NO new field and NO migration. Records attach through `element_guids`, the column every
module record already has, already routed as POST .../elements. `data.element` has one
reader in the codebase; `element_guids` has the module engine. Adding a bespoke field
would have created a second convention beside the platform's own.

The rule it enforces: an element with no quality record is `unrecorded`, never `clear`.
"0 open NCRs" across a building nobody inspected is literally true and completely
misleading. `any_attached` and `coverage_pct` separate "this building has a problem"
from "nobody has linked records yet".

--- clawback status ---------------------------------------------------------------

`lp_irr is None` means XIRR did not converge — 26% of shapes in a 729-pattern sweep —
not "no restitution owed". That case silently returned the same payload as a healthy
deal, so a GP kept money a clawback might have recovered. Now reported as
`clawback.status` = applied / not_owed / unavailable / not_requested, with the reason.

No fallback is picked, deliberately: inventing a restitution figure out of a failed
solve is how a plausible dollar amount gets cited while a missing one gets chased.
Whether a non-converging IRR is a zero-clawback case is a deal-terms decision.

--- also -------------------------------------------------------------------------

test_proforma: the CPI-fallback forecast is value-checked, not just its label. The old
assertion was `forecast_at_completion < 40_000_000`, an upper bound — flipping the
branch's `max` to `min` collapsed 27,133,231.54 to 9,000,000 (spend-to-date, i.e.
claiming a month-6-of-17 job is finished) and passed. Found by mutation testing.

Rebased onto b44ddbf and re-applied on top of #108's field sweep rather than over it —
`gross_area_sf` keeps `unit: sf`, `efficiency_pct` and `irr_pct` keep `type: percent`,
asserted before the edit was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ibuilder added a commit that referenced this pull request Jul 30, 2026
…et had gone slack again

`/procurement` moves to `api/procurement.ts` as `withProcurement`: 9 methods, 86 lines, `client.ts`
4,026 -> 3,940. Reaches only `json` on HttpCore, so no shared private helper crosses this boundary —
unlike ③ (`liveStream` had to come down) and ④ (`reviewPost` had to become protected).

The nine sat in **six separate regions** of the file (~1297, ~2152, ~2634, ~3185, ~3242, ~3252). That
is the concrete form of the roadmap's claim that the `// --- section ---` comments no longer delimit
anything: they label where a run *starts* and the file then carries on into other domains. Groups
located by the route each method calls; bodies by brace matching; each doc comment claimed by scanning
BACKWARDS to its opening `/**`, because a forward scan takes the next method's comment and a
single-line-comment assumption broke ③ outright.

## The ratchet had drifted back into slack, and only mutation showed it

Mutation-checking the extraction is routine here. This time it found something:

  M1  delete `procurementGate` (one method)   -> **6 passed**   <- should have failed
  M2  unwire the whole mixin (-9 methods)     -> failed at 689 vs 696

Losing a single method was **invisible**. The floor said `>= 696`, and the live surface had grown to
**698** across #108-#111, so -1 still cleared it. That is exactly the drift that took this number from
685 to 696 one day earlier, recurring within 24 hours — and the mechanism is now clear enough to write
down: **every merge that adds an endpoint silently converts this ratchet back into a slack floor.**
A ratchet is only a ratchet on the day it is set.

Raised to **698**, read from the gate's own `surfaceOf` rather than a probe of my own — my counter has
disagreed with it before, and a threshold from a different reader is a threshold for a different
question. Re-ran M1 against the tightened floor: **fails at 697 vs 698**, naming the number. The
comment now says the count must be re-read after any batch of merges, not only when extracting.

## The roadmap's remaining-groups list was wrong, so it is re-measured rather than edited

It named `/procurement`, `/auth` and `/elements` and omitted several larger groups. Counted on `main`
by route literals per first path segment:

  /auth 20 · /proforma 15 · /connections 11 · /drawing-set 11 · /drawings 11 · /elements 11 ·
  /models 9 · /documents 9 · /contracts 8 · /sync 7 · /pdf 7 · /mep 7

⑦ is `/auth` (20) — the one group that owns **token state** rather than only calling routes, so it
needs care. `/proforma` (15) is the larger easy one and the better next cut if ⑦ stalls.

Verified: web **1012/1012**, `tsc --noEmit` clean, eslint clean, doc + lane gates green.
No version files — batched into the next release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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