# What a workflow body may write

> A workflow body runs on a system data context, so its writes are NOT gated by the app's `security {}` blocks. The person who raised the event is authorized to RAISE it — `[Authorize]` and a slot's `Candidates` decide that — and everything the transition then writes lands regardless of what that person could have written themselves. The one exception is a `deny` a KIT wrote: inside a body, only that kit's own code (and a kit built on it) writes past it — and a function the deny names in `except via` writes past it everywhere, in a body and a request alike.

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

## Summary        {#summary}
A workflow body writes as the **system**, not as the caller. The entity's `security {}` block gates what a PERSON
may do directly; it does not gate what a transition does once an event has been legitimately raised — with one
exception: a `deny` that a **kit** wrote binds every line of a workflow body except the kit's own code — its own
workflow, its own functions when your body calls them, and a kit built on it. Outside a body — in a page action, an API
call — a kit's function writes as its caller, and only a function the deny names in `except via` writes past it
([deny … except via](https://osysharp.com/reference/security/except-via/)).

## Signature      {#signature}
```osy title="the write inside a transition is not checked against the entity's rules" syntax
state Open {
  on Close {
    this.Item.Note = "closed by the workflow";   // lands even if NO rule grants `update` to anyone
    goto Closed;
  }
}
```

## Description    {#description}

### Where the authorization actually happens   {#where}
Two different questions, and only the first one is about the person:

- **May they raise this event?** `[Authorize(principal => …)]` on the event, and a slot's `Candidates` for who may
  hold the work. See [[Authorize] (event)](https://osysharp.com/reference/workflow/authorize/).
- **May the body write this row?** Not asked. The engine opens a system data context for the run, and a system
  context is defined as one that opts out of data security.

So a transition is trusted code, in the same sense a server function's own bookkeeping is. Put the gate on the
EVENT, where the person is.

### Why it is built that way   {#why}
A run outlives the request that started it. A milestone that expires at 2am, a deadline, a retry after a service
came back — these resume with no acting principal at all, and a rule written `allow update where Owner == user`
has no `user` to compare against. If a body were gated by the caller's rules, the same transition would succeed or
fail depending on who happened to trigger it, and a timer-driven one could never succeed.

### A kit's deny binds every workflow but the kit's own code   {#kit-deny}
A kit says what only IT may change with a `deny`. The WorkQueues kit's `WorkItem` carries `deny update Assignee` — who
holds a piece of work changes only inside the kit's own run, where every change is recorded with its reason. If a
workflow body's system authority were total, your app's own workflow over the same row could assign it anyway and
leave no trail.

So the system authority stops at a package's deny, and what decides is WHOSE CODE makes the write:

- **Your app's workflow** writes past your app's own rules, as above, but NOT past a `deny update`, `deny create` or
  `deny delete` a kit wrote. The write is refused, and the refusal names the kit.
- **Nor past a `deny … except via F`, whoever wrote it and whoever's body it is.** That line names the only code
  that may make the write, and a workflow body is not it: your app's body, the kit's own body and the body of a kit
  built on it all call `F`, like everything else ([deny … except via](https://osysharp.com/reference/security/except-via/)). See
  [the one deny no body writes past](#except-via-in-a-body).
- **The kit's own workflow** writes past the kit's other denies — a plain `deny update AssigneeId;` is what its run
  is for.
- **The kit's own function** writes past those too when your app's workflow body calls it. The write happens inside
  the kit's code, so it is the kit's: the kit decides what its function writes, and your app cannot widen it.
  ⚠ That is the BODY's authority, not the function's: called from a page action or an API call, the same function
  writes as its caller and is held to the deny like any code. A kit that wants one of its functions to change a
  denied member from a request names it — `deny update UnitsLeft except via Draw;` — and that exception is the same
  in a body and a request: the named function's own write to the named member, decided by the server.
- **A kit built on the kit** — one whose manifest `use`s it — writes past the kit's plain denies as the kit does. It
  is that kit's extension: `Osysharp.UserManagement` deactivates a person through `Osysharp.Identity`'s `IsActive`.
- **Your app's own code does not**: your workflow body, and every function it calls that is not the kit's. App code
  the KIT's run calls through the kit's own extension point (an override of a kit class's `virtual` method, such as
  the conversations kit's `PartyKinds.New` making your `Customer`) is part of that run, and runs as it does.
- **A conditioned deny** (`deny update Account when !IsAuthenticator`) is evaluated for the person who raised the
  event. A body that a timer drove has nobody, so the deny applies.
- Only a deny the kit **wrote** counts. What a kit merely did not grant still governs your app's direct writes, and
  does not fence a body.
- Reads are judged when you **compile**, not when the body runs — see [kit read denies](#kit-deny-read).

Change a kit-owned field the kit's way — raise the kit's workflow event, or call the kit's own function that makes
the change, so the kit's code does the write.

### The one deny no body writes past   {#except-via-in-a-body}
`deny update Level except via Fill;` says which CODE changes `Level`. A body's system authority is an answer to "who
is asking", so it does not apply: a step that assigns the field is refused, and a step that calls `Fill` is not — in
your app's workflow over your own entity, in a kit's workflow over the kit's own entity, and in the workflow of a kit
built on it.

| in a workflow step or an `[On]` handler | a plain `deny update X;` a kit wrote | `deny update X except via F;` |
|---|---|---|
| your app's code assigns `X` | refused | refused |
| the kit's own code assigns `X` | written | refused |
| code of a kit built on the kit assigns `X` | written | refused |
| anybody calls `F`, and `F` assigns `X` | — | written |

`[Authorize]` on `F` is not asked in a body — a body has no caller — so a kit's clock or an event handler calls the
named function freely; the attribute keeps saying who may call it from a request.

### What may my code read of a kit's denied fields?   {#kit-deny-read}
A body reads as the system, so at run time it would read past every `deny read`. Two kinds of kit line are held
anyway, when your app compiles:

- **A broad deny** — `deny read SealedCode;`, no `when`, no `where` — means nobody but the kit reads it. Your app's
  code that names the field does not compile, in a function and in a workflow body alike. Call the kit's function that
  does the work instead; a kit built on the kit (one whose manifest `use`s it) reads it like the kit. A `[Test]` may
  name it: a test runs as a person, so it reads the masked value, and asserting that mask is how a kit proves its line.
- **A credential mask** — `deny read PasswordHash when !IsAuthenticator;` — keeps the column out of everything but
  the sign-in. You may still CHECK it (`Security.VerifyPassword(password, u.PasswordHash)` answers a `bool`), so your
  own sign-in method works. Logging it, mailing it, copying it to another field or returning it is refused, the same
  way a [secret](https://osysharp.com/reference/project/secret-sinks/) is.

A deny that depends on who is reading (`deny read Balance when !IsShopStaff`, `deny read … where Owner != user`) is
not held in a body: its answer would change with whoever raised the event, and a timer raises it for nobody. It keeps
governing what people read.

```osy title="a kit's sealed code, used the kit's way" syntax
on Paid {
  // Mail.Send(to, Crypto.Decrypt(card.SealedCode!));   // refused: the shop kit wrote `deny read SealedCode;`
  DeliverGiftCard(card);                                // the kit's own function mails it, once
}
```

```osy title="change the holder through the kit, not by assigning it" syntax
state Escalated {
  enter {
    // this.Item.AssigneeId = lead;          // refused: the kit wrote `deny update AssigneeId`
    WorkHandling.RaiseTake(this.Item, lead);  // the kit's own run writes it, and records why
    Reassign(this.Item, lead);                // …or a function the kit ships: its code, so its write
  }
}
```

### What this means for your model   {#consequences}
- **Do not rely on your app's `security {}` to stop your own workflow.** If a field must never change once a run
  owns it, that is a guard in the body or an `[Immutable]` on the member — not a missing `allow update`. (A kit
  that ships the field can stop you: see [kit denies](#kit-deny). So can naming the one function that may write it —
  `deny update Status except via Close;` holds in your own workflow too.)
- **A read from a page is still gated.** This is about the BODY. What the person then sees on screen goes through
  the ordinary read rules, unchanged.
- **`[Authorize]` is the real perimeter.** An event anybody may raise is an event anybody may cause every write in
  its transition to happen.

## Examples       {#examples}
A whole app, compiled: `Ticket` grants read and create and **no update to anyone**, and the transition writes
`Note` anyway.

```osy title="the body writes a field no rule grants" test app=workflow-body-security
using Osysharp.Workflow;

enum TicketState { Open, Closed }

entity Ticket {
  [MaxLength(80)] string Title;
  TicketState Status = TicketState.Open;
  [MaxLength(200)] string Note = "";
  security { allow read, create when IsAnonymous || IsAuthenticated; }   // no `update`, to anyone
}

workflow TicketFlow {
  Tracks  = Ticket.Status;
  Initial = Open;

  event Close();

  state Open {
    on Close {
      this.Item.Note = "closed by the workflow";   // lands: a body is not gated by the block above
      goto Closed;
    }
  }
  state Closed { }
}

void CloseIt(Ticket t) { TicketFlow.RaiseClose(t); }
```

A deny that names a function holds in the same place — the step calls the function:

```osy title="a workflow step goes through the function the deny names" run app=workflow-body-except-via
using Osysharp.Workflow;

[Principal] entity Cook {
  [Unique, MaxLength(80)] string Name;
  security { allow read when IsAuthenticated; }
}

enum BatchState { Resting, Done }

entity Batch {
  [Required, MaxLength(80)] string Label;
  BatchState Status;
  int Turns = 0;
  security {
    allow read, create, update when IsAuthenticated;
    deny update Turns except via Turn;
  }
}

[Server, Authorize(IsAuthenticated)]
void Turn(Batch batch) { batch.Turns = batch.Turns + 1; }

workflow BatchFlow {
  Tracks    = Batch.Status;
  Autostart = true;
  Initial   = Resting;

  event Nudge();
  event Shortcut();
  event Bake();

  state Resting {
    on Nudge { Turn(this.Item); }                 // the named function: written
    on Shortcut { this.Item.Turns = 40; }         // the step itself: refused
    on Bake { goto Done; }
  }
  terminal success Done { }
}

void NudgeIt(Batch b) { BatchFlow.RaiseNudge(b); }
void ShortcutIt(Batch b) { BatchFlow.RaiseShortcut(b); }

[TestFixture]
void Bakery() {
  new Cook { Name = "Ida" };
  new Batch { Label = "rye" };
}

principal Ida => Cook.Single(c => c.Name == "Ida");

[Test(Bakery)]
[runas(Ida)]
void A_step_changes_the_field_only_through_the_named_function() {
  var rye = Batch.Single(b => b.Label == "rye");
  Workflow.Settle(rye);
  Assert.Denied(() => ShortcutIt(rye));
  Assert.Equal(0, Batch.Single(b => b.Label == "rye").Turns);
  NudgeIt(rye);
  Assert.Equal(1, Batch.Single(b => b.Label == "rye").Turns);
}
```

Gate the EVENT, which is where the person is:

```osy title="gate the EVENT, not the row" syntax
[Authorize(principal => principal == this.Item.Owner)]
event Close();
```

## See also       {#see-also}
- [[Authorize] (event)](https://osysharp.com/reference/workflow/authorize/) — who may raise an event
- [subscribe](https://osysharp.com/reference/workflow/subscribe/) — who may hold a work slot
- [security { }](https://osysharp.com/reference/security/entity-security/) — what the `security {}` block gates
- [The WorkQueues kit — queues, who holds what, and the service levels promised on any item](https://osysharp.com/reference/workflow/inbox-kit/) — the kit whose `deny update Assignee` this rule was built for
- [deny … except via](https://osysharp.com/reference/security/except-via/) — the deny that names the only function that may write, in a request and in every body
