Summary#
A function is the unit of work. It looks like a C# method, it lives at the top level of a file, and it runs on the server.
Four things are true of every one, and together they are the whole model:
- It is transactional by return. What it writes commits when it finishes. There is no
Save()and noUnitOfWork.Commit(). - It has no colour. It can call out to the world — HTTP, a model, a file — with no
async, noTask<T>, and no change to its signature or to anyone who calls it. - It contains no authorization code. It runs as the caller, and the entity's
security { }rules decide what it is allowed to touch. - A fault undoes it. If it throws, the rows it wrote are discarded — there is no half-done state to clean up.
entity Order {
[Required, Unique, MaxLength(20)] string Code;
decimal Total;
invariant Total >= 0;
security { allow create, read, update when IsAuthenticated || IsAnonymous; } // the rules live HERE — never in the function
}
Order Place(string code, decimal total) {
var order = new Order { Code = code, Total = total };
return order;
} // ← committed here. No Save(), no auth check, no try/catch, no DTO.Read what is absent: no repository, no transaction scope, no IsAuthorized call, no mapping to a response type.
That absence is the point — the execution model explains why none of it exists.
Description#
It is a transaction, and the boundary is return#
Everything a function writes lands together, or not at all:
entity AuditLine {
[Required, MaxLength(200)] string Message;
security { allow create, read when IsAuthenticated || IsAnonymous; }
}
void PlaceAndLog(string code, decimal total) {
var order = new Order { Code = code, Total = total };
var line = new AuditLine { Message = $"placed {code}" };
// there is no state in which the order exists and the log line does not
}You never write change tracking. The platform knows which rows you created and which fields you touched — no dirty flags, no diffing, no save pipeline. On the server you do not even choose when to persist; returning is the commit.
This is the one place the client differs, and it is worth knowing before it surprises you: in a UI action, the
same new Order { … } is staged and shows on screen immediately, and it persists when a UnitOfWork.Commit() runs. Same
statement, two moments — the asymmetry, and the reason for it, is in
the execution model (§when data persists). Do not reach for UnitOfWork.Commit() in a server function.
The durable model — why there is no async#
This is the paragraph to understand. When a function reaches something that leaves the process — an HTTP call, a model completion, a file read — the engine suspends it, performs the effect, and resumes it at the next line, with every local still in place.
That suspension is durable. If the process is restarted, redeployed or killed while the call is in flight, the function still resumes where it left off. It is not a thread parked in memory; it is a continuation the platform persisted.
So you write this:
app Shop {
model "model/**/*.osy";
use Osyrin.Http;
}
string Fetch(string url) {
var response = Http.Get(url); // the function pauses here — you did not have to say so
return response.IsSuccess ? response.Body : "";
}No async. No Task<string>. Nothing about the signature says it might take a while, and no caller has to change
when you add an outward call three layers down.
In C#, async is a colour: a method that awaits must be async, so its callers must await it, so they must be
async too — it spreads until it reaches Main. The colour exists so a caller knows the callee might yield. Here
that is the engine's business rather than the signature's: any function can suspend, so none has to advertise it.
There is nothing to spread, so there is nothing to mark. The full story, including the single place await does
appear, is in async / await — why Osy# has neither — if you are coming from C#, it is the first habit to unlearn.
What crosses the wire, and what does not#
The engine hands off to the server when — and only when — a statement genuinely needs the server. It is worth knowing which those are, because the syntax hides them:
| These reach the server | These do not |
|---|---|
a query or any read of stored data (Order.Single(…)) | pure computation — arithmetic, string work, comparisons |
an effect — Http.*, File.*, LlmClient.*, Log.*, Memory.Search | control flow — if, foreach, while, switch |
| a call to another function | new T { … } and assignment — they accumulate in the unit of work |
UnitOfWork.Commit() / cancel() | reading fields of rows you already have |
| raising or starting a workflow | building and looping over a local List<T> |
The right-hand column is the surprising one: creating a row and assigning to it do not force a round trip. They accumulate in the open unit of work and travel with it. So a loop that builds fifty rows is one unit of work, not fifty conversations.
The thing actually worth noticing is a loop that calls another function ten times — that is ten hand-offs. The syntax hides the round trip; the latency does not.
Security is ambient — a function has no auth code#
A function runs under the caller's security context, and the entity's security { }
rules do the authorization. You do not check permissions in a function, and you should not try to.
This is not a convenience. A check you write is a check someone can forget to write; a rule on the entity is enforced for every path that touches it — this function, the next one, the UI, a workflow, an imported CSV — with no way to route around it.
Two consequences follow, and both catch people out.
user does not exist inside a function body. It is an ambient of the security rules, not of your code — writing
user.Id in a function is an unknown-identifier error. There is deliberately no "current user" to read: authorization
is a property of the data, expressed once on the entity, not a value you fetch and branch on.
The caller's identity still reaches the row anyway — through the audit columns. CreatedBy is stamped from the
security principal, which is the same thing user.Id resolves to inside a rule. That is what makes ownership work
with no owner field and no assignment anywhere in your code:
[Role] enum AppRole { Authenticator, Member }
[Principal]
entity User {
[Required, MaxLength(255)] string Email;
security { allow read where Id == user.Id; }
}
entity Note {
[Required, MaxLength(200)] string Title;
// `CreatedBy` is stamped for you, from the signed-in principal.
security {
allow create when IsAuthenticated;
allow read, update, delete where CreatedBy == user.Id; // …so this is ownership, for free
}
}
void Write(string title) {
var note = new Note { Title = title }; // nobody sets an owner. There is no owner field.
}Every reader of Note now sees only their own — including Note.Count(), which honestly means "how many notes are
mine".
And the enforcement is real with nothing in the function at all. Write contains not one line of security code,
so the only thing that can refuse it is the entity's rule — and it does:
[Test]
void The_function_has_no_auth_check_and_is_still_gated() {
// Nobody is signed in. Note grants create only `when IsAuthenticated`, that rule is part of every
// write, and so the create is refused — without `Write` knowing that security exists.
Assert.Denied(() => Write("a note"));
Assert.Empty(Note.ToList());
}See the security guide for the whole model.
Errors#
A fault — one you throw, a broken invariant, a violated constraint —
discards everything the function wrote and travels to the caller. There is no half-applied state, and nothing to
unwind by hand.
void PlaceTwo(string first, string second) {
var a = new Order { Code = first, Total = 10m };
if (second == "") { throw new ValidationException("the second code is required"); }
var b = new Order { Code = second, Total = 20m };
}You can also handle one. Constraint and invariant violations arrive as an ordinary ValidationException, so a
caller that wants to answer for a bad row rather than fail on it just catches it — and a try block that throws
discards what it wrote, so the handler is never left holding a broken half-row:
- throw — raising a fault, and the closed set of five types
- try / catch / finally — catching one,
whenfilters,finally, and the per-block rollback
What a signature may say#
Written like C#, with the deviations worth knowing:
decimal Quote(decimal amount, decimal rate = 0.25m, string? note = null) {
return amount * (1m + rate);
}
decimal Two() {
var a = Quote(100m); // rate defaults
var b = Quote(100m, rate: 0.1m); // named argument
return a + b;
}- Return
void, a scalar, an enum, an entity, aclass, or aList<T>/T[]. Every path must return (function); athrowcounts as a path. - Default parameter values and named arguments work as in C#. A nullable parameter (
string? note) is optional even without a default — omit it and it bindsnull. - Overloads do not exist. Two functions may not share a name; give the second one a name that says what it does.
- Recursion works, and is capped — a runaway function is stopped by the platform, and that stop cannot be caught (try / catch / finally).
- Names are PascalCase; parameters are camelCase.
Who calls a function#
The same function, unchanged, is reachable from all of these — it does not know or care which one it is serving:
- A UI action —
Publish(draft);by name. The engine crosses the boundary (How an Osy# app works (the execution model)). - Another function — an ordinary call.
- A workflow — as a state's work.
- A test —
[Test]calls it directly, with real data and real rules ([Test] / [TestFixture]). - The outside world — if you publish it. A function is exposed over REST or as a tool by declaring it in the app manifest; you never write an endpoint, and the function stays transport-agnostic. That surface is for other people's integrations, never for your own UI.
Where a function lives#
At the top level of a file, beside the data — not inside the entity. An entity body holds data and its rules; behaviour sits next to it. If you want behaviour attached to a type, with a receiver, that is a class method.
The pages#
Everything a function body may contain.
Declaring one
- function — the shape, the return, the transaction
- async / await — why Osy# has neither — why there is no
async, and the one placeawaitappears - class methods — behaviour attached to a type instead
Control flow
- if / else · switch — branching
- foreach · for · while — looping
- break / continue — leaving a loop early
Errors
- throw — raising a fault
- try / catch / finally — handling one
Values and locals
- var · Typed locals · const — declaring locals
- Numeric types & literal suffixes — numeric types and literal suffixes
- String interpolation & format specifiers —
$"…"and format specifiers - Array literals · List indexer · List OrderBy (in-memory) — lists
- Sum / Average / Min / Max / Count · LINQ over a local list —
Sum/Average/Min/Max/Countand the rest of LINQ, over a plainList<T>you are holding as much as over a query. There is no accumulator loop to write. - Compound assignment (+= -= *= /= %= ??=) · ++ / -- (increment / decrement) —
+=,++ - (int)x — casts —
(int)x, narrowing between the numeric types - Enumerable.Range —
Enumerable.Range(0, 8), a sequence of integers to iterate - Convert — converting between types
- nameof — a member's name as a string
The standard library, from a function body
- Current time (DateTime.UtcNow, DurableClock.Now) —
DateTime.UtcNow,DurableClock.Now - Guid.Empty and Guid.NewGuid —
Guid.NewGuid(),Guid.Empty - Text.Split · Text.LastIndexOf — text
- Crypto.Sha256Hex · Crypto.HmacSha256Hex and Crypto.FixedTimeEquals · Crypto.Encrypt and Crypto.Decrypt · Crypto.Md5Hex — hashing, signing, encryption
- reading a secret's value (Secret.Name) —
Secret.Name, a declared secret's value in a body - Security.* — hashing, verifying, tickets, random ids — password hashing and token issuing
- Log.* —
Log.Information(…)and friends - Http.* — calling someone else's API
See also#
- How an Osy# app works (the execution model) — the execution model: what runs where, and when data persists
- Querying data — reading data, and why a security rule is part of the query
- The security model — the rules that a function deliberately does not contain
- [Test] / [TestFixture] — proving a function does what you think, against real data and real rules
- function — the concept page for the declaration itself