Skip to content

Let a condition read a value from a linked row - #95

Merged
GabeBolton merged 1 commit into
mainfrom
feat/85-cross-link-expressions
Aug 11, 2026
Merged

GabeBolton merged 1 commit into
mainfrom
feat/85-cross-link-expressions

Conversation

@GabeBolton

Copy link
Copy Markdown
Contributor

Delivers the cross-link read from #85. The remaining two acceptance items are split out as #93 (select_one filtering) and #94 (dropping the now-redundant ROPA columns).

article_9_condition:
  relevant: "${data_field.special_category} = 'yes'"
  required: "${data_field.special_category} = 'yes'"

The dot lives inside the braces. Before it, an element of the current form with a parameters.source_table; after it, a column of that table.

Why this is a correctness fix, not a convenience

Drafting the ROPA schema (#92) forced the register to ask "is this special category data?" when the linked data_fields row already recorded it, and "is this outside the UK/EEA?" when the linked organisation already implied it. The same fact was stored twice with nothing keeping the copies consistent — in a compliance record, the failure that costs you credibility.

The design property worth keeping

expressions.py stayed pure. evaluate() takes an optional resolve callable, and that is the only route out of the context dict. The language gained a reference form and no capability: it still cannot invent a lookup its caller did not supply, which is the reason it is a parser and not eval (#76). The database side lives in relevance.py, with db imported inside the function so a deployment using no cross-link reference never pulls it in.

Server-side recomputation follows links too — irrelevant_elements and outstanding build the resolver themselves — so what actually gets stored is decided with the same information the form showed.

Judgement calls a reviewer should check

One hop only. ${data_field.product.name} is a parse error. Two hops let one condition walk the entire schema, and each hop is a fetch on every keystroke. The tokeniser already carries the extra segments, so relaxing it is one line if a real case appears.

A reference through a soft-deleted row reads as unanswered, so a record pointing at a deleted data_fields row loses the condition rather than keeping the last known value. A deleted row is not evidence — but this is a real behaviour change if a dimension row is deleted under live records. Tracked for documentation in #94.

value_column must be id, refused at boot. Resolving a name-keyed link against id would silently read the wrong row.

Readable in required: as well as relevant: — they take one language, and in the ROPA draft nearly every element pairs the two on an identical condition.

Freshness has a seam, and it is cosmetic. An edit to the linked row in another tab is not noticed until reload; the submit-time check re-reads the row, so it is never a correctness gap.

Startup validation extended: a misspelt column of the linked table, a reference through a non-link element, a bad value_column, or an undefined source_table all fail at boot naming the form, the element and the keyword.

Verification

254 → 293 tests. Failure capability checked by 15 deliberate mutations — two of which initially passed. Chasing those removed a dead string-coercion path (SQLite's column affinity already matches '1' to 1) and added a test pinning that behaviour, because if it ever changes every cross-link condition on an edit form quietly reads as unanswered.

Not verified: never driven in a browser, and the ROPA config contains no cross-link reference yet, so the feature has not been exercised against examples/ropa/config/ at startup.

🤖 Generated with Claude Code

Advances #85: priority 1 only. A `select_one` still cannot filter its source
table, and that half is left open.

`${data_field.special_category}` resolves through a link element of the current
form to a column of the row it selects. The register can now stop asking whether
this is special category data when the linked data_fields row already records it,
and stop asking whether the recipient is outside the UK/EEA when the linked
organisations row already implies it. That was a correctness problem, not an
ergonomic one: the same fact stored twice with nothing to stop the copies
disagreeing is the failure mode a compliance record cannot survive.

expressions.py stays ignorant of the database. A LinkRef evaluates by calling a
resolver handed to evaluate(), so the language gained a reference form and no
capability - it can still read nothing its caller did not supply, which is the
whole reason this is a parser and not eval. relevance.py owns the resolver,
built from the form's own parameters: and rebuilt per evaluation, so its
per-row cache serves six conditions over one row with one query and cannot
outlive the callback that made it.

The dot lives inside the braces so `${a}.__class__` is the syntax error it has
always been - attribute access is a reference form, not an operator.

Startup validation keeps the property it had: a reference is checked against the
linked table's columns, and a misspelt column names the form and the element
rather than becoming a condition that is quietly always false. Pointing at an
element that is not a link, or at a link keyed on anything but id, is refused
too. The submit path compiles without a schema to check against and must stay
able to, so the column check is the startup one.

`required:` reads the same references as `relevant:`, because the two take one
language and a field whose relevance comes from the linked row takes its
obligation from there too.

A reference crosses at most one link. Two hops would let one form's condition
walk the whole schema, and each hop is a row fetch on every keystroke.

Backwards compatible: every existing expression parses and evaluates as before,
and a deployment with no cross-link reference never builds a resolver that runs.
254 -> 293 tests, failure capability checked by fifteen deliberate mutations
rather than assumed; two of them were missed on the first pass and the tests
that now catch them exist because of it.

Open: examples/ropa/ still asks special_category_data and transfer_outside_uk_eea
on the record. Removing those columns is a schema change to a draft this commit
does not own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@GabeBolton
GabeBolton merged commit a25ea6d into main Aug 11, 2026
2 checks passed
@GabeBolton
GabeBolton deleted the feat/85-cross-link-expressions branch August 11, 2026 20:45
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