II Business Logic

Loops and Read-Only Calls

Two exceptions come from how components are wired together, not from a mistake in one operation: LoopException, when calls and events go round in a circle, and SafetyException, when a GET request reaches an operation that may write. Both are refused before the operation runs. You write nothing for the checks; you meet them as failures.

§A call refused as a loop

LoopException: 'store.orders.refresh' is hop 3 of this chain
  trail: exposition > store.orders.refresh > ~store.orders.updated >
         default.accounts.recount > ~default.accounts.updated > store.orders.refresh

accounts, the component that keeps the customers, counts a customer’s open orders with a receiver on the changes of store.orders. Somebody later added a receiver in store.orders that refreshes an order when its customer’s account changes:

# components/accounts/manifest.toa.yaml
receivers:
  store.orders.updated: recount
# components/store.orders/manifest.toa.yaml
receivers:
  accounts.updated: refresh

accounts has no namespace, and a trail writes such a component as default.accounts.

Each is reasonable alone. Together an order update changes the account, which changes the order, which changes the account, and nothing would ever stop it. The third visit to the same operation on one path is refused.

§Read the trail

Read it from the left; the repeated hop is the circle.

HopWritten
an operationstore.orders.refresh
an event~store.orders.updated
a serviceexposition (the gateway)

§Fix the subscription

What you change is almost never the operation. A circle is closed by a receiver that writes back to the component whose event it listens to, so look at the ~ hops. Every component has created, updated, deleted and sync events whether it declares them or not, so a receiver on somebody’s updated or sync is enough to make one.

  • Listen to a narrower event, one with a condition, so that the write a receiver causes does not publish the event that triggers it.
  • Do not write when there is nothing to change: a transition that discards its change publishes nothing.

§Where you see it

  • In a call you made: thrown at the await, with the path in its trail. Let it through. Over HTTP it is a 500.
  • In an event or a task: nobody is waiting, and the next attempt would be refused the same way, so the message is set aside for an operator at once, with the trail as its reason; see Operating. This is the usual case.

The refused call read, committed and published nothing. What ran before it stays as it was: in the example each component was written twice.

§What is not a loop

  • An operation that calls the same endpoint fifty times, in a loop or concurrently: each call is a path of its own.
  • Work that repeats on a pulse or a schedule: every call starts afresh.

§An operation that re-arms itself

A call made with context.delay continues the path that armed it. An operation that delays a call to itself is a circle, however slow, and its third round is refused. Say that the next round starts afresh:

await context.delay(endpoint, request, { interval, overdue: null, unchained: true })

§An operation meant to call itself

A walk over a tree of categories comes back to the same operation on purpose. Declare it unchained:

# components/store.catalog/manifest.toa.yaml
operations:
  walk:
    type: computation
    unchained: true
    input:
      type: object
      properties:
        category: { type: string }
        depth: { type: integer }
      required: [category, depth]
    output:
      type: integer
// components/store.catalog/operations/walk.ts
import type { Context, WalkInput } from '../types/index.d.ts'

export async function computation (input: WalkInput, context: Context) {
  if (input.depth === 0) return 0 // the stop is yours to write

  const below = await context.remote.store.catalog.walk({
    input: { category: input.category, depth: input.depth - 1 }
  })

  return below + 1
}

Every call to an unchained operation starts a path of its own, so a circle through it is never refused. Stopping is the operation’s responsibility. A circle elsewhere is still refused.

§Limits

A call is refused whenDefaultVariable
it would enter the same hop this many times on one path3TOA_TRAIL_REPEATS
its path has grown to this many hops32TOA_TRAIL_DEPTH

Both are environment variables for the whole deployment. TOA_TRAIL_REPEATS=0 refuses no repeats, and is the way out if a deployment starts refusing calls it has always made; the depth limit still applies. Read the refused trail before leaving it off: a handshake that goes a > b > a > b > a is three visits to a, and stays refused until it is written another way or a is declared unchained.

§A read that reaches a write

SafetyException: 'accounts.touch' may change state, and this call may only read

Two things may only read, and so may every call made on their behalf, however deep:

  • an observation or a computation, whatever it is serving. It cannot change state itself, and it does not change it through somebody else either;
  • a request that arrives at the gateway as GET or HEAD, whatever operation it reaches.

An observation that calls a transition therefore fails, every time:

// components/store.orders/operations/summary.ts
import type { Context, Entity } from '../types/index.d.ts'

export async function observation (input: null, entry: Entity, context: Context) {
  // throws: `touch` is a transition
  await context.remote.accounts.touch({ query: { id: entry.customer } })

  return { status: entry.status, total: entry.total }
}

touch stands for any transition of another component. An operation that has to call something that writes is an effect.

§What is refused

A call to an operation that may change state, whether or not this call of it would:

TypeFrom an observation, a computation, or a GET or HEAD request
observation, computationproceeds
transition, assignment, effect, unmanagedrefused

It is refused where the call is made, before it is sent. Nothing is read, committed, published or queued, and there is nothing to pass in a request that lifts it.

Of requests, only these are read-only:

Read-onlyNot read-only
GET, HEADPOST, PUT, PATCH, DELETE, LOCK, UNLOCK
a procedure or a tool served under GETan event, a task made elsewhere, a pulse, a delayed call

What the gateway does on its own behalf around a request, such as reading a credential, is not held to it.

§Where you see it

  • In a call you made: thrown at the await, with the path in its trail. Let it through. Over HTTP it is a 500.
  • In an event or a task: nobody is waiting, and the next attempt would be refused the same way, so the message is set aside for an operator at once, with the trail as its reason; see Operating. This is the usual case.

The refused call read, committed and published nothing. What ran before it stays as it was: in the example each component was written twice.

§What is not a loop

  • An operation that calls the same endpoint fifty times, in a loop or concurrently: each call is a path of its own.
  • Work that repeats on a pulse or a schedule: every call starts afresh.

§An operation that re-arms itself

A call made with context.delay continues the path that armed it. An operation that delays a call to itself is a circle, however slow, and its third round is refused. Say that the next round starts afresh:

await context.delay(endpoint, request, { interval, overdue: null, unchained: true })

§An operation meant to call itself

A walk over a tree of categories comes back to the same operation on purpose. Declare it unchained:

# components/store.catalog/manifest.toa.yaml
operations:
  walk:
    type: computation
    unchained: true
    input:
      type: object
      properties:
        category: { type: string }
        depth: { type: integer }
      required: [category, depth]
    output:
      type: integer
// components/store.catalog/operations/walk.ts
import type { Context, WalkInput } from '../types/index.d.ts'

export async function computation (input: WalkInput, context: Context) {
  if (input.depth === 0) return 0 // the stop is yours to write

  const below = await context.remote.store.catalog.walk({
    input: { category: input.category, depth: input.depth - 1 }
  })

  return below + 1
}

Every call to an unchained operation starts a path of its own, so a circle through it is never refused. Stopping is the operation’s responsibility. A circle elsewhere is still refused.

§Limits

A call is refused whenDefaultVariable
it would enter the same hop this many times on one path3TOA_TRAIL_REPEATS
its path has grown to this many hops32TOA_TRAIL_DEPTH

Both are environment variables for the whole deployment. TOA_TRAIL_REPEATS=0 refuses no repeats, and is the way out if a deployment starts refusing calls it has always made; the depth limit still applies. Read the refused trail before leaving it off: a handshake that goes a > b > a > b > a is three visits to a, and stays refused until it is written another way or a is declared unchained.

§A GET that reaches a write

SafetyException: 'store.orders.approve' may change state, and this call may only read

A request that arrives at the gateway as GET or HEAD may only read, and so may every call made on its behalf, however deep. An observation that calls a transition somewhere below fails when it is reached by GET:

// components/store.orders/operations/summary.ts
import type { Context, Entity } from '../types/index.d.ts'

export async function observation (input: null, entry: Entity, context: Context) {
  // throws under GET: `touch` is a transition
  await context.remote.accounts.touch({ query: { id: entry.customer } })

  return { status: entry.status, total: entry.total }
}

touch stands for any transition of another component.

§What is refused

A call to an operation that may change state, whether or not this call of it would:

TypeUnder a GET or HEAD request
observation, computationproceeds
transition, assignment, effect, unmanagedrefused

It is refused where the call is made, before it is sent. Nothing is read, committed, published or queued, and there is nothing to pass in a request that lifts it.

Only the gateway starts such a request:

Read-onlyNot read-only
GET, HEADPOST, PUT, PATCH, DELETE, LOCK, UNLOCK
a procedure or a tool served under GETan event, a task made elsewhere, a pulse, a delayed call

What the gateway does on its own behalf around a request, such as reading a credential, is not held to it.

§Where you see it

  • In a call you made: thrown at the await. Let it through; over HTTP it is a 500. The route is what is wrong, not the client.
  • context.delay: arming a delay from an observation or a computation, or under a read-only request, throws, whatever the delayed call would have been.
  • A task: a read-only request may hand a task to an observation or a computation, and that operation may only read too. If it calls something that may write, the task is set aside for an operator at once.
  • An event: never.

§Fix it

An observation or a computation that calls a write is declared an effect, or stops making the call.

Where the request is what may only read, serve the operation under a method that may write, POST or PUT; see Methods.

A route can also state that its GET does change state, with the io:readonly directive:

# components/store.orders/manifest.toa.yaml
exposition:
  /:id/confirmation:
    GET:
      endpoint: confirm # a transition, reached by a link in an email
      io:readonly: false

HEAD is served by the same method, and writes with it. The directive is inherited, so a node states it for every method under it; io:readonly: true holds any other method to reading. It is described in Input and Output.

This is not recommended. Other software repeats a GET freely: a crawler walking your links, a browser prefetching one and a cache revalidating a stored reply each make the request, and none of them asked for what it does. A tool exposed to a model is honest either way: its read-only hint follows what the route declares, not the verb.

§What neither check covers

  • Neither is a security boundary. They stop an application wired wrongly by accident.
  • A lock taken through context.atom, a request through context.fetch and whatever an unmanaged operation does with its storage are not calls to an operation, and a read-only request does not restrain them.

§Summary

You wantYou write
circles refusednothing
an operation that re-enters itself on purposeunchained: true on the operation
a delayed call that starts afreshunchained: true in the options of context.delay
a GET that only reads, all the way downnothing
an operation that reads and calls a writean effect, not an observation or a computation
a GET that writesio:readonly: false on the route

The idea behind both checks is described in Call Chains.

Search
Search the documentation