Skip to content

MOD-TABLE — a register field can hold line items, not just a value - #111

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

MOD-TABLE — a register field can hold line items, not just a value#111
ibuilder wants to merge 1 commit into
mainfrom
feat/mod-table

Conversation

@ibuilder

Copy link
Copy Markdown
Owner

Base 42f73677, git merge-tree CLEAN as of that SHA. Item 2 of the field sweep's "not done" list.

The gap

The sweep found 22 places a list had been flattened into one textarea (bid_submission.unit_prices, daily_report.crew_by_trade, incident.witnesses), and the deeper version of the same thing: sov is one record per line with no parent document, and estimate was a single amount.

A schedule of values, a bid, an estimate and a daily manpower log are all line-item documents. The config had no way to say so — so bid_leveling.py, whose entire job is a row-per-scope-item comparison, was parsing prose for numbers a structured grid would have handed it clean.

The column vocabulary is a deliberate subset

excluded why
nested table a grid inside a grid has no sane form rendering and no sane CSV export
rollup computed from other records, which a row is not
file / signature each needs its own upload or signing flow; a repeating row multiplies it
reference a picker per row is a real feature, not a column type. Half-done it would put unresolvable ids in rows — the exact defect refCell was written to stop, one layer down

A total_column must name a column that exists and is numeric: a footer summing a text column renders a number nobody can account for, which is worse than no total.

What makes it safe to ship: the legacy string

Those 22 fields were textareas, so live records hold prose where rows are now expected.

  • Rejecting it would make every such record un-saveable the moment somebody edited an unrelated field — a config change breaking data it never touched.
  • Accepting and dropping it would be worse: it is the only copy.

So validate_record accepts a string, and the form renders it above the grid, verbatim and read-only, until the lines are entered. Mutation-checked — removing the tolerance fails the test with exactly that message.

Numeric cells store as numbers

Third place one root cause has surfaced. A number stored as text is what makes SQL comparison lexicographic ('9' >= 10 is TRUE) — the bug MOD-FILTER's cast exists to survive, and the bug the record form had for percent.

A hole closed at the point it was created

isEmpty returns false when a field has no entry in inputs, and a table is deliberately not registered there — its value is an array of objects, not an el.value string, so every consumer of inputs would read it wrongly. A required table with zero rows would have passed the client check. None of the eight shipped tables is required, so it's a hole rather than a live bug — and it's the same "plausible answer for the missing case" shape, so it's handled before the branch rather than after.

Applied to 8 fields across 5 registers

bid_submission.unit_prices + .alternates · daily_report.crew_by_trade + .equipment_on_site · incident.witnesses (OSHA 301 asks who witnessed, with employer and statement) · project_charter.key_milestones + .key_deliverables · new estimate.line_items with a G703-shaped column set.

The register table shows 3 lines · $118,260 rather than [object Object] per row — a missing branch that renders as a plausible-looking bug.

Verification

  • Backend 447/449. Both failures are environmental and pass individually: test_bim_columns passes with the correct PYTHONPATH (src;../data/src), test_scan_cache passes solo — DB contention from a concurrent web-suite run.
  • test_module_tables PASS inside the full suite, not only alone.
  • tsc --noEmit clean, re-run after the rebase — a rebase can break what a marker check cannot see, which is how MOD-FILTER — per-field filters + server-side sort · MOD-PERCENT — the form layer didn't know percent existed #109's resolution passed every grep and failed to compile.
  • Built in an isolated git worktree, so the shared checkout was never touched.

⚠️ Merge dependency

This adds table to FIELD_TYPES. #109's fieldTypeCoverage.test.ts will correctly fail this branch until table is added to READ_ONLY_FIELD_TYPES — a grid has no cell-sized inline editor. I own both branches and will sequence them.

That gate was written one PR ago to catch a new type added without a renderer classification, and it caught the very next one. Worth merging #109 first, then this.

🤖 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 1df4e36.


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

…just a value

The field sweep found 22 places a LIST had been flattened into one textarea
(`bid_submission.unit_prices`, `daily_report.crew_by_trade`, `incident.witnesses`), and the
deeper version of the same gap: `sov` is one record PER LINE with no parent document, and
`estimate` was a single `amount`. A schedule of values, a bid, an estimate and a daily manpower
log are all line-item documents. The config had no way to say so — so `bid_leveling.py`, whose
whole job is a row-per-scope-item comparison, was parsing prose for numbers a structured grid
would have handed it clean.

`table` fields declare `columns` and an optional `total_column`.

THE COLUMN VOCABULARY IS A DELIBERATE SUBSET, and the exclusions are the design:

  nested table   a grid inside a grid has no sane form rendering and no sane CSV export
  rollup         computed from other RECORDS, which a row is not
  file/signature each needs its own upload or signing flow; a repeating row multiplies it
  reference      a picker per row is a real feature, not a column type. Shipping it half-done
                 would put unresolvable ids in rows — the exact defect `refCell` was written
                 to stop, reintroduced one layer down

A `total_column` must name a column that exists AND is numeric: a footer summing a text column
would render a number nobody can account for, which is worse than no total.

