# topic

> 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.

<!-- id: realtime-topic · area: realtime · stability: stable · html: https://osysharp.com/reference/realtime/topic/ -->

## Summary        {#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.

```osy title="a room feed" test app=realtime-room
[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      {#signature}

```osy syntax
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](#summary), and each of the three publishing positions by [`Publish`](#publish).


## Description    {#description}

### The parameters are the address, not the name   {#address}

`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}

`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:

```osy title="an explicitly public topic" test app=realtime-public
[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`   {#publishers}

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).

```osy title="a broadcast topic — anyone may listen, only staff may speak" test app=realtime-broadcast
[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   {#gates-read-committed-data}

`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:

```osy title="✗ publishing on a membership that is still pending" syntax
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:

```osy title="commit the authorizing row first, then publish" syntax
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   {#one-unit-of-work}

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:

```osy title="the same commit, in a test body" syntax
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   {#publish}

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   {#publish-positions}

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:

```osy title="publishing from a page action" test app=realtime-action-publish
[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");
      Osysharp.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.

```osy title="publishing from an api endpoint" test app=realtime-webhook
[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}

`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.

### `Delivery` — `Ephemeral`, and only that   {#delivery}

`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}

`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.

```osy title="who is in the room" test app=realtime-presence
[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   {#subscribing}

A page receives with `Listen()` — see [Listen](https://osysharp.com/reference/realtime/listen/). On a `Presence = true` topic it also reads the SET of who
is connected, and announces its own state: see [Here, Announce](https://osysharp.com/reference/realtime/presence/).

## Examples       {#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       {#see-also}
- [security { }](https://osysharp.com/reference/security/entity-security/) — the `allow` rules a topic's `Candidates` predicate reads
- [Candidates (slot)](https://osysharp.com/reference/workflow/candidates/) — the `Candidates` spelling a topic deliberately shares
- [Listen](https://osysharp.com/reference/realtime/listen/) — the subscribe half of the surface
- [Here, Announce](https://osysharp.com/reference/realtime/presence/) — who is here, and what they are doing
- [component](https://osysharp.com/reference/ui/component/) — where a subscription is consumed, as a `live var`
