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

Reference / Realtime

topic

topic Name(<address params>) { Candidates = u => <who may subscribe>; Publishers = u => <who may publish>; // omitted ⇒ same as Candidates Carries = <payload type>; // omitted ⇒ a signal, no payload Delivery = Ephemeral | Durable; // omitted ⇒ Ephemeral Presence = true; // omitted ⇒ false } Name.For(<address>).Publish(<payload>);

A topic is a named, addressable realtime destination: a declared place to put a message so that every open page entitled to it receives the value, without re-reading the database. Its parameters are its address, so RoomFeed.For(a) and RoomFeed.For(b) are two topics. Candidates says who may subscribe and is required. Publishing takes no commit and no unit of work.

stable6 examples compiled by CIrealtimetopicsecurityauthoring

Summary#

A topic is a declared destination for messages. Publishing to one delivers the value to every open page that is entitled to it — no polling, no re-query, and no row has to exist to carry the message.

[Principal] entity User {
  [MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

entity Room {
  [Required, MaxLength(120)] string Name;
  security { allow read, create when IsAuthenticated; }
}

entity Member {
  [Required] Room Room;
  [Required] User Person;
  security {
    allow read where Member.Any(m => m.Room == Room && m.Person == user);
    allow create where Person == user;
  }
}

class ChatLine { public string Body; }

topic RoomFeed(Guid roomId) {
  Candidates = u => Member.Any(m => m.Room.Id == roomId && m.Person == u);
  Carries    = ChatLine;
}

void Say(Guid roomId, string body) {
  RoomFeed.For(roomId).Publish(new ChatLine { Body = body });
}

Signature#

topic Name(<address params>) {
  Candidates = u => <predicate>;   // REQUIRED — who may subscribe
  Publishers = u => <predicate>;   // omitted ⇒ the same as Candidates
  Carries    = <type>;             // omitted ⇒ a signal topic, carrying no value
  Delivery   = Ephemeral;          // the only value today. Omitted ⇒ Ephemeral
  Presence   = true;               // omitted ⇒ false
}

Name.For(<address>).Publish(<payload>);   // from a server function, an action, or an api endpoint

This fence is a TEMPLATE — <address params> is a placeholder, not something that compiles — so it is illustrative rather than compiled, and every claim it makes is backed by a compiled example further down: the declaration form by the room feed above, and each of the three publishing positions by Publish.

Description#

The parameters are the address, not the name#

RoomFeed.For(roomA) and RoomFeed.For(roomB) are two different topics. This is the reason topics are declared rather than named by string: an address made of typed parameters is one the compiler checks, and one a security rule can read.

It also means a topic's name is never its security boundary. Knowing the name RoomFeed gets you nothing; what decides whether you receive a room's traffic is Candidates, evaluated for you, at that address.

Two spellings of one address are one address — an upper-case and a lower-case spelling of the same Guid reach the same subscribers, because the address is compared by its bound values rather than by the text that arrived.

Candidates — who may subscribe, and it is required#

Candidates is a predicate over the principal, evaluated at subscribe time with the topic's address in scope. It is free to read app data, which is what lets a user of the app configure who may join — a membership table, a role grant — without the developer recompiling.

It is deliberately the same spelling a workflow slot's Candidates uses, because it is the same question.

A topic with no Candidates is a compile error. Entity security takes the opposite posture — say nothing and nobody may — and that is right there, because forgetting a rule fails loudly: nobody can read the table and it is reported within the minute. A topic nobody may join fails the other way. The page simply never updates. There is no error, no refusal, nothing in a log, and it looks exactly like a quiet room. It is the least reportable failure in the system, so it is the one that must not be silent.

A deliberately public topic says so out loud:

[Principal] entity User {
  [MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

class Tick { public int Count; }

topic Heartbeat() {
  Candidates = _ => true;      // loud, greppable, and a decision somebody made
  Carries    = Tick;
}

Publishers — who may publish, defaulting to Candidates#

Omitting Publishers means the same people who may listen may speak, which is right for nearly every topic. The two cases that differ are both real and both one line: a broadcast topic (many subscribe, a server function publishes) and a firehose (many publish, few subscribe).

[Principal] entity User {
  [MaxLength(200)] string Email;
  bool IsStaff;
  security { allow read when IsAuthenticated; }
}

class Notice { public string Text; }

topic Announcements() {
  Candidates = _ => true;
  Publishers = u => u.IsStaff;
  Carries    = Notice;
}

The publisher is stamped by the platform. Who sent a message is taken from the acting principal, never from the payload — so a message claiming to be from somebody else is not expressible rather than merely refused. You do not have to remember to set it, and you cannot get it wrong.

⚠ Write the rule in the declaration, not in the function that publishes. A publish rule written in a function body is enforced only on the path that remembers to call it, and it is reliably weaker than the one you would have declared: the natural hand-written check asks whether the caller is in the room and forgets to ask whether the message names them as its author.

A gate reads COMMITTED data — so commit before you publish#

Candidates and Publishers are evaluated against the database as it is committed, not as your unit of work currently has it. That is deliberate and it matches every other gate in the platform: authorization is decided against the data that exists, never against a caller's pending, uncommitted view of it.

It has one consequence worth knowing before you meet it. If a single action creates the row that authorizes the publish and then publishes, the publish is refused — because at the moment the gate runs, that row is still pending:

void JoinAndGreet(Channel channel, string body) {
  new Membership { Channel = channel, Person = Session.CurrentUser };   // not committed yet…
  ChannelFeed.For(channel.Id).Publish(new PostLine { Body = body });    // …so `Candidates` cannot see it → refused
}

Commit first, and it behaves as you would expect:

void JoinAndGreet(Channel channel, string body) {
  new Membership { Channel = channel, Person = Session.CurrentUser };
  UnitOfWork.Commit();                                                  // the membership is now a fact
  ChannelFeed.For(channel.Id).Publish(new PostLine { Body = body });    // `Candidates` sees it
}

The refusal names your own rule, which reads like a bug in the rule rather than a question of ordering — so when a publish is refused by a rule you believe should pass, check what the current unit of work is still holding. It will tell you: the refusal lists the entities your unit of work has pending, so you can see the row that was going to authorize you sitting there uncommitted.

A page action is one unit of work, and so is a [Test] body

This catches people because a page action does not commit either. Calling a server function from a page carries your uncommitted edits along with the call — the server runs your query against them, so you read your own changes — but nothing is written until an explicit UnitOfWork.Commit(). So the refusal above is not something that only happens in an unusual place: it is what a page doing both halves in one action gets.

A [Test] body behaves identically, and deliberately so — it is modelling that page action. If your test creates the authorizing row and then subscribes or publishes, commit in between, exactly as the app itself would have to:

var channel = CreateChannel("design", "");   // stages a Channel and a Membership
UnitOfWork.Commit();                         // …now they are facts the gate can see
ChannelFeed.For(channel.Id).Listen();        // admitted

Publish — no commit, no unit of work#

Publishing is not a data write. It opens no transaction and needs no row, which is what lets an inbound webhook or an agent mid-answer reach a page directly.

Where you may publish from

From a server function, from an api endpoint, and from a page action — the three positions the signature names, each with a compiled example here.

A publish written straight into an action is the same publish. You do not have to route it through a server function to be allowed to write it:

[Principal] entity User {
  [MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

class Line { public string Body; }

topic Lobby(string room) {
  Candidates = _ => true;
  Carries    = Line;
}

[Page("/lobby")]
[Render(CSR)]
[AllowAnonymous]
component LobbyPage() {
  string draft = "";

  action Send() {
    Lobby.For("main").Publish(new Line { Body = draft });
    draft = "";
  }

  render {
    Stack(gap: 2) {
      Input(value: draft, label: "Message");
      Osyrin.Button("Send", onClick: Send);
    }
  }
}

It still runs on the server, and that is not an implementation detail you can lose. The browser evaluates the address and the payload, then hands the publish to the host, which performs it under its own authority: Publishers is evaluated there, and the sender is stamped there from the connection. So the rules in this page hold identically whether you publish from an action or from a server function — including the committed-data rule below, because the action's own uncommitted edits are not facts yet when the gate reads them.

That also means a publish is the one thing in an action that cannot be made to lie about who sent it. There is no argument for the sender anywhere in this surface, and the browser is given nothing to say about identity.

[Principal] entity User {
  [MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

class Delivery { public string Status; }

topic OrderTracking(Guid orderId) {
  Candidates = _ => true;
  Carries    = Delivery;
}

void CarrierUpdate(Guid orderId, string status) {
  OrderTracking.For(orderId).Publish(new Delivery { Status = status });
}

A refused publish throws, like any other refusal of a declared rule. It does not return quietly: a message nobody received is the hardest failure in this system to notice, so it is not one of the silent ones.

Carries — the payload type, or a signal#

Carries names what each message holds — an app class. A topic that declares none is a signal: Publish() takes no argument, and subscribers learn only that something happened.

A published message may not carry a ROW, and the refusal says so. A publish is composed once and goes to every subscriber the join gate admitted, so a row inside it would be one person's view of that row handed to all of them — and what a reader may see of a row is that reader's own question. Carry the values the readers need instead: a name, an id, the two fields the line renders. (A presence entry is the exception that proves it: it carries the principal's row, and the platform projects it separately for each recipient, through each one's own read rules.)

Prefer carrying the value. A signal can only be answered by re-asking the whole question, and that is what a topic exists to avoid: a room of ten people a hundred messages deep costs about a thousand rows to deliver one "ok" when the message carries nothing, and ten when it does.

DeliveryEphemeral, and only that#

Ephemeral (the default) is not persisted, and deliberately does not reach someone who was offline — a typing indicator has no meaning to somebody who was not there.

Delivery = Durable is refused at compile time: it is not delivered yet, and accepting it would mean a subscriber who was away silently received nothing.

Write the durable half as rows and the instant half as the topic. They are different in kind, and an app that needs both wants both anyway:

  • the row is what survives a reload and answers "what was said before I arrived";
  • the publish carries the same values, so an open page renders them without re-reading anything.

That is two lines in the function that does the work — one new, one Publish — and it is what every app here does.

Presence — the set of who is here#

Presence = true makes the topic's membership derived from who is connected to it. The platform adds an entry when a page subscribes and removes it when that page goes away, so a crashed browser cannot stay online for ever — and no app code maintains it.

[Principal] entity User {
  [MaxLength(200)] string Email;
  security { allow read when IsAuthenticated; }
}

entity Room {
  [Required, MaxLength(120)] string Name;
  security { allow read, create when IsAuthenticated; }
}

entity Member {
  [Required] Room Room;
  [Required] User Person;
  security {
    allow read where Member.Any(m => m.Room == Room && m.Person == user);
    allow create where Person == user;
  }
}

topic RoomPresence(Guid roomId) {
  Candidates = u => Member.Any(m => m.Room.Id == roomId && m.Person == u);
  Presence   = true;
}

An app can never write somebody else's entry — only decorate its own. Invisible mode is therefore free and needs no setting: a page that does not subscribe is not in the set.

Presence is gated by the same Candidates rule as any other join. That matters more than it looks: a presence set names people, so admitting a stranger to "just the presence" of a private room would disclose its membership without ever showing them a message.

One person is one entry however many pages they have open — a phone and a desktop are one person in the room.

Subscribing from a page#

A page receives with Listen() — see Listen. On a Presence = true topic it also reads the SET of who is connected, and announces its own state: see Here, Announce.

Examples#

The compiled examples above are the reference set: a room feed with a membership rule, an explicitly public topic, a broadcast topic with a separate Publishers rule, a page action publishing directly, a webhook publishing with no row to carry it, and a presence topic.

See also#

  • security { } — the allow rules a topic's Candidates predicate reads
  • Candidates (slot) — the Candidates spelling a topic deliberately shares
  • Listen — the subscribe half of the surface
  • Here, Announce — who is here, and what they are doing
  • component — where a subscription is consumed, as a live var

Related

Realtime

Realtime in Osy# is one construct: a topic. A topic is a declared, addressable destination — publishing to one delivers…

Listen

The receiving half of a topic, consumed on a page. Listen yields a stream of the topic's payloads, and a live var bound…

Here, Announce

Who is currently on a presence topic, and what they say they are doing. Here is the converging set of people present…

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…

Candidates (slot)

Declares WHO may hold or satisfy a `subscribe` slot. `Candidates` is one expression surface that dispatches on its…

component

The one archetype for all UI: a bounded reactive unit — typed props, reactive members (fields, `live`…