Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Entity

entity Sub : Base

entity <Sub> : <Base> { <members> }

Derives one entity from another. The subtype is its own type with its own name, its own security and its own workflows, and it carries every member of its base. Both store their rows in one table.

stable10 examples compiled by CIentitymodelinheritance

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:

WrittenWhy
is not Contract cthe 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)da 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#

DeclarationInherited?Why
members, and their attributes ([Required], [MaxLength], [Searchable], …)yesthey travel with the member, which the subtype has
semantic => … (the search card)yes, and a subtype may declare its own to overrideit is a description, and a subtype that adds nothing genuinely describes itself the same way
depthunlimitedSignedContract : Contract : Document carries every member of both levels above it
invariantyes, and the subtype's own add to themit is a constraint, and constraints only accumulate — a subtype cannot drop one
security { }no — always its ownit 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 — fine

What 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#

WrittenRefused because
a sealed basethe 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 : Ya 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 reversedifferent kinds — one is a table, one is not
extends / implementsother languages' spellings; Osy# keeps C#'s colon, one spelling per concept
two siblings declaring one member DIFFERENTLYone 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 typeit 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 entity is, 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 sample does not ship it; it lives in the repo's demo/ tree)

Related

entity

Declares a persisted type — a table of rows the app stores, queries and secures. Every entity gets an Id and audit…

sealed

Declares that no type may derive from this entity. A type is open unless it says otherwise, exactly as in C#. Sealing…

entity members

The typed members an entity holds — text, numbers, dates, booleans, Guids, enums and references. A member's type…

invariant

A row-level rule spanning several members, checked when the row is written. Use it when a constraint on one member is…

security { }

The rules that decide who may read and write an entity's rows. A where clause filters by the row (the owner sees their…

relations

One entity points at another by declaring it as a member — that is the foreign key. The parent reads its children back…