[@layout]: ../docs.layout.md

# Actions

An Action defines a validated operation shared by forms and server callers. Register
Actions through a plugin.

Queries, Actions, and Events use `plugin.member`
identifiers: the Plugin id supplies the owner, and its declaration supplies the
local member name. For example, the `rename` Action below is `notes.rename`.

---

## Rename a personal note

Complete the [local Ada account setup](../accounts/auth.reference.md#local-actor)
in a Quickstart project. Run `bun add zod`, then create `plugins/notes.plugin.ts`:

```ts
import { defineAction, definePlugin } from "@pathmx/core"
import { z } from "zod"

export default definePlugin({
  id: "notes",
  actions: {
    rename: defineAction({
      title: "Rename",
      target: "note",
      input: z.object({ title: z.string().trim().min(1) }),
      available: ({ can }) => can("write"),
      prepare({ input: { title }, target }) {
        return {
          effects: { update: [target.id] },
          run({ tx }) {
            tx.source(target.id).data({ title })
            return { title }
          },
        }
      },
    }),
  },
})
```

This registers `notes.rename`. `target` is guaranteed to be a Source inside both
callbacks. Because this is a general editing operation, `available` requires the
caller's write permission. Action execution requires a current Actor admitted by a credential or local host session. Inspection Views cannot execute Actions. A Home is optional; storage features require it only when creating personal State. Actions are available
through writable live-server or browser hosts. Ordinary static export does not
provide an Action host; see [browser publications](./core.reference.md#browser-publications).

---

## Author and try the form

Create `paths/ada/welcome.note.md`:

```md
---
title: First note
---

# {{ source.title }}

<form
  action="/actions/notes.rename"
  method="post"
  data-pmx-action-target="/ada/welcome.note"
>
  <input type="hidden" name="_target" value="/ada/welcome.note" />
  <label>Title <input name="title" required /></label>
  <button type="submit">Rename</button>
</form>
```

Run `bun run dev`, sign in as Ada, and open `/ada/welcome.note`. Enter “Workshop
ideas” and choose **Rename**. The title changes; reload and inspect the file to
confirm its `title:` metadata was saved. An empty title is invalid. A signed-out
reader cannot read or rename this personal note; sign back in as Ada to continue.
The Home gives Ada write access, while the Action validates the particular edit.

---

## Organize Action code

A repository Plugin explicitly registers its Actions. To split a growing module,
move a `defineAction(...)` value into an imported file and keep
`actions: { rename }` on the owning Plugin. The action's file location does not
supply its public name or install it automatically. Portable hosts install the
same Plugin through `createApp({ plugins: [notes], ... })`.

Definitions register and validate before Plugin setup and Source indexing.
Restart the host after changing executable Plugin or Action code; Source content
continues to update through normal live sync. See the
[Plugin tutorial](./plugins.guide.md#organize-and-compose-plugins) for entrypoints.

---

## Targets and availability

Declare `target: "note"` to require that Source type, or use a predicate such as
`target: (source) => source.type === "note" || source.type === "draft"`.
The predicate may return a Promise when matching requires authored model preparation.
Omitting `target` declares an untargeted Action: supplying a target is then invalid,
and callback `target` is `undefined`.

Core checks the declared target and its execute permission before calling
`available`. The callback receives the caller's `view`, the current `target`, and
`can(capability)` for that target. Return:

- `true` to make the Action available; this is the default when omitted.
- `false` to hide it from discovery and reject execution.
- A string to describe why it is unavailable.
- An object with `status`, `reason`, `title`, `description`, `rel`, or `boundInput`
  to customize its affordance. Status defaults to `available`; bound input is
  merged over object input before schema validation. Bound input requires an object payload.

A `title` makes targeted Actions discoverable through Source affordances. Actions
without a title still enforce availability. Core checks availability again against
the current target inside the prepared commit before validating and executing the
operation.

---

## Form targets and input

The [complete form above](#author-and-try-the-form) sends the canonical Source id
in `_target`. It is transport metadata and is removed before validating input;
a Referer header never supplies authority or a target.

For forms authored within a Source, the Actor Path plugin supplies that containing
Source as the default `_target`. When explicitly targeting another Source, include
`data-pmx-action-target` as in the complete form so it preserves your supplied target. Custom
clients must send their target explicitly. A missing or incompatible target rejects
the request before the Action callbacks run. An admitted call that omits a required
target reports that an explicit target Source id is needed; missing or inaccessible
Sources still receive a generic rejection.

Native form fields arrive as strings or string arrays. An input schema validates
and transforms them before `prepare`; an optional output schema validates the
result before committing. Programmatic and JSON callers can supply structured values.

---

## Invoke through a View

Use `view.actions.run(name, input, { target })`. The runner retains that exact View
through availability, preparation, transaction reads, and the receipt. `target` is
an optional Source id; it is required when the Action declares a target.

```ts
import type { AppContext, CredentialSession } from "@pathmx/core"

export function renameNote(
  app: AppContext,
  session: CredentialSession,
  title: string,
) {
  const view = app.view(session)
  return view.actions.run(
    "notes.rename",
    { title },
    {
      target: "/ada/welcome.note",
    },
  )
}
```

`app.view(session)` is a trusted host entry, available on the application and
plugin contexts. A request handler obtains its caller View with
`ctx.view(await ctx.session())`. The session comes from credential admission, not
caller-supplied form data. Trusted background services can explicitly admit a
separate service View for a configured service Actor.

An existing View cannot choose an Actor override. Invocation options accept
`target`, `request` and `stream`, which [completes a Block stream](#stream-blocks-before-saving). Ordinary HTTP Action forms also invoke through their admitted View.
Anonymous and inspection Views reject execution. A retained View rechecks current
Actor and target access when the operation runs.

---

## Stream Blocks before saving

Generated content can appear in a Source while it is being written, then be saved
once. Open a stream through a View that can write the Source, write the Markdown so
far, and commit it:

```ts
const stream = view.stream("/lessons/bridge.page", { after: "goals" })
for await (const text of response.partials) stream.write(text)
await stream.commit()
```

- `{ after: id }` inserts new Blocks after a Block; `{ block: id }` fills one
  Block's body, and its text stays one Block.
- Each write replaces the Markdown so far. Everyone who can read the Source sees it
  in place, refreshed at most every 100 ms, without anything being saved.
- Streamed Blocks render like saved ones and carry `data-pmx-streaming`. New
  Blocks without an `id`, or with one another Block in the Source already has, get
  one, which they keep when saved.
- Unfinished Markdown is shown safely, for display only:
  - **held** until complete when a partial form would render as something else:
    comments and topmatter, a tag still being written, a component that has not
    closed, a directive such as `[@include]` or an image whose target is still
    being written, a table header before its delimiter row, a lone list marker
    or separator;
  - **closed** temporarily: fences, `$$` math, code spans, `**`, `~~`, and an
    ordinary link, which shows its text;
  - **deferred**: an open fence drops its language, so highlighting, diagrams and
    fenced renderers wait for the closing fence. Code inside a fence is shown as
    written.

  Components are custom elements, so any tag with a hyphen waits for its closing
  tag (or `/>`) and never renders with half its props or content, unless the
  component [shows partial content](./plugins.reference#components). Other HTML
  closes at the end of its Block.
- Current write permission and contextual admission govern publication as well
  as saving. Losing either ends the stream and withdraws its unsaved content.
- `commit()` saves the Markdown with the View's authority; the stream ends in the
  same update, so readers never see it twice. Further writes and duplicate commits
  reject while a commit is pending. `cancel()` discards the stream until commit
  preparation finishes; once persistence starts, the commit completes.
- If a replacement Block's body changed since the stream opened, `commit()` ends
  the stream and rejects with `SourceConflictError`, preserving the newer saved
  edit. Changes to unrelated Blocks do not prevent a commit.
- A stream lives in the host process: a restart drops it, and only committed
  content remains.

To validate or record more than the Markdown, save through your own Action and name
the stream it completes; it ends with that Action's commit:

```ts
await view.actions.run("lessons.save-section", { markdown }, { target, stream })
```

---

## Retry a Save

PathMX's HTTP connection gives each Save a hidden request identifier. An interrupted
response or a busy/restarting host is retried with the same identifier and payload,
up to four attempts. If those attempts cannot confirm the result, the control reports
an uncertain outcome; retrying the same Save through that connection retains its
identifier. Built-in controls preserve the failed form and offer Reload after those
attempts are exhausted. A confirmed Save ends that invocation; another Save gets a new identifier.
The identifier requires no authored form field or Plugin change.

Server callers can retain an identifier explicitly:

```ts
const request = crypto.randomUUID()
const rename = () => view.actions.run(
  "notes.rename",
  { title: "Workshop ideas" },
  { target: "/ada/welcome.note", request },
)
const receipt = await rename()
const confirmed = await rename() // The original receipt; no second write or job.
```

`view.actions.submit(name, fields, { request })` provides the same behavior for
form fields. Direct HTTP callers send the identifier as `Idempotency-Key`, alongside
their usual same-origin credentials and `Accept: application/json`. Identifiers
contain 1–128 letters, digits, dots, underscores, colons or hyphens.

The durable host stores the original receipt atomically with the committed marker
and Event admission. Completed operations retain a compact summary instead of
full before/after Source snapshots; unresolved saves retain their recovery data.
Retrying under the same admitted identity returns that receipt, including after a
restart, without repeating the Action or admitting its jobs again. Current Actor,
target and Action availability checks still apply. Reusing an identifier with a
different Action, target, context or payload is rejected; changed work needs a new
identifier. Request-backed input and output must be JSON values; a void output is
also supported. Bun and Cloudflare use this shared behavior. A Bun site without
Plugin jobs opens its durable journal on the first request-backed Save; an existing
journal is opened and recovered at startup.

Keep the identifier until the outcome is known. Closing the connection discards its
automatic retry memory; callers that need recovery across a closed connection retain
an explicit identifier. Native browser form POSTs without PathMX's client connection
do not add an identifier automatically.

---

## Structured input and target schemas

Action input can be an object, array, scalar, or any other value accepted by its
Zod schema. `view.actions.run` passes domain values directly. HTTP JSON callers
POST an envelope to `/actions/<name>` with `Content-Type: application/json`:

```json
{
  "target": "/lessons/contrast.quiz",
  "input": {
    "submission": "attempt-1",
    "responses": {
      "assessment": {
        "choice": "contrast",
        "explanation": "The text is easier to read."
      }
    }
  }
}
```

This example invokes `/actions/input.submit`. `target` is transport metadata;
`input` is the complete domain payload. Request JSON may contain only these two
keys. Browser requests require same-origin credential admission; send
`Accept: application/json` for the Action receipt.

An Action's `input` can also be a schema resolver returning a schema or a Promise
of a schema. It receives the
same `{ target, view, app, can }` context as availability, after target admission:

```ts
import { defineAction } from "@pathmx/core"
import { z } from "zod"

export const choose = defineAction({
  title: "Choose",
  target: "question",
  input: ({ target }) =>
    z.strictObject({
      choice: z.literal(String(target.data.choice)),
    }),
  prepare({ input }) {
    return { run: () => input }
  },
})
```

Source discovery publishes the resolved input JSON Schema. Execution resolves it
again against the current Source, so stale discovered choices cannot bypass current
validation. Static schemas remain useful for operations whose shape is fixed.

Input derives its schema from each Source's authored responses. Call
`view.actions.run("input.submit", { responses: { [response]: values }, submission }, { target })`.
Each `responses` key identifies an authored response Block; optional `submission` identifies an
attempt for contributions such as Assessment to recognize retries. `values` uses
domain field names, with text strings, numeric answers, and arrays for multiple
choices or sequences. Unknown fields fail validation. Conditional responses require
and save only active fields; their schema describes this runtime rule.

Generated Input forms use `/actions/input.submit`. Input's Action form decoder converts native
controls and JSON-encoded `_input.*` custom fields into this same domain payload,
then invokes `input.submit`. Forms and programmatic callers share validation,
State writes, Assessment grading, and contribution replay checks.

---

## Native form decoding

`view.actions.submit(name, fields)` accepts ordered `[name, value]` text pairs.
It consumes `_target` as the target Source id and rejects repeated or empty targets.
Repeated domain fields remain arrays. Binary fields are rejected before invocation.

An Action may define `form({ fields, view, app, target, can })` to convert native strings and arrays
into its structured input. The decoder runs within serialized preparation after
target access and availability checks. Its result then receives bound input and
normal schema validation before `prepare` runs. Without a decoder, the text-field
record is the input. Programmatic `actions.run` already receives structured input
and does not invoke the decoder.

```ts
const recordQuantity = defineAction({
  target: "order",
  form: ({ fields }) => ({ quantity: Number(fields.quantity) }),
  input: z.strictObject({ quantity: z.number().int().positive() }),
  prepare: ({ input }) => ({ run: () => input.quantity }),
})
```

Generated Input forms name `input.submit` in their compiled metadata and use the
generic `/actions/input.submit` endpoint. HTTP performs origin and credential checks
before calling the same View runner used by an in-process or worker host.

Browser Action reconciliation is automatic. If live delivery has already applied
the committed state, Core can reuse that read; otherwise it refreshes the current
View. Action authors do not manage freshness versions or opt into this behavior.

JSON Action failures include an `outcome` field in their Problem Details body:
`rejected`, `partial`, or `uncertain`. An HTTP status alone does not establish
whether persistence occurred. Schema validation failures also include `issues`, an
array of `{ path, message }` entries. View connections retain these on the rejected
Action failure, allowing a form to identify its failing field without parsing the
message. Core includes the validation location and message without adding the rejected input
value to the entry.
A successful receipt remains successful if later
projection or page refresh fails; retry the read, not the Action. See
[View connections](./core.reference.md#view-connections).

---

## Prepare bounded effects

`prepare({ input, view, app, principal, operation, target })` receives validated
input and one named context. Resolve optional Homes through
`app.access.home(actorId)` on the supplied Plugin-scoped `app`; domain plugins
interpret their own roles. Derive ids and perform
read-only preparation here. It has no transaction or Event recorder and must not
perform external side effects. Return `{ effects, run }`; `run({ tx, events })`
stages the operation using the values captured during preparation.

Effects are finite lists of canonical Source ids, without `.md`:

- `read` grants additional service reads beyond the caller's View.
- `create`, `update`, and `delete` grant exactly those respective changes.
- Omitting a list grants nothing for that operation. Omitting `effects` permits
  ordinary View reads but no Source writes.

An admitted domain Action can write its declared Sources even when the caller has
no general write permission. That lets a submission service save a private attempt,
or a badge Action update only the participant's name. The trusted Action must derive
its effects from its domain rules; copying arbitrary caller-supplied ids into effects
would authorize those ids. General editors should require the caller's write
permission, as in the example above.

A write grant does not grant a hidden read. Updating through `tx.source(id)` or
deleting through `tx.delete(id)` also needs `read: [id]` when the View cannot read
that Source: both operations read existing content before applying the change.
When an admitted update/delete effect is missing this read declaration, the Action
reports `action-read-undeclared` and asks you to add the target to `effects.read`.
Contextual policy denials remain generic; declaring a read effect cannot override
those restrictions. Newly created content can be read within its transaction.
Create effects cannot overwrite an existing Source, even when that Source is hidden.
Declare and create the same canonical typed id, such as
`/ada/approvals/first.mentor-approval`. Use that exact id in `effects.create` and
`tx.create(id, content)`. An untyped Markdown filename normally has a `.page` id;
adding `type` frontmatter does not repair a mismatched create declaration.
Check the typed id and all required effects when a create is denied; visitor
errors intentionally do not disclose hidden Sources.
Core snapshots the effect lists before `run`, checks staged changes, and retains
Source grant and attachment invariants. A [contextual View](./views.reference.md)
further restricts named invocation and each declared service effect. Effects are private execution details and
are not exposed in affordances.

Effects also preserve Source authority. They cannot create or promote an Actor,
change a Source's path-derived identity through `type` frontmatter, alter Auth User
email/role policy, or create/remove/retarget a Home claim. Use an explicit trusted
repository-management operation for those authoring changes. A first Block body
replacement that begins like YAML frontmatter is rejected when it could become
Source metadata.

`tx.source(id).data(patch)` replaces only the supplied top-level values. For example:

```ts
tx.source(target.id).data({ cover: "./cover.png", art: { style: "ink" } })
```

This changes `cover` and replaces the entire `art` value; it does not deeply merge
nested maps. Untouched YAML entries retain their formatting, comments and order,
and the Markdown body stays byte-for-byte unchanged. New keys are appended.
A supplied `undefined` or empty string removes that key; `null` is a stored value.
An empty patch does nothing. Comments inside a replaced nested value belong to
that replacement. An edit that would leave invalid YAML, such as deleting an
anchor still used by another field, fails before commit.

`tx.source(id)` also edits Blocks: `appendBlock`, `prependBlock`, `updateBlock(id, …)`,
`removeBlock(id)` and `moveBlock(id, { before })` or `moveBlock(id, { after })`.
Block edits preserve Source metadata, author comments and the Markdown meaning of
unaffected content. Core reuses existing sections where possible and serializes
changed YAML through the shared YAML utilities. Ordinary formatting may normalize;
exact quoting, spacing and line endings are not a general mutation guarantee.
Updating only a Block body retains its metadata comments, and changing only its
metadata retains the body. Comments inside a replaced metadata value belong to
that replacement.

A Block with an authored id (`<!-- id: plan -->`) keeps it wherever it moves. A Block
without one is addressed by its position (`block-2`), so moving Blocks changes which
Block a positional id names; give a Block an authored id when code needs to find it
again after reordering.

```ts
tx.source(target.id).moveBlock("demos", { before: "plan" })
```

Preparation, execution, output validation, and staging occur inside one prepared
commit. Source changes and recorded Events publish only after it succeeds. A failed
read, undeclared write, invalid result, or invalid Event aborts the staged changes.
This bounds the transaction offered to trusted plugin code; it is not a sandbox
for installed JavaScript.

---

## Compose Input submissions

Start with [Assessment's complete setup and question Source](../learning/assessment.reference.md#install-and-configure),
including dependencies, Actor/Home admission, and a submission result. The stock
Assessment package preset connects to the existing `input.submit` Action without
a repository Plugin. Its equivalent composition is:

```ts
import { definePreset } from "@pathmx/core"
import { createInputPlugin } from "@pathmx/core/plugins"
import { createAssessmentPlugin } from "@pathmx/assessment"

const assessment = createAssessmentPlugin()
const input = createInputPlugin({
  authoring: [assessment.input],
  submissions: [assessment.submission],
})

export default definePreset([input.plugin, assessment.plugin])
```

A custom `InputSubmissionContribution` defines `prepare(submission, context)` and
returns a plan with `effects`, optional `replayed({ tx })`, and
`run({ tx, events })`. Return `undefined` when the contribution does not apply.
The replay transaction exposes only `read`, `readSource`, and `exists`; it has no
write methods or Event recorder. The submission includes the origin Source, planned
Input State id, retry identity (`revision`), and a `responses` map from Block ids
to validated field values. Single-Block and multi-Block saves use the same map.

Input combines the plans' effects with its own State write. It runs read-only replay
checks before staging any writes or Events; a matching replay returns the existing
submission identity. Otherwise it stages Input State and every contribution in the
same transaction. Assessment uses this to preserve immutable attempts, update its
latest-result pointer, and avoid rerunning grading on a replay. A replay checker must
reject reuse of an identity with different answers.

---

## Uncertain durable operations

For an Action invoked by a durable job, use
[`step.action`](./jobs.reference.md#save-an-action-as-a-step). It saves the receipt
with the Action commit and reuses it on replay without repeating Source changes
or Event admission. Ordinary `view.actions.run` remains a new invocation per call
unless you retain an explicit [request identifier](#retry-a-save).

When a durable host cannot confirm an operation's result, its Action failure can
include an `operation` id. Preserve that id and inspect it with
`bun run pmx jobs operation '<operation-id>'`. Recovery checks the saved Source
intent before normal access resumes; see [durable jobs](./jobs.reference).