THE PROPERTY THAT MAKES THIS SAFE TO SHIP IS THE LEGACY STRING. Those 22 fields were
textareas, so live records hold prose where rows are now expected. Rejecting it would make
every one of those records un-saveable the moment somebody edited an unrelated field on them —
a config change breaking data it never touched. Accepting it and dropping it would be worse:
it is the only copy. So `validate_record` accepts a string, and the form renders it above the
grid, verbatim and read-only, until the lines are entered by hand. Mutation-checked: removing
that tolerance fails the test with exactly that message.

Numeric cells store as NUMBERS, not the input's string. That is the third place one root cause
has now surfaced: a number stored as text is what makes SQL comparison lexicographic
('9' >= 10 is TRUE), which is the bug MOD-FILTER's cast exists to survive and the bug the
record form had for `percent` fields.

A HOLE CLOSED AT THE POINT IT WAS CREATED: `isEmpty` returns false when a field has no entry in
`inputs`, and a table is deliberately not registered there (its value is an array of objects,
not an `el.value` string, so every consumer of `inputs` would read it wrongly). A `required`
table with zero rows would therefore have passed the client-side check. None of the eight
shipped tables is required, so this is a hole rather than a live bug — and it is the same
"plausible answer for the missing case" shape that keeps costing this codebase, so it is
handled before the table branch rather than after it.

Applied to 8 fields across 5 registers, each where the artifact genuinely IS a line-item
document: bid_submission.unit_prices + .alternates (bid leveling is DEFINED as a row per scope
item), daily_report.crew_by_trade + .equipment_on_site, incident.witnesses (OSHA 301 asks who
witnessed, with employer and statement), project_charter.key_milestones + .key_deliverables,
and a new estimate.line_items with a G703-shaped column set (code, description, qty, unit,
unit cost, amount).

The register table shows "3 lines · $118,260" rather than "[object Object]" per row — a missing
branch that renders as a plausible-looking bug.

MERGE DEPENDENCY: this adds `table` to `FIELD_TYPES`, and `fieldTypeCoverage.test.ts` (in the
MOD-FILTER PR, not yet merged) asserts every legal type is either editable or deliberately
read-only. `table` belongs in READ_ONLY_FIELD_TYPES — a grid has no cell-sized inline editor.
That gate will correctly fail this branch until the two are sequenced, which is the gate doing
exactly what it was written for one PR earlier.

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

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

Copy link
Copy Markdown
Owner Author

Rebased onto 9b226f56 (post-#109/#110), and table is now classified. git merge-tree CLEAN as of that SHA.

The gate did its job, and I want to record that it did

fieldTypeCoverage.test.ts was written in #109, one PR before this one, to catch a type added to FIELD_TYPES without a renderer classification. The very next PR to add a type was this one, and it failed until table was added to READ_ONLY_FIELD_TYPES with its reason — a line-item grid has no cell-sized inline editor; its value is an array of objects, the register cell shows the summary (3 lines · $118,260), and it is edited in the record form via tableEditor.

Mutation-checked: removing that one entry — the exact pre-classification state — fails 2 of the 6 assertions (every type a shipped module declares is either editable or deliberately read-only, and the server's legal types and the form's handled types have not drifted apart). The cost of a new field type is now one deliberate line saying how it renders.

The rebase, and the hunk where "keep both" would have been wrong

Two conflicts in portal.ts:

  • Hunk 1 was additive — main's filterRow/serverSortField/filterableFields against my tableEditor, zero symbol overlap. Both kept.
  • Hunk 2 was not. Both sides were the same else branch of one if-chain: main's is the improved input branch (MOD-PERCENT numeric typing plus the unit shown beside the input), while mine still carried the pre-MOD-FILTER — per-field filters + server-side sort · MOD-PERCENT — the form layer didn't know percent existed #109 version behind a new else if (f.type === "table"). Keeping both would have duplicated the else and reverted the percent fix. Hand-merged: my table branch, then main's else, with my stale copy dropped. Verified absent — grep -c 'f.type === "number" || f.type === "currency") ? "number"' → 0.

And the JSDoc-boundary break happened again, exactly as expected: every marker grep passed (tableEditor 1, filterRow 1, refCell 1, READ_ONLY_FIELD_TYPES 2) and tsc reported TS1005: '(' expected — a /** opener consumed into context. Resolve, then compile. One line to fix.

Manifest

Took main's side and inserted inside TESTS: 455 → 456, test_module_tables present, _manifest_guard() reports none unregistered. I read main's count by executing git show origin/main:services/api/run_tests.py and cross-checked it against the unique test_* literals in that same object — both say 455, so the number is from main rather than from a worktree.

Verification after the rebase

  • tsc --noEmit clean · src/portal/ + src/shell/ + surface.test.ts 319/319 · fieldTypeCoverage 6/6
  • Backend: test_module_tables, test_module_config, test_module_filters, test_module_fields all PASS · ruff clean

Ready for your merge order (#111#112#113). #112 and #113 will need the same manifest treatment; both are mine and I'll rebase them as they come up.

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