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 everything
API, clients, storage and the app's pages from one model.
Authorization in the model
Out of scope behaves as missing.
Typed outcomes
Stable codes and paths on every failure.
Writes and effects together
One transaction, or nothing.
Flows with undo
Outside calls, compensated in reverse.
Uncertainty recorded
A timeout is not a failure.
Audit history
Who changed what, in the same transaction.
Schedules
Recurring work with no cron to host.
Every change explained
What changes and what happens to the data, before it ships.
Pages for every app
Overview, Playbook and Edit, for each tag.
Readable rules
Every rule reads back as a sentence.
Functions
Your own code for anything outside the model.
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.
| http | GET · POST · PATCH · DELETE by action type |
| graphql | queries, mutations, same outcome types |
| typescript | client.<resource>.<action>(input) |
| storage | migrated per environment on release |
| docs | descriptions become model documentation |
| app pages | Overview, 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
| title | status | owner |
|---|---|---|
| Renew domain | open | bo |
| Invoice Q3 retainer | open | alice |
| Ship the features page | done | alice |
| Rotate mail keys | done | bo |
| Onboard Priya | open | alice |
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
}
}
]
}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
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
Dispatch order
runs by dependency · 2 at a time · one of 12 in Dispatch batch
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
Checked against the declared type. The next step runs.
Known failure
A compensation rule retries, propagates or replaces it.
Uncertain
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.
10:42 · title: Q3 pricing v1 → Q3 pricing
10:39 · note: — → Reviewed with finance
09:12 · title: Q3 pricing v1 · details: 2 tags
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
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.
| Overview | What runs on this tag, its address, and Go back. |
| Playbook | The app's resources and rules, as sentences. |
| Edit | Your own draft of the model, kept when you choose Save draft. |
| Settings | The 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.
- 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