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#
osy explain [path] [--json] [--with-findings]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).
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):
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).
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) — 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. For where the app falls short of a production bar, see Checking your app.
Examples#
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 adviceThe data surface for one entity reads like this:
## `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:
[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:
- **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:
## `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:
## 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#
reading a secret's value (Secret.Name) — what the compiler lets a secret do, and the one place it may end.
where a secret may be sent — secretIn — egress "host" { secretIn … }, the only way a secret may travel in a URL or a body.
Understanding your app — the machine model of the whole app (types, relations, effects).
Checking your app — where the app falls short of a production bar; --with-findings folds it in.
secure by default (deny-all) — why an entity with no security block grants nothing.
security { } — the security { } block this report reads.
principal predicates (IsAuthenticated / IsAnonymous / IsTheApp) and open reads — the policies it translates into English.