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 in a Quickstart project. Run bun add zod, then create plugins/notes.plugin.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.

Author and try the form

Create paths/ada/welcome.note.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 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 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.

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. 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:

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. 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:

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:

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:

{
  "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:

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.

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.

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 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:

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.

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, 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:

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. 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.

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.