Summary#
entity RushOrder : Order derives one entity from another, with C#'s colon and C#'s meaning. The subtype is its
own type — its own name, its own security rules, its own workflows — and it carries every member of its base
plus whatever it adds. Both types store their rows in one table.
Those two facts are the whole feature, and everything below is a consequence of one or the other.
Signature#
entity <Sub> : <Base> {
<attributes> <Type> <Member>; // members of its OWN, on top of everything Base declares
invariant <condition>; // its own checks; Base's still apply
security { … } // its OWN rules — nothing is inherited here
}Description#
What a subtype is#
A subtype is a distinct type. It has its own name, and everything the platform keys on a type keys on it separately: its security rules are its own, a workflow that tracks it tracks only it, and a row of it reads back as itself.
It is also complete. You do not reach through a base to get at inherited members — RushOrder simply has
Reference, in a query, in a UI binding, in a function:
entity Order {
[Required, MaxLength(120)] string Reference;
int Quantity;
}
entity RushOrder : Order {
[Required, MaxLength(60)] string Courier;
}
void Ship() {
// `Reference` and `Quantity` come from Order; `Courier` is RushOrder's own. No difference at the use site.
var rush = new RushOrder { Reference = "R-1001", Quantity = 2, Courier = "DHL" };
}Where are a subtype's rows stored?#
A subtype's rows live in its base's table, alongside the base's own. A hidden platform-written column records which type each row is.
This is not an implementation detail you can ignore, because it is what makes the useful things possible: a
reference typed Order can hold a RushOrder, a parent-child tree can span types, and there is one id space, so
nothing has to ask "which table is this id in". It is also why a subtype may not redeclare a base member — see
One column, so no redeclaring — and why [Unique] reaches further than you might expect — see
Unique spans the hierarchy.
One consequence is worth knowing if you ever look at the table directly: a member only a subtype declares is
optional in the database, because rows of every other type genuinely have no value for it. [Required] is still
enforced — for that type, on every write — but the column itself holds NULL for everyone else, rather than a
fabricated blank.
A subtype goes wherever its base is wanted#
A SignedContract is a Document, so it goes anywhere a Document is expected — a member, a function
argument — with no cast, exactly as in C#. This is what one table and one id space buy: a single reference column
holds any kind, and nothing at the far end knows the hierarchy exists.
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity Note {
[Required] Document Document; // typed as the ROOT…
[Required, MaxLength(400)] string Body;
}
void Annotate() {
var c = new Contract { Title = "Supply", Counterparty = "Acme" };
new Note { Document = c, Body = "countersigned" }; // …and a Contract goes straight in
}"Anywhere a Document is expected" includes the places where two values have to agree on one type — a ?:, a
switch expression, a ?? fallback. The result is the base of the two, so Document d = rush ? contract : doc;
is the ordinary way to pick between them. Two SIBLINGS — a Contract and an Invoice — have no common type to
infer, so they take the type they are written INTO: Document d = rush ? contract : invoice; is fine, while
var d = … has nothing to take and says so.
The other direction — treating a Document you are holding as a Contract — can fail at run time, so you state it:
test the row with is, or narrow a whole set with OfType<T>().
Reading a type returns everything below it#
Order.Where(…) returns rush orders too, because a RushOrder is an Order — the same thing a List<Order>
means in C#. Narrowing is what you state: RushOrder.Where(…) returns rush orders and whatever derives from
them, and never a plain Order. Count(), a collection you navigate to and a tree you walk all read the same way.
Each row that comes back is governed by its own type's rules, never by the type you asked through — so a base read can return fewer rows than the table holds, and that is the rules working rather than a missing row.
int AllOrders() {
// The rush orders too — they are orders.
return Order.Where(o => o.Quantity > 0).ToList().Count;
}
int RushOnly() {
return RushOrder.Where(o => o.Quantity > 0).ToList().Count;
}How do I get back just one kind? — is and OfType<T>#
A base read hands you every kind. is tests one row; OfType<T>() narrows a whole set — and both are
polymorphic downward, because a SignedContract is a Contract.
x is Contract is a plain condition: it works in a query, where it becomes a check the database does without
reading any rows, and in ordinary code. is not Contract is its negation.
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity SignedContract : Contract { [Required, MaxLength(60)] string Signatory; }
// In a query — the database answers it; nothing is loaded to decide.
int ContractCount() { return Document.Where(d => d is Contract).ToList().Count; }
// `OfType<T>()` gives you a set of that type, so its own members are readable.
string Counterparties() {
var all = "";
foreach (var c in Document.OfType<Contract>().ToList()) { all = all + c.Counterparty + ";"; }
return all;
}Both count the SignedContract too. To ask for only the leaf, name it: Document.OfType<SignedContract>().
Why can't I read the subtype's members after is?
is on its own answers a question; it does not change what you may read. d is Contract tells you the row is a
contract, and d is still a Document, so d.Counterparty does not compile. Give the test a name and it does:
entity Document { [Required, MaxLength(200)] string Title; }
entity Contract : Document { [Required, MaxLength(80)] string Counterparty; }
entity Memo : Document { }
string Render() {
var lines = "";
foreach (var d in Document.OrderBy(x => x.Title).ToList()) {
if (d is Contract c) {
lines = lines + d.Title + " with " + c.Counterparty + "\n"; // `c` is the same row, as a Contract
} else {
lines = lines + d.Title + "\n";
}
}
return lines;
}c is the row you tested — nothing is copied — and it exists inside the if only. That is deliberate: outside
the branch the test may not have held, and a name that reads a row as a kind it is not would be worse than no name.
For the same reason these are refused, each with a sentence saying what to write instead:
| Written | Why |
|---|---|
is not Contract c | the test failing says nothing about what c would be |
var b = d is Contract c; | c belongs to a branch, and there is no branch here — use is Contract to get the answer |
(Contract)d | a written downcast must fail at run time when the row is not one, and that check is not built |
Security is never inherited#
A subtype declares its own security { } block, and inherits nothing from its base.
That is deliberate, and it is the safe direction. An entity with no policy grants nothing, so a subtype whose author has not yet thought about who may read it returns no rows — rather than silently receiving whatever grant its base happened to have. A row is always governed by the rules of its actual type, never by the type you reached it through.
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
entity Document {
[Required, MaxLength(200)] string Title;
security { allow create, read when IsAuthenticated; } // any signed-in user sees any Document
}
entity Contract : Document {
[Required, MaxLength(80)] string Counterparty;
// Its OWN, and narrower. Nothing of Document's grant carries over — had this block been left out entirely, a
// Contract would be readable by nobody, which is the direction you want to be wrong in.
security { allow create, read where CreatedBy == user.Id; }
}What IS inherited#
| Declaration | Inherited? | Why |
|---|---|---|
members, and their attributes ([Required], [MaxLength], [Searchable], …) | yes | they travel with the member, which the subtype has |
semantic => … (the search card) | yes, and a subtype may declare its own to override | it is a description, and a subtype that adds nothing genuinely describes itself the same way |
| depth | unlimited — SignedContract : Contract : Document carries every member of both levels above it | |
invariant | yes, and the subtype's own add to them | it is a constraint, and constraints only accumulate — a subtype cannot drop one |
security { } | no — always its own | it is an authority question, where silence must mean no |
Description defaults to inheritance; authority defaults to denial.
Can I redeclare a base's member on a subtype?#
Because both types share a table, a member declared on both is not a shadowed member — it is one column claimed by
two declarations. C# lets you shadow with new; a table cannot, so there is no spelling for it and the compiler
asks you to delete one:
entity Order { [Required] string Reference; }
entity RushOrder : Order {
[Required] string Reference; // ✗ 'RushOrder.Reference' redeclares 'Order.Reference'
}The realistic way to hit this is not a typo — it is a base gaining a member later that a subtype already used. The error names both declarations so you can see which one you meant.
Two SIBLINGS may share a member name, as long as they agree about it. Rush.Note and Standby.Note do not
collide with each other the way a subtype collides with its base — they are different rows — so one shared column
serves both:
entity Ticket { [Required, MaxLength(200)] string Title; }
entity Bug : Ticket { [MaxLength(80)] string Area; }
entity Feature : Ticket { [MaxLength(80)] string Area; } // same column, same meaning — fineWhat is refused is the two disagreeing, because there is only one column and only one of them can win:
entity Bug : Ticket { [MaxLength(80)] string Area; }
entity Feature : Ticket { int Area; } // ✗ one column, declared as `string` by one type and `int` by the other[Unique] spans the hierarchy#
A unique constraint is over a table, and one table holds every type in the hierarchy. So [Unique] on a base is
unique across the base and every type derived from it:
entity Order {
[Required, Unique, MaxLength(40)] string Code; // no two Orders share a Code…
}
entity RushOrder : Order { // …and a RushOrder is an Order, so it is in the same space
[Required, MaxLength(60)] string Courier;
}An Order with Code = "A-1" and a RushOrder with Code = "A-1" is refused. That is usually exactly what you
want — one id space is much of the point of sharing a table — but it is impossible to read off the declaration, and
the other reading ("unique among Orders") is equally plausible. So the compiler warns, naming every type the
constraint covers:
'Order.Code' is unique across EVERY type stored in 'Order's table — Order, RushOrder — not just 'Order'.
It is a warning rather than an error because the behaviour is correct; what was missing is that you were told. There
is no per-type spelling: if hierarchy-wide is not what you meant, the member belongs on one type rather than on a
shared one. The same warning appears for a composite [Unique(A, B)] and for a [Unique] a subtype declares.
What happens to the rows if I remove a subtype?#
Deleting a subtype from your source means the same thing as deleting any other entity: its rows go, and nobody else's do. It does not drop the shared table, the base and its siblings are untouched, and the columns that only the removed type declared go with it.
Like every other drop it is gated — a plain recompile reports it and keeps the type, and removing it for real needs
--prune in development or an acknowledged migration in production.
Deleting a base while something still derives from it is a compile error: the subtype's : Base names a type
your application no longer declares. Remove the subtype in the same change, or keep the base.
What is refused#
| Written | Refused because |
|---|---|
a sealed base | the type said no type may derive from it — see sealed |
two bases (: A, B) | Osy# has no interfaces, so a second name could only be a second base, and a row has one type |
class X : Y | a class is an in-memory value, not a table; give it a field of the other type and compose |
| an entity deriving from a class, or the reverse | different kinds — one is a table, one is not |
extends / implements | other languages' spellings; Osy# keeps C#'s colon, one spelling per concept |
| two siblings declaring one member DIFFERENTLY | one column, and only one of the two declarations can win |
a written DOWNCAST ((Contract)d) | it must fail at run time when the row is not one, and that check is not built — narrow instead with is or OfType<T>(), which cannot fail |
a member other than security { } on a subtype of a platform type | it shares a table the platform owns and writes — see below |
Deriving from a platform type#
A type a capability brings in may be derived from when it is not sealed — today that is AgentTask, so an app can
give its own kind of agent work its own type, its own rules and its own process.
A subtype of a platform type declares security { } and nothing else. It shares a table the platform owns and
writes, so adding a column to it would reshape platform storage — the same rule a partial entity over a platform
type already follows. Your own types are unrestricted: a subtype of a type you declare adds whatever it likes.
using Osyrin.Agents;
[Principal] entity Person {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
// A distinct type sharing AgentTask's table — no new columns anywhere.
entity PersonalTask : AgentTask {
security { allow read when IsAuthenticated; }
}
workflow PersonalTaskProcessor {
Tracks = PersonalTask.Status;
Initial = Running;
state Running { }
state Waiting { }
terminal success Completed { }
terminal error Failed { }
}
app.Agent = new AgentConfig { Loop = PersonalTaskProcessor };The task rows an agent opens under that loop are PersonalTasks: a task's type is the type its loop tracks. A
plain agent with no loop, or one whose loop tracks AgentTask itself, still gets an AgentTask.
Rows of a platform type stay platform-written through the subtype — the new name grants no ability to create, change or delete one that the base did not.
Its fields, references and child collections all come through: personalTask.Children reads the same relation
AgentTask.Children does, because it IS that relation — there is one table, one foreign key, and one relation over
it, whichever type you reach them through.
Workflows bind one type#
Tracks = RushOrder.Status binds RushOrder and nothing else — a workflow over a base does not cover its
subtypes. That is what keeps one state machine per column true: a base's run and a subtype's run would otherwise
both drive one Status value on one row.
It is also what makes inheritance the natural way to give two kinds of thing two different processes: give each its own type, and each type its own workflow.
Examples#
enum TaskState { Open, Doing, Done }
entity WorkItem {
[Required, MaxLength(200)] string Title;
TaskState State = TaskState.Open;
}
// Its own type, so its own rules and its own process — and no new columns.
entity UrgentItem : WorkItem {
[Required] DateTime DueBy;
}See also#
- sealed — how a type says no one may derive from it
- entity — what an
entityis, and what the platform provides for free - security { } — the
security { }block a subtype must write for itself - invariant — the row-level checks a subtype accumulates from its base
- relations — a reference typed as a base holds any of its subtypes
demo/doc-vault— the runnable demo: four kinds of document in one table, three levels deep, with per-type security you can see by signing in as three different people (osy docs sampledoes not ship it; it lives in the repo'sdemo/tree)