Hidden Event
Summary
The Hidden event controls whether a property is shown on the UI for a specific record. When a hidden condition evaluates to true, the UI does not render the property, API responses mask its value to the CLR default, and a write to it is refused: an error prompt is recorded against the property and a PropertyWriteRejectedException interrupts the set. A hidden property is also implicitly read-only — it reports IsReadOnly and appears in RecordReadOnly. Multiple hidden subscribers can exist on the same property — if any condition is true, the property is hidden.
Over the API this surfaces as an ordinary business rejection — HTTP 400 carrying the prompt, the same shape a validation failure returns — not a server error. The Error prompt also blocks the save, so nothing in the request commits.
The write rejection is evaluated at assignment time, not at save time. Two consequences worth knowing:
- A value written while the property was visible stays if the record later becomes hidden — hiding never clears data.
- Within a single request that both un-hides a property and sets it (e.g. a PATCH carrying
StatusandRejectionReasontogether), the outcome depends on the order the properties are applied. Set the trigger first, or split it into two requests.
Hidden masks values but is not field-level security. The server injects RecordHidden into every $select for hidden-subscribed entity types, so every consumer's response — the Core client and third-party API callers alike — carries the CSV and serializes each hidden property as its CLR default (null for nullable properties, "" / 0 / false for non-nullable ones, the zero member for enums), with hidden navigations omitted from $expanded content. Business logic always reads the real value; masking happens at serialization only, and the stored data is never touched. Two gaps keep this short of a security boundary:
$filterand$orderbyalways evaluate the real values in SQL, so a caller can infer a hidden value by filtering on it;$applyaggregations compute over real values and skip injection entirely;- the record CSVs (
RecordHidden,RecordReadOnly) are not RBAC-filtered: every caller sees the names of hidden-subscribed properties and the per-record outcome of their conditions. Do not attach aHiddenrule to a property whose existence — or whose trigger condition — is itself sensitive to some roles.
To keep a property away from certain users entirely, use property permissions (RBAC Hide) instead. Hidden is for state-driven relevance — a field that makes no sense for the record's current state, e.g. a rejection reason on an order that isn't rejected.
How it reaches the UI
Every entity carries a virtual RecordHidden property: a comma-separated list of the property names currently hidden for that record. The client auto-includes RecordHidden in every query (like RecordReadOnly), and the server additionally injects it into any $select on a hidden-subscribed type — third-party callers get it whether they asked or not. The dynamic UI components skip rendering the listed properties, and the same CSV drives masking: the server replaces the listed properties' values with CLR defaults and drops hidden navigations before the response is written. Because every edit round-trips through the server, the CSV in each response keeps the UI's hidden state current — a status change that un-hides a field makes it appear, with its real value, without a manual refresh.
For list queries the CSV is computed inside the SQL projection — no entities are materialized, and the database evaluates the conditions per row. Queries that materialize for other reasons (single-record requests, RecordReadOnly selected — the Core client's shape) evaluate the same conditions in memory instead; the two forms always agree.
Unlike RecordReadOnly, the RecordHidden CSV always covers all properties with hidden subscribers, regardless of which properties the request selected or accessed.
Syntax
invoice.Hidden(i => i.RejectionReason)
.If(i => i.Status != InvoiceStatus.Rejected);
Fluent API
| Step | Method | Description |
|---|---|---|
| Required | .If(condition) |
Expression condition that, when true, hides the property for this record. Translated by EF into the SQL projection — keep it to property comparisons and boolean logic over the entity's own columns. |
Translatability: a condition must be something EF can turn into SQL — property comparisons,
boolean operators, arithmetic, null checks. Method calls, service access, and statement bodies are
not supported: compute such data into a persisted property first and reference that property in
the condition. Conditions that read a navigation translate into a JOIN with SQL null
semantics (o.Parent.Flag is simply false when Parent is null in SQL, while in-memory
evaluation lazy-loads) — prefer a local persisted trigger property there too. A startup validator
dry-runs every hidden-subscribed entity against every active tenant feature set and fails the
app start with an actionable error if a condition cannot translate; feature sets appearing
later via config hot-reload validate lazily and fall back to materialization with an error log
instead.
Scenarios
1. Hiding a field until it becomes relevant
[Logic]
public class InvoiceBL(Invoice.Logic invoice)
{
[RegisterLogic]
public void RejectionReasonHiddenUnlessRejected()
{
invoice.Hidden(i => i.RejectionReason)
.If(i => i.Status != InvoiceStatus.Rejected);
}
}
2. Multiple hidden conditions on the same property
A property can have multiple hidden rules. If any condition is true, the property is hidden.
[Logic]
public class InvoiceBL(Invoice.Logic invoice)
{
[RegisterLogic]
public void InternalNotesHidden()
{
invoice.Hidden(i => i.InternalNotes)
.If(i => i.IsArchived);
invoice.Hidden(i => i.InternalNotes)
.If(i => i.Status == InvoiceStatus.Draft);
}
}
3. Checking hidden state in business logic
var isHidden = invoice._State.RejectionReason.IsHidden;
if (!isHidden)
invoice.RejectionReason = reason;
Interaction with other mechanisms
| Case | Behavior |
|---|---|
| Writes to a hidden property | Rejected at assignment time — an error prompt is recorded and the set is interrupted, exactly like a read-only property; the API answers 400 with the prompt. This applies to business-logic writes too: setting a hidden property from BL throws PropertyWriteRejectedException (check _State.<Property>.IsHidden first). Not re-checked at save, so see the ordering note above. |
| Reads of a hidden property | Business logic reads the real value — masking never reaches BL, computes, or the stored data. API responses mask the value to the CLR default for every consumer (the server injects RecordHidden into each $select on hidden-subscribed types; $apply aggregations excepted). $filter/$orderby still evaluate real values. Not a security feature; use RBAC property permissions for that. |
| Hidden + read-only state | Hidden implies read-only: _State.<Property>.IsReadOnly is true while hidden, and the name is unioned into RecordReadOnly (without the accessed-properties filter, so the two CSVs agree). An explicit ReadOnly rule on the same property is simply redundant while it is hidden; the write rejection reports the more specific Hidden reason. |
| Hidden + required | Validation still fires: a hidden-but-required-and-empty property still blocks the save. Make requiredness conditional on the same state as the hiding (e.g. RejectionReason required only when Status == Rejected). |
| Navigations (references, collections) | Can be hidden. The UI hides the component, and responses omit the expanded content — the navigation key is absent (or null/[] on prompt-carrying responses). The related entities themselves are untouched and stay reachable through their own endpoints. |
| Identity properties | Guid, Title, and any [NaturalKey] property cannot be hidden — they identify the record everywhere, and a hidden required key would make a new record unsavable. Registering a hidden condition on them throws at startup. This is the same protected set RBAC enforces. Defensively, they are also never masked. |
| Methods | Not covered — RecordHidden carries property names only. Use DisableIf for method buttons. |
Performance
Hidden conditions run in three places:
- When a property is assigned (the write-rejection check): only that property's own conditions run, once per assignment. Unrelated to the query shape.
- When a record's
RecordHiddenis produced: on list queries the conditions are translated into the SQL projection — the database computes the CSV per row, no entities are materialized, and no per-row BL runs. On materialized paths (single-record requests, queries selectingRecordReadOnly— which the Core client always does), conditions run in memory for every property that has a hidden subscriber — regardless of the request's$select. This is deliberate: the CSV must be complete, or the UI would show a field BL wanted hidden just because it wasn't selected — and its value would go out unmasked. Properties without hidden subscribers cost nothing. - Wherever read-only state is evaluated (
RecordReadOnlyserialization,_State.<Property>.IsReadOnly): hidden implies read-only, so the read-only check also fires the property's hidden conditions.
Masking itself adds no meaningful cost: it rewrites already-computed values in the outgoing response using the CSV the serializer already carries. Because the SQL path evaluates conditions inside the database query, the per-row cost of broad Hidden adoption lives in the query plan, not the serialization pipeline, and third-party list traffic never materializes.
In every case the condition must be extremely fast:
- Do: Reference properties already in memory on the entity.
- Do not: Make database calls, LINQ queries, or expensive service calls. If you need data from the database or a service, compute it once into a property and reference that property in the hidden condition.
- Careful with navigations: in SQL a navigation access becomes a JOIN per condition; on materialized paths, reading a parent or referenced entity that is not already loaded triggers a lazy load — one query per row on a list. If a condition needs a value from a related entity,
Computeit onto this entity and test the local property instead.
Notes
- The hidden state is tracked on
_State.<Property>.IsHiddenand travels to the client as theRecordHiddenCSV. - Hidden conditions are cumulative — multiple subscribers combine with OR logic.
- On the client,
dataSet.IsHidden("Path")andPropertyComponentContext.IsHidden()expose the state to custom components; the built-inPropertyComponentandPropertyGrouphandle it automatically, including live show/hide when the record's state changes. - Un-hiding recovers the real value automatically: the state change round-trips through the server, and the response for the now-visible property carries the stored value again.
- Masked values reach the client's local data while a property is hidden. The client never writes them back (PATCH bodies carry only the explicitly edited path), but
DataSet.IsDirtycompares snapshots by value, so a hide/unhide flip can report a spurious dirty state until the next load. $applyaggregations compute over real values — an aggregate can therefore reflect data that individual rows mask. Same category as the$filterinference gap: use RBACHidewhen the value itself must stay private.