# what a refused user is told

> When a write is refused, two different sentences are produced from it. The person using your app gets a short one that names the operation and the thing and no more — never the rule, the predicate, or their own address. Everyone building the app — `osy test`, `osy run`, `osy logs` — gets the whole story: entity, verb, the rule as you wrote it, and who was asking. You write the first one, in your own words: once for the whole app with `app.DenialMessage`, per entity with `security { message "…"; }`, or per condition on a `deny` rule.

<!-- id: security-denial-messages · area: security · stability: stable · html: https://osysharp.com/reference/security/denial-messages/ -->

## Summary        {#summary}
A refusal is read by two audiences with opposite needs, so the platform writes it twice.

| Who | Sees | Where |
|---|---|---|
| the person using your app | **"You do not have permission to change this policy."** | the 403 body, the error toast, a REST or MCP error |
| whoever is building it | that sentence **plus** entity, verb, the rule as you wrote it, and the caller | `osy test`, `osy run`, `osy query`, `osy logs` |

You never choose between them and you never wire anything up. What you *do* choose is the first sentence — the one
your users read — and the usual way to choose it is one line for the whole app:

```osy title="the app's own voice, for every entity in it" test app=security-denial-messages
app.DenialMessage = "You do not have permission to {verb} the {entity}.";

[Principal] entity Member { [MaxLength(200)] string Email; }
```

That sentence is now what a refused person reads anywhere in the app, with `{verb}` and `{entity}` filled in per
refusal: *"You do not have permission to update the cost center."* An entity that needs different words says so
itself; a single condition worth explaining says so on its own rule. Those are the exceptions, in that order.

⛔ **This is about WRITES — `create`, `update`, `delete` — and reads are not missing from that list.** A read you are
not permitted is not refused, it is **filtered**: the row is simply not in the result. There is no denial, so there
is nothing to say. See [Why reading is not here {#reads}](#reads).

## Signature      {#signature}
```osy syntax
app.DenialMessage = "…";                     // the whole app. One line, usually the only one you need.

security {
  message "…";                               // this entity, when the app's sentence is not right for it
  deny <ops> [when …] [where …] message "…";  // this CONDITION, when the condition is the person's situation
}
```

Most specific wins. A `deny` rule's own message beats the entity's, which beats the app's, which beats the
platform's built-in sentence.

### The holes a message can carry        {#placeholders}

| Hole | Becomes | Example |
|---|---|---|
| `{verb}` | `create`, `update` or `delete` | "you cannot **update** …" |
| `{Verb}` | the same, capitalised | "**Update** is not allowed …" |
| `{entity}` | the entity's name read back as English | `CostCenter` → "cost center", `HTTPEndpoint` → "HTTP endpoint" |
| `{Entity}` | the same words, sentence-cased | `CostCenter` → "Cost center" |

Write `{{` for a literal brace. Anything else in braces is a **compile error** naming this whole list, so a typo
never reaches a real person as `{entty}`.

⚠ **The article is yours.** The platform's own sentence says "this cost center" rather than "a cost center" because
no rule picks *a*/*an* correctly — a vowel-letter rule writes "an user", a consonant one "a invitation". Your app
knows its nouns, so write "the {entity}", "a {entity}", or drop the article entirely.

## Description    {#description}

### One sentence for the whole app        {#app-level}
`app.DenialMessage` is a top-level line in any model file. It is the whole surface most apps ever need: the
platform's built-in wording is nearly right already, and what an app actually wants is to say the same thing in its
own voice, once.

```osy title="an app that speaks for itself" test app=security-denial-messages
entity CostCenter {
  [MaxLength(100)] string Title;
  security {
    allow read when IsAuthenticated;
    allow create, update, delete when IsAuthenticated;
  }
}
```

With the `app.DenialMessage` above, a refused write on `CostCenter` reads *"You do not have permission to update the
cost center."* — for every verb, and however the refusal was reached.

**However it was reached** is the part that matters, because there are two ways and an author should not have to
know which one they hit:

- the row satisfied none of the `allow` rules (*"this row is not yours"*), or
- no rule for that verb is active for this caller at all (*"nobody in your position may do this"*).

Both get your sentence.

### When one entity needs different words        {#entity-level}
Put a `message` at the top of that entity's `security { }` block. It overrides the app-level sentence for this
entity only, and takes the same holes.

```osy title="an entity whose refusal deserves its own wording" test app=security-denial-messages
entity Policy {
  [MaxLength(100)] string Name;
  Member Owner;
  security {
    message "Only an organisation admin can {verb} an approval policy.";
    allow read when IsAuthenticated;
    allow create, update, delete where Owner == user;
  }
}
```

Reach for this when the app-level sentence would be misleading or unhelpfully vague for one thing in particular —
a screen users hit often, a rule that surprises people, a noun the generic sentence reads badly around. If you find
yourself writing near-identical messages on entity after entity, that is the app-level line asking to be written
instead.

### When a single condition is the whole story        {#rule-level}
A `message` on a `deny` rule replaces the sentence **for that rule only**, and only when that rule is what refused.
Use it where the CONDITION genuinely *is* the person's situation and knowing it is what they need:

```osy title="a condition worth explaining, said in your words" test app=security-denial-messages
entity Report {
  [MaxLength(120)] string Title;
  Member Owner;
  bool Locked = false;
  security {
    message "Only the owner can {verb} a report.";
    allow read when IsAuthenticated;
    allow update where Owner == user;
    // The one case that is not "you are the wrong person" — it is "this is the wrong time".
    deny update where Locked message "This report is locked while it is being paid. It reopens once payment clears.";
  }
}
```

Someone editing a locked report is told about the lock. Everyone else refused an update on a `Report` is told *"Only
the owner can update a report."*

⚠ **This is the narrow tool, not the general one — and a `message` on an `allow` is a compile error.** A predicate
says who QUALIFIES, from the system's point of view; it never becomes a description of the person's situation
however you word it. And the commonest refusal of all is made by the ABSENCE of a matching rule — several `allow`s
were consulted and none matched — so no individual rule's message would even be true. That is what the entity-level
and app-level messages are for.

### Why reading is not here        {#reads}
There is no way to write copy for a refused read, and that is a design decision rather than a gap.

Reading is **filtered, not refused**. A query returns the rows you may see; the rest are not in the result. There is
no denial, no 403, and nothing that happened for a sentence to describe — so a message would have nowhere to appear
and nothing to be about. You do not see what you are not allowed to.

### What your users read when you have written nothing        {#user-copy}
The built-in sentence, built from the operation and the entity's own name read back as English:

| Operation | Sentence |
|---|---|
| create | You do not have permission to create this cost center. |
| update | You do not have permission to change this cost center. |
| delete | You do not have permission to delete this cost center. |

When the refusal names no entity at all, it is *"You do not have permission to do that."*

**Nothing else ever appears there.** Not the rule, not the predicate, not the name of a policy, not the caller's own
email address. That is deliberate on both counts: a denial message is exactly where somebody probes, so the rules are
not printed at it; and a sentence naming an internal is not copy your users should ever have been shown.

⚠ **It does not narrate the rule either.** "You must be an admin of this organisation" would be friendlier and it is
still the rule, in nicer words. What a person may DO about a refusal is your app's to say — which is what everything
above this section is for.

### Where the whole story is        {#builder-detail}
Writing friendly copy never costs you the ability to debug your own app. Every refusal is recorded server-side in
full, at `Warning`, with the entity, the verb, the caller and the request's correlation id as fields — so a person
who reports *"it says I don't have permission"* hands you the correlation id from the error they saw, and one command
shows you exactly which rule refused them:

```console
osy logs --correlation <id>
```

The same detail is inline in the places you are already looking while you build:

- **`osy test`** — a denial that escapes a test uncaught reports both halves.
- **`osy run`, `osy query`** — you are driving your own app as its author, so you get both halves.
- **`osy explain`** — reads the rules directly; see [security { }](https://osysharp.com/reference/security/entity-security/).

⚑ A refusal is very often the security model working exactly as it should — a screen offering a button it should not,
a probe, a grant that has lapsed. That is why it is recorded at `Warning` rather than as an error: a log where every
correct refusal looks like a fault is a log where the real fault is invisible.

## See also       {#see-also}
- [security { }](https://osysharp.com/reference/security/entity-security/) — the `security { }` block these messages live in
- [secure by default (deny-all)](https://osysharp.com/reference/security/secure-by-default/) — why an entity you say nothing about refuses everyone
- [runas](https://osysharp.com/reference/testing/runas/) — becoming the refused user, which is the only way to know a rule works
- [Seeing what happened when your code ran](https://osysharp.com/reference/local/seeing-what-happened/) — following a correlation id back through the whole request
