Summary#
A platform shape often decomposes into more than one table. A schedule owns its rules; a rule owns its times, weekdays and month-days; a business-hours calendar owns its windows and exceptions. Those child rows have no independent life — a time-of-day that belongs to no rule is not a thing.
So they derive their access: the parent's rule, re-rooted onto the child across the reference that owns it. You declare security once, on the entity you actually have an opinion about.
Signature#
// ONE decision. The rules, their times/weekdays/month-days, and the exclusions all follow it.
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }Description#
Why the count of tables is not a count of decisions#
Before this, an app declaring one nightly schedule wrote six near-identical blocks, and a business-hours calendar cost three. That number followed how many tables the shape happens to decompose into — an implementation detail of the shape, not a question anyone was answering six times. It also grew: every table a platform shape gained was a block every app had to add, and the one people forgot was the last one.
A row that is part of another row is governed by that row. Whoever may read a schedule may read its rules; whoever may edit it may edit them. A rule readable by someone who cannot read its schedule is a mistake far more often than a policy.
It reaches all the way down#
Derivation follows the whole chain, not one level. A ScheduleRuleTime is part of a ScheduleRule, which is part of
a Schedule — so the rule you wrote on the schedule reaches the time-of-day two hops away. That matters because most
of the tables in a shape are usually grandchildren; stopping at one level would leave most of the ceremony in place.
The row filter comes too#
The parent's where clause is not dropped on the way down — it is re-rooted, so it keeps meaning the same thing:
// on the parent
partial entity Schedule { security { allow read where Owner == user; } }
// what the child gets, in effect — the same question asked through the hop
// a ScheduleRule is readable when Schedule.Owner == userA predicate the platform cannot re-root this way is a compile error, never a weakened child rule. That direction is deliberate: a derived rule that quietly dropped a clause would grant more than the parent does.
Declaring your own rule replaces it#
A child that declares its own partial entity … { security { } } uses that and derives nothing:
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
partial entity ScheduleRule { security { allow read when IsAuthenticated; } } // read-only, and it does NOT also derive⚠ This is the opposite of how capability-owned rows compose, and the
difference is load-bearing. There, your block lands alongside the capability's rule and the grants combine —
you can add a support role, and you cannot take the owner's access away. Here your block replaces the derivation.
If it combined, the stricter rule above would be pointless: the derived delete grant would OR with it and hand back
exactly what you just refused.
The rule of thumb follows from that. Reach for an override when a child genuinely differs from its parent — and expect that to be rare, because a child that needs its own policy is usually telling you it is not really part of anything.
Which tables these are#
You do not have to track them. osy explain reports each one's posture as part-of derived and names the entity
whose rule governs it, so the answer is always available from the app rather than from a list to keep in your head.
A principal check is COPIED; a row filter is HOPPED#
The two halves of a rule derive differently, and the difference decides what is even possible.
A when asks about the CALLER. when IsFinance — where IsFinance is
RoleGrant.Any(g => g.User == user && g.Role == Role.Finance) — never mentions the parent's row at all, so there is
nothing to re-root: the child gets the same predicate, unchanged, and grants exactly what the parent grants.
A where is a row filter. where Owner == user reads the parent's own column, so it is re-rooted across the
reference — the child's copy reads Schedule.Owner == user.
// Copied verbatim onto every child: it asks about the caller, not about the row.
partial entity Schedule { security { allow read when IsFinance; } }
// Re-rooted onto every child: it reads the schedule's own column.
partial entity Schedule { security { allow read when IsAuthenticated where Owner == user; } }⚠ A row filter containing a collection hop cannot be derived, and the compile says so. where Shares.Any(s => …)
would put a hop in front of a predicate that already contains one, and the two security backends are not guaranteed
to agree on that shape — so it is refused rather than emitted as a rule that might read what it denies. Rewrite the
parent's filter to read only its own columns, or declare the child's rule yourself where the child is one you can
name.
⚑ A role check in a when is fine and always was intended to be — it is the commonest rule there is, and it is
copied rather than hopped precisely because it has no row correlation to carry.
Examples#
A nightly digest, in full. One security decision, and everything the schedule decomposes into follows it:
[Principal] entity Operator {
[Required, MaxLength(80)] string Email;
security { allow read, create when IsAuthenticated; }
}
entity DigestRun { }
// The ONE block. `ScheduleRule`, `ScheduleRuleTime`, `ScheduleRuleWeekday`, `ScheduleRuleMonthDay` and
// `ScheduleExclusion` all take this rule; none of them needs a block of its own.
partial entity Schedule { security { allow read, create, update, delete when IsAuthenticated; } }
void SeedNightly() {
var s = new Osyrin.Scheduling.Schedule {
Name = "Nightly digest",
Template = new DigestRun { },
Zone = "Europe/Stockholm",
EffectiveFrom = new DateTime(2026, 1, 1),
};
var rule = new Osyrin.Scheduling.ScheduleRule { Schedule = s, Every = ScheduleFrequency.Day, Interval = 1 };
new ScheduleRuleTime { Rule = rule, At = TimeSpan.FromHours(2) };
}See also#
- secure by default (deny-all) — the deny-all posture these rows are an exception to, and why
- capability rows that belong to a user — capability rows owned by a user, whose blocks COMBINE rather than replace
- Schedule (recurring work) — the shape this is most often met through