Features

Every feature is a guarantee.

Not a list of things zenn.ing has. A list of things zenn.ing promises about the systems you build on it, and the mechanism that keeps each promise.

derive

Model once. Everything else is generated.

A model is a few screens of structured data. The compiler validates every reference and rule, then derives and hosts the surfaces you would otherwise write and drift apart by hand.

  • HTTP routes and GraphQL schema with one shared outcome envelope.
  • TypeScript clients with typed inputs and typed failure codes.
  • Storage provisioned and migrated from field and relationship declarations.
  • The app's Playbook page and its documentation from the same source.
httpGET · POST · PATCH · DELETE by action type
graphqlqueries, mutations, same outcome types
typescriptclient.<resource>.<action>(input)
storagemigrated per environment on release
docsdescriptions become model documentation
app pagesOverview, Playbook and Edit for each tag

authorization

Authorization is part of the model.

A resource's scope rule constrains every read, update and delete. A record outside a caller's scope behaves as missing rather than forbidden-but-visible. Try the same table as three callers.

  • An allow rule decides whether the caller may run the action at all.
  • Trusted caller context comes from the platform, never from the request body.
  • Rules are expressions over fields, relationships and context, checked by the compiler.

todo · list · 3 of 5 visible

titlestatusowner
Renew domainopenbo
Invoice Q3 retaineropenalice
Ship the features pagedonealice
Rotate mail keysdonebo
Onboard Priyaopenalice

outcomes

Every action returns a typed outcome.

Success carries a value. Failure carries a kind, a stable code and a path. Messages explain; codes stay stable for your code to rely on. Generated clients return the same outcome types over HTTP and GraphQL.

  • Validation rules name their own codes, such as todo/limit-reached.
  • Kinds map to HTTP status: input 400, authorization 403, not-found 404, conflict 409, validation 422.
  • A path tells a form which field to mark.
{
  "status": "error",
  "kind": "validation",
  "errors": [
    {
      "code": "todo/limit-reached",
      "path": [
        "input",
        "owner"
      ],
      "data": {
        "open-count": 5
      }
    }
  ]
}
200 ok409 conflict/precondition422 validation

effects · outbox

Writes and effects commit together.

An action that emits an effect records it in the outbox in the same transaction as its write. A failed action commits neither. A worker delivers later, at least once, with a stable delivery ID for deduplication.

  • The effect sees the stored record after the write, including generated keys.
  • A successful write never claims the email arrived. The outbox tracks that separately.
  • A delivery that keeps failing is held, visible and retryable, never silently dropped.

one transaction

todo 0192f2b1 · status → donewrite
outbox · audit record · dlv_01J9…effect

Roll back the action and the outbox row goes with it. There is no window where one exists without the other.

flows · undo

Outside calls, with undo declared beside them.

When a step needs a result from the outside world, use a flow. Steps run in order or by dependency, call effects or actions, and read earlier results. If a later step fails, completed steps run their undo in reverse.

  • The compiler checks step inputs, effect arguments and result paths.
  • Undo is another operation, so it works across services and storage groups.
  • Independent steps run concurrently, with a limit you choose.
  • A release-level flow calls actions in several domains; a subscription runs another domain's action when one emits an effect.
  • Repeating a request with the same idempotency key returns the saved outcome.

Place order

inputs: sku, quantity, card · runs in order

Place orderpendingHTTP · POST /flows/place-order
Charge cardpendingeffect · payments
Create orderpendingaction · order.place · uses charge id
Send receiptpendingeffect · email
Outcomependingsaved under key ord_7f31
Refund chargependingundo of Charge card
Cancel orderpendingundo of Create order
undo lane · runs in reverse when a later step fails
+0000msIdle. Durable progress is journaled, so a restart resumes where it left off.

Dispatch order

runs by dependency · 2 at a time · one of 12 in Dispatch batch

Dispatch orderdonechild flow · key: order id
Reserve stockdoneeffect · orders
Book deliverydoneeffect · ran alongside stock
Dispatchdonewaits for both results
Outcomedonereported to the batch
Cancel dispatchpendingundo of Dispatch
undo lane · kept until the batch completes
+0000msstock and delivery started together, limit 2.
+0000msdispatch started once both checkpoints existed.
+0000msOutcome: ok. 11 of 12 orders done, 4 running at a time.

uncertainty

Uncertain is not failed.

