# Explaining your app's security

> Explains your app's declared authorization in plain English — who can read, create, update and delete each entity, which fields are masked, each page's auth posture, which realtime topics admit a signed-out visitor, where a page's access does not line up with the data it shows, and — for every declared secret — every place it leaves the server and when it was last set.

<!-- id: local-explaining-your-app · area: local · stability: stable · html: https://osysharp.com/reference/local/explaining-your-app/ -->

## Summary        {#summary}

Turns your app's declared authorization into a plain-English report — **who can do what**, per entity and per page —
so you can reason about access across the whole app without reading a single security predicate. It needs no platform
and no database: it parses and resolves, nothing else.

## Signature      {#signature}

```console
osy explain [path] [--json] [--with-findings]
```

## Description    {#description}

Your app states access as declarations — a `security { }` block on an entity, a `policy`, an `[Authorize]` on a page.
Each is a precise rule, but reading them one file at a time never adds up to the question you actually have: *across
this whole app, who can read the customer's card number? who can create an order? which page is public?* `osy explain`
answers that. It walks every declaration and writes it out in sentences.

Because access here is **declared, not coded**, the translation is exact and repeatable — the same rules always produce
the same words. It is not a summary that guesses; it is a rendering. When a rule uses a shape the report cannot state
plainly, it says so — it prints the shape faithfully and marks it **`(needs review)`** rather than paraphrasing a
meaning it cannot stand behind. A sentence with no such mark is one you can rely on.

The report has three parts.

**The data surface — per entity.** For every table-backed entity, who may **read**, **create**, **update** and
**delete** it, in one line each:

- A role check reads as *"the caller holds the Staff role."* A grant to signed-in users reads as *"any signed-in
  user."* A grant that also admits anonymous visitors reads as *"anyone, including anonymous visitors (public)."*
- A row filter reads as *"only their own rows (where Owner is the caller)."* Membership through a related table reads
  as *"only rows they belong to (via Membership)."* A membership that also demands a **role** on that record names
  it: *"only rows of an Organization where the caller's Membership has Role = Admin"* — and the same rule written
  through a parameterised policy, `where IsAdmin(Organization)`, reads as that same sentence.
- A condition on the row's own fields is said as written: *"only rows where Status is Status.Open"*, *"rows where
  Archived is false"*, *"rows where Zone is set"*.
- Any other related record is spelled out piece by piece: *"only rows where there is a Placement whose ComputeZone is
  this row and for which the caller is the User of an ApplicationMember whose Application is that Placement's
  Application."* A policy called inside it, such as `IsAdmin(r.Organization)`, is read as its own body with the
  argument put in.
- An entity that grants nothing reads as *"no one (denied by default)"* — the honest reading of a `security { }` block
  with no matching rule, or of an entity with no block at all under [secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/).

**Grouping is never left to guess.** `A && B` reads *"A, and of those only B"*, never as two separate grants.
Alternatives inside a condition are bracketed, with each option a whole sentence: *"(either its Owner is the caller;
or its Helper is the caller)"*. An operation with more than one grant lists them one per line, and field rules that
say the same thing are one line naming every column they cover.

Every condition a rule carries reaches the sentence. When a rule holds a condition the report cannot phrase
(arithmetic such as `m.Score * 2 > 10`, say), the whole rule is printed as written, with the brackets the source
used, and marked `(needs review)`. It is never narrowed to a weaker sentence with that condition silently gone. A
sentence about *less* restriction than the rule declares is the one thing this report must not produce, because it
is read in place of a review.

It also lists **field masking**: a field a rule withholds (*"the CardNumber field is never returned to any reader"*),
and a `[Classification]` mask (*"Ssn is classified Secret — masked from readers below Secret"*), noting where a field
is also redacted from the audit trail.

**The UI surface — per page.** Access is enforced at the data, but each routed page also declares its **own** posture,
and the two are independent — so the report shows both. Per page: its route; whether it is public (`[AllowAnonymous]`),
gated by a policy (`[Authorize(P)]`, stated as the same English the data surface uses), open to any signed-in user
(the secure-by-default rule), or a composable fragment that inherits its host's gate; the entities it reads and writes;
and any policy-aware controls it carries (a button or field that reflects a policy). A composable that touches no data
is left out — the report is about pages that matter to access, not every presentational primitive.

**The realtime surface — per topic.** A `topic`'s `Candidates` decides who receives a room's traffic, so it is an
authorization rule like any other and the report states it. Per topic: its address (`RoomFeed(Room room)` — two
addresses are two topics), whether it admits a caller with **no principal** (`[AllowAnonymous]` on the topic, which is
the only thing that does — the join gate refuses a signed-out caller on every topic that does not declare it), whether
publishing follows its own `Publishers` rule or falls through to `Candidates`, what it carries, and whether membership
is presence-derived.

It also flags a join rule that **never reads its principal**. That is not a verdict — `Candidates = _ => true` is a
perfectly good "every signed-in user" — but a rule that restricts on the topic's *address* reads as a per-room
restriction and is not one, so an auditor is shown which rules discriminate between callers and which do not. The
same question `osy lint` asks, answered the same way, so the two cannot disagree about one app.

**The coverage cross-check.** The reason to put the two surfaces side by side is to catch where they **disagree**. A
page's gate opens the screen, but the row read is gated separately at the entity, and the two do not inherit — so a
page that lets in a caller the data will deny renders fine and shows an empty list, with no error to point at. The
report flags every such gap: a public page over data that is not anonymously readable, a signed-in page over data only
a role may read, or a page that reads an entity nothing grants a read to. It only flags what it can prove — a page that
plainly admits **more** than the data serves. It does not guess a direction between two different policies.

**Findings you meant.** Some of these flags are deliberate — a public status topic, a welcome page that shows members
more than visitors. `[Intended("<finding>", Justification = "…")]` on the page or topic says so ([[Intended] — acknowledging an osy explain finding](https://osysharp.com/reference/security/intended/)):
the finding is still listed, as **✓ intended** with your reason, and the count the report opens with —
`> **Findings:** 2 open · 1 acknowledged as intended` — stops counting it. A mark that no longer matches a finding is
itself counted open.

**A package's entity your app adds rules to.** A `partial entity X { security { … } }` over a package's entity does not
replace the package's rules: the report lists the package's rules first, then *this app's `partial entity` rules*, and
says how the two combine — under `[Authority(Narrowable)]` (the default) the package's rules still decide and yours can
only narrow them; under `[Authority(Extensible)]` your grants add to theirs. Only where the package declares no rules
of its own are yours the whole answer.

**The secrets surface — per secret.** For every secret the app declares, every path by which it **leaves the server**:
the sink it ends at (a request header and its name, a declared query or body slot, the key of a signing call, the mTLS
client certificate), the host it goes to — from the URL when the code writes one, otherwise the hosts the app granted
the package that sends it — the method, the function and `file:line`, and the chain of helpers it travelled through. A
secret that reaches no sink is listed as **unused**. Every path here was checked by the same compiler pass that refuses
every other path ([[function-secret-read#confinement]]), so the list cannot drift from what the compiler enforces. Each
secret also shows when its value was **last set**, read from the local platform running this project when there is one;
offline it says it does not know rather than guessing, and a deployed app's times are on the console's Secrets tab. The
`secretIn` lines in force are listed last, with whether the app granted each one ([where a secret may be sent — secretIn](https://osysharp.com/reference/project/secret-sinks/)).

**Output.** Markdown by default — a document you can read, review, or commit. `--json` writes the whole report as
JSON, the form to hand to a coding agent, to diff between two revisions, or to export into a compliance tool as audit
evidence (the secrets map is its `secrets` member); the English sentences ride **inside** the JSON, so the
machine-readable surface is also the readable one. `--with-findings` additionally folds in the maturity
linter's security and UI advice inline (see [Checking your app](https://osysharp.com/reference/local/checking-your-app/)) — off by default, because the report's job is
to state what your rules **are**, and the findings are what a reviewer would then **suggest**. Exit is non-zero when
the source did not fully resolve, so the report is partial and says so.

For the machine model of the whole app — types, relations, function effects — see [Understanding your app](https://osysharp.com/reference/local/understanding-your-app/).
For where the app falls short of a production bar, see [Checking your app](https://osysharp.com/reference/local/checking-your-app/).

## Examples       {#examples}

```console
osy explain                    # the plain-English security report, human-readable
osy explain --json             # the same report as JSON (the English rides in it too)
osy explain --with-findings    # also fold in the linter's security / UI advice
```

The data surface for one entity reads like this:

```text
## `Order` — default-deny

- **Read** — any of these 2:
  - The caller holds the Staff role.
  - Only their own rows (where Owner is the caller).
- **Create** — The caller holds the Staff role.
- **Update** — No one (denied by default).
- **Delete** — No one (denied by default).

**Fields**

- The CardNumber field is never returned to any reader.
```

An organisation-scoped grant table — writable only by an admin of the row's organisation, whether the rule is
written inline or through a policy:

```osy title="an org-admin write rule, both spellings" test app=local-explain-org-admin
[Role] enum OrgRole { Admin, Member }

[Principal] entity User {
  [Required, MaxLength(200), Unique] string Email;
  security { allow read when IsAuthenticated; }
}
entity Organization { [Required, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated; } }
[RoleGrant] entity Membership { [Required] User User; [Required] Organization Organization; [Required] OrgRole Role;
  security { allow read when IsAuthenticated; } }

policy IsAdmin(Organization o) => Membership.Any(m => m.User == user && m.Organization == o && m.Role == OrgRole.Admin);

entity RoleGrant {
  [Required] Organization Organization;
  [Required, MaxLength(40)] string Label;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete
      where Membership.Any(m => m.User == user && m.Organization == Organization && m.Role == OrgRole.Admin);
  }
}
entity Invitation {
  [Required] Organization Organization;
  [Required, MaxLength(200)] string Email;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete where IsAdmin(Organization);   // the same rule, named
  }
}
```

reads, for both entities:

```text
- **Read** — Any signed-in user.
- **Create** — Only rows of an Organization where the caller's Membership has Role = Admin.
- **Update** — Only rows of an Organization where the caller's Membership has Role = Admin.
- **Delete** — Only rows of an Organization where the caller's Membership has Role = Admin.
```

A page whose access does not line up with the data it shows is flagged in place:

```text
## `OrdersPage` @ `/orders`

- **Access** — Any signed-in user (secure-by-default; not [AllowAnonymous]).
- **Reads** — Order
  - ⚠ 'OrdersPage' admits any signed-in user, but 'Order's read is gated by a policy/role — a signed-in user outside
    that grant sees an empty 'Order'.
```

Where each secret goes reads like this:

```text
## Secrets — where each one leaves the server

### `StripeApiKey` — last set 2026-09-28 14:02 UTC
- **header** `Authorization` → granted: api.stripe.com · POST · `StripeClient.Post` (StripeClient.osy:342) [Osysharp.Payments.Stripe] · via `StripeClient.Headers`

### `PostNordApiKey` — last set 2026-09-21 09:40 UTC
- **query** `apikey` → api2.postnord.com · GET · `PostNordClient.Track` (PostNordClient.osy:145) [Osysharp.Carriers.PostNord]

### `LegacyToken` — last set: unknown offline — no local platform is running for this project
- **unused** — no code path sends it anywhere.
```

## See also       {#see-also}

[reading a secret's value (Secret.Name)](https://osysharp.com/reference/function/secret-read/) — what the compiler lets a secret do, and the one place it may end.

[where a secret may be sent — secretIn](https://osysharp.com/reference/project/secret-sinks/) — `egress "host" { secretIn … }`, the only way a secret may travel in a URL or a body.

[Understanding your app](https://osysharp.com/reference/local/understanding-your-app/) — the machine model of the whole app (types, relations, effects).

[Checking your app](https://osysharp.com/reference/local/checking-your-app/) — where the app falls short of a production bar; `--with-findings` folds it in.

[secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — why an entity with no security block grants nothing.

[security { }](https://osysharp.com/reference/security/entity-security/) — the `security { }` block this report reads.

[principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads](https://osysharp.com/reference/security/principal-predicates/) — the policies it translates into English.
