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

Reference / Function

Functions (the unit of work)

A function is where your app's logic lives — a top-level unit of work, written like a C# method, that runs on the server. It is transactional by return: no Save(), no UnitOfWork.Commit(). It can call out to the world with no async and no Task, because the engine suspends and resumes it durably. And it contains no authorization code, because the entity's rules do that job — which is why a function is usually just the business problem, and nothing else.

stable7 examples compiled by CIfunctionlogicguide

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:

  1. It is transactional by return. What it writes commits when it finishes. There is no Save() and no UnitOfWork.Commit().
  2. It has no colour. It can call out to the world — HTTP, a model, a file — with no async, no Task<T>, and no change to its signature or to anyone who calls it.
  3. It contains no authorization code. It runs as the caller, and the entity's security { } rules decide what it is allowed to touch.
  4. 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 serverThese do not
a query or any read of stored data (Order.Single(…))pure computation — arithmetic, string work, comparisons
an effectHttp.*, File.*, LlmClient.*, Log.*, Memory.Searchcontrol flowif, foreach, while, switch
a call to another functionnew T { … } and assignment — they accumulate in the unit of work
UnitOfWork.Commit() / cancel()reading fields of rows you already have
raising or starting a workflowbuilding 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 constraintdiscards 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, when filters, 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, a class, or a List<T> / T[]. Every path must return (function); a throw counts 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 binds null.
  • 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 actionPublish(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 worldif 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

Control flow

Errors

Values and locals

The standard library, from a function body

See also#

Related

function

A function is a top-level unit of work, written like a C# method — a return type, a name, typed parameters, a body. It…

async / await — why Osy# has neither

Osy# has no async and no Task. A function that calls out to the world is written like any other function — the engine…

try / catch / finally

Handles a fault. C#'s syntax, including typed catches, catch filters and finally. The rows written inside a try block…

throw

Raises a fault. It ends the function immediately, and the rows the function wrote are discarded rather than…

How an Osy# app works (the execution model)

Read this before you write anything. An Osy# app is ONE model — you do not build an API for it, and you do not write…

Querying data

How you read data in Osy#. You write C# LINQ; it becomes one SQL statement. The rules that follow from that are the…

The security model

How authorization works in Osy#, end to end. Everything is denied until you grant it; a grant is compiled into every…

[Test] / [TestFixture]

A test is an ordinary function marked [Test]. It runs against a throwaway clone of the app, so it may create rows and…

class methods

Behaviour attached to a class — a method with a receiver, called as value.Method(). Classes are in-memory values, so a…