An external request can time out after the provider did the work. zenn.ing does not guess. It records the step as uncertain and waits for a person; automatic retry is reserved for supplied connectors that declare themselves idempotent.

  • Your functions are never retried behind your back.
  • A known failure can match a compensation rule: retry, propagate, or substitute a typed replacement.
  • Uncertain and invalid results need attention and never undo prior work on their own.

Result

continue

Checked against the declared type. The next step runs.

Known failure

rule

A compensation rule retries, propagates or replaces it.

Uncertain

needs attention

Timed out or invalid. A person decides, and nothing is undone.

audit

Auditable from the model, not bolted on.

Because every write, rule and effect is declared, the runtime knows who did what, under which rule, and what happened next. Retaining that is a setting on the resource, not a project.

  • Audit history keeps field changes in the same transaction as the write.
  • Every request records its caller context and outcome.
  • Reading history is itself a permission.
alice edited Q3 pricing via edit

10:42 · title: Q3 pricing v1 → Q3 pricing

bo edited Q3 pricing via edit

10:39 · note: — → Reviewed with finance

alice created Q3 pricing via create

09:12 · title: Q3 pricing v1 · details: 2 tags

secret is never retained

Written, never recorded in history, never returned.

schedules

Recurring work, with no cron to host.

A schedule runs an action or a flow on a period you declare. The Enquiries example closes every answered enquiry whose reply is 30 days old, once an hour. The 30 days is a rule in the model; the schedule only says when to look.

  • Every run is an execution with its own history.
  • The platform ticks the clock. There is no cron job or worker for you to host.

Close stale enquiries

every 1 hour

runs enquiry · close-stalenext in 42 min

releases

Every change says what it changes, before it goes live.

Shipping opens one drawer that states the effect before it asks for anything: what changes in the playbook, in plain words; what happens to each record's data; and what the release needs, such as a secret or another app it calls. Production takes the change as a new release. Going back to an earlier release opens the same drawer, and is refused when production has applied a storage change that release does not have.

  • Releases are immutable; production selects one.
  • A migration that would remove data is named and refused, not hidden.
  • Who shipped what, and when, is recorded with every release.

Ship Enquiries → Production

from your draft

What changes

Answered enquiries close themselves after 30 days.

What happens to the data

Nothing is removed. 142 enquiries keep everything.

What it needs

Nothing new. Mail is already connected.

Going back to the 1 Oct version opens this review too

your apps

Every app has its own pages.

Open an app and choose a tag, Production or one you named. Overview says what runs there and where going back starts; Playbook reads the rules as sentences; Edit changes your own draft, never production directly.

  • Each member edits their own development copy of production; only the organisation's owner ships to production.
  • Rules read back as sentences, so a reviewer needs no code.
OverviewWhat runs on this tag, its address, and Go back.
PlaybookThe app's resources and rules, as sentences.
EditYour own draft of the model, kept when you choose Save draft.
SettingsThe app's name and details. Tags and variables live under Environments.

the model

A model you can read in a review.

Rules are small expressions over fields, relationships and caller context. The editor shows each one as a sentence, and descriptions compile into documentation.

  • Compiled and checked before a release.
  • Every operator and error code is documented with a working example.
membership · enrol-invitedallow · all of
  • the caller is authenticated
  • the caller's email is confirmed
  • the invitation was sent to that confirmed email
  • the invitation has not been revoked
  • the invitation has not expired at request time

Description: Only a confirmed identity with a live invitation may join.

functions

Anything outside the model is a function.

Declare an effect in the model, name its inputs and result, and write its handler as a TypeScript or JavaScript module at a path named after the effect. It reads the variables your project declares and calls other services with fetch; your actions and flows call it through the effect. The platform runs it in an isolated worker, so there is no server for you to keep.

  • Inputs and results checked against the effect's declaration.
  • Variables and secrets are set per environment. Secrets are never shown in the browser; PUBLIC_ variables reach your pages, so never put a secret in one.
  • Effects the platform handles, such as mail, need no function.
// domains/shop/functions/weather/report.ts
import type { Functions } from "zenn/shop"

const report: Functions["weather/report"] =
  async (input, { env }) => {
    const url = new URL("https://api.weather.example")
    url.searchParams.set("lat", String(input.latitude))
    url.searchParams.set("lon", String(input.longitude))
    const res = await fetch(url, {
      headers: {
        authorization: `Bearer ${env.WEATHER_API_KEY}`,
      },
    })
    if (!res.ok) throw new Error("No weather service")
    const body = await res.json()
    // checked against the declared result type
    return { temperature: body.temp, "observed-at": body.at }
  }
export default report