Core API

Use the Bun adapter to host a PathMX repository programmatically. For ordinary site authoring, the CLI supplies this setup.

Choose the job you need:

Run a host

Use an initialized Quickstart project with its welcome page. Create host.ts in the project root:

import { PathMX, handleRequest } from "@pathmx/core/bun"

const app = await PathMX({ root: "./paths" })
const server = Bun.serve({
  hostname: "127.0.0.1",
  port: 3000,
  fetch: (request) => handleRequest(app, request),
})
console.log(`Open ${server.url}`)

const stop = async () => {
  server.stop()
  await app.close()
  process.exit(0)
}
process.once("SIGINT", stop)
process.once("SIGTERM", stop)

Run bun run host.ts and open http://localhost:3000/: it serves the authored welcome page. curl -I http://localhost:3000/api/runtime/health returns 204 for a healthy repository. Stop with Ctrl+C to close the application. Choose another port if 3000 is occupied. Installed production Auth requires its configured credentials; see Authentication.

PathMX() supplies the Bun environment, default plugins, and repository plugin discovery. Host lifecycle owns shutdown and resource cleanup. Expected authored Source errors are collected by default; inspect app.sourceDiagnostics() for their details. Invalid Sources and their affected consumers remain unavailable while unrelated content works. See Source recovery. Use PathMX({ root: "./paths", diagnostics: "throw" }) when a validation caller needs startup to fail on the first authoring diagnostic. Storage and host failures still throw in either mode. Define validated domain operations with the Action API. Trusted plugin contexts also provide app.view(session); invoke Actions through that admitted View. Server routes use ctx.view(await ctx.session()). They can serve a mounted contextual View with ctx.serve(view, { basePath }). Trusted hosts can select a domain context with app.view(session, { context: sourceId }).

Portable composition

The package root exposes runtime-neutral contracts. A host supplies the environment and selects plugins explicitly:

import { createApp } from "@pathmx/core"
import { bunEnvironment } from "@pathmx/core/bun"
import { defaultPlugins } from "@pathmx/core/plugins"

const app = await createApp({
  root: "./paths",
  environment: bunEnvironment,
  plugins: defaultPlugins,
})
// Await app.close() when this host is finished.

The Bun environment is one adapter. Keep runtime-specific setup at the host boundary. The core foundation supplies Source access, metadata, schema, queries, and Blocks; explicit plugins add behavior. Plugin code uses the provided contexts rather than importing internal engine or registry modules.

Atomic Source storage

A custom PathMXEnvironment can supply storage.commit to persist all Source changes from one Action atomically:

import type { PathMXEnvironment } from "@pathmx/core"

type Commit = NonNullable<PathMXEnvironment["storage"]["commit"]>
// commit([{ path: "/reader/notes.md", before: "# Notes\n", after: "# Updated\n" }])

Each change contains an adapter storage path, expected before text, and replacement after text. null means the file is absent; an empty string is a present file. Core validates the full Source change set and supplies each path once, with its final contents.

The adapter must compare every expected value and apply every replacement in one atomic operation. Reject on a conflict or storage failure with all files unchanged. Resolve only after the complete batch is durable. Core then refreshes the graph and continues the Action lifecycle. Plugins keep using the existing Source transaction API.

Without this optional capability, core uses the individual storage write/delete methods. The Bun filesystem adapter uses that sequential behavior: an error can leave a partially persisted batch. A method that merely loops over file writes does not satisfy the atomic commit contract.

Folder move storage

storage.moveTree(move, changes, options?) supports semantic folder moves. move contains adapter paths root, from, and to, plus a files inventory relative to from. changes has the same path, before, and after text shape as storage.commit. Preconditions describe storage before the move; replacements describe the final tree. Sources being relocated appear as an old path deletion and new path creation. Assets travel with the tree.

import type { PathMXEnvironment } from "@pathmx/core"

async function previewFolderMove({ storage }: PathMXEnvironment) {
  if (!storage.moveTree) throw new Error("Folder moves are unavailable.")
  await storage.moveTree(
    {
      root: "/project/paths",
      from: "/project/paths/old",
      to: "/project/paths/new",
      files: ["a.md", "portrait.jpg"],
    },
    [
      { path: "/project/paths/old/a.md", before: "# A\n", after: null },
      { path: "/project/paths/new/a.md", before: null, after: "# A\n" },
      {
        path: "/project/paths/index.md",
        before: "[A](./old/a.md)\n",
        after: "[A](./new/a.md)\n",
      },
    ],
    { dryRun: true },
  )
}

The adapter must validate the Source-root boundary, exact inventory, absent destination, and expected text before mutations. With dryRun: true, it performs validation only. Apply must either complete the move and text changes or restore the original tree and texts on a handled write failure; an incomplete recovery must fail explicitly. Binary data must retain its bytes. Planning requests storage.list(root, { includeHidden: true }) to inventory all files without changing normal Source discovery. Native directories may also implement storage.isDirectory(path) so planning recognizes empty folders.

The Bun adapter uses a directory rename with text rollback; the browser adapter uses an IndexedDB transaction. Bun's guarantee covers handled I/O failures, not power loss or edits by another process during the operation. Ordinary Source Actions continue using the separate atomic storage.commit capability described above. Adapters without moveTree reject folder moves before writing.

Individual assets use optional storage.moveFile(move, changes, options?) with the same validation and handled-failure rollback contract. The move contains root, from, to, and the planned bytes' SHA-256 hex digest. The adapter checks those bytes, an absent destination, and all reference-edit preconditions before mutating. Binary content must remain exact. The Bun adapter implements this capability; adapters without it reject individual asset moves.

Core's durable Bun host additionally journals the asset identity and digest with its Source edits. Recovery accepts either the original asset or its renamed counterpart with matching bytes, then completes the reference edits. Unexpected bytes or both locations being occupied stop recovery; the journal does not guess which external edits to discard.

View connections

A ViewConnection provides authorized requests, page reads, invalidation subscriptions, and Actions without tying the UI to a transport. The host binds identity and context; callers do not pass an Actor with each operation.

import { createHTTPConnection } from "@pathmx/core/connection"

const connection = createHTTPConnection(location.origin)
const href = "/lessons/check-in.assessment"
const unsubscribe = connection.subscribe(href, () => {
  // Re-read and reconcile through your UI's existing renderer.
})
const response = await connection.read(href)
if (!response.ok) throw new Error("Page is unavailable.")
const document = await response.text()

const receipt = await connection.actions.submit("input.submit", [
  ["_target", href],
  ["_response", "assessment"],
  ["contrast", "strong"],
])
// The receipt confirms persistence. Refresh the page separately.

unsubscribe()
connection.close()

read(href) returns a standard Response containing the authorized HTML document and its existing document assets. A .md href returns Source Markdown. Subscribe before the initial read. Notifications are invalidation hints, not document payloads; reads always recheck access. Apply updates with the normal document renderer so focused controls and unsent edits retain their continuity.

request(href, init?) sends a same-site request and returns a standard Response. Plugin routes receive the host-admitted HostSession from ctx.session(); request headers cannot select a different identity on a local connection.

const response = await connection.request("/api/search?q=contrast", {
  headers: { Accept: "application/json" },
})
if (!response.ok) throw new Error("Search is unavailable.")
const { documents } = await response.json()

Browser plugin clients use getViewReader() from @pathmx/core/connection for request and read. It selects the active host connection, including a worker. Static publications supply a reader that permits only GET and HEAD. getViewConnection() supplies the full connection when Actions are available.

Worker requests preserve methods, headers, status, and finite binary or multipart bodies. Bodies are buffered across worker messages; this transport does not stream responses or report upload progress. An AbortSignal cancels the caller's wait and signals the host handler. Cancellation does not roll back a mutation already applied. Scoped Views expose only their admitted core routes; arbitrary plugin routes are not redispatched through a broader View. Credential endpoints require an HTTP host.

createHTTPConnection(baseURL) uses same-origin credentials and the host's Action and live endpoints. Supply the mount URL for a contextual View. Root-relative canonical hrefs resolve within that mount; absolute URLs outside it are rejected. Authored same-site download links read through the active reader, so locally stored files can be downloaded offline. Static readers use the publication's read-only runtime and do not ship mutation or live-server transport code.

createWorkerConnection(worker) and serveWorkerConnection(connectionPromise, port) from the same entrypoint implement the identical contract over worker messages. A trusted host creates the underlying connection with createViewConnection(app, session, viewOptions?) from @pathmx/core, using its own environment and admitted HostSession. A local owner uses { type: "local", actor: "/people/sam.person" }; this grants no repository administration or remote authentication. These adapters do not supply identity admission, publication loading, or durable storage for the host.

getViewConnection() from @pathmx/core/connection returns the active connection installed by the document runtime. Independently bundled clients share this same host admission. It rejects when no connection is installed, including read-only static publications.

actions.run(name, input, { target, request }) matches the View Action runner. actions.submit(name, fields, { request }) accepts ordered text pairs, consumes _target, and invokes the Action's form decoder. Binary transfer uses a plugin route through request; it is not converted into this text-form contract. Closing a connection releases its subscriptions and pending transport operations, without shutting down a shared host. A mutation interrupted before acknowledgment has an uncertain outcome.

The optional request identifies one invocation across retries. HTTP connections generate it automatically and retry transient failures with the same payload; explicit identifiers can be retained across closed connections. See retry a Save for receipt replay and scope.

A successful Action receipt confirms the complete persistence operation, including a successful no-op or replay. Reading the updated representation is a separate operation: a failed refresh never requires repeating the Action. The host repairs failed projections from Sources before another mutation. Connection reads request that repair and fail if the host cannot recover.

ActionFailure exposes outcome: rejected means no writes are confirmed to have occurred; partial means a sequential adapter confirms an incomplete batch; uncertain means the result cannot be confirmed. Preserve the form on rejection. applied records the number of confirmed Source changes before failure; HTTP and worker connections preserve that count. Zero does not prove that an uncertain operation made no changes. For partial or uncertain outcomes, reconcile before another submission. Generic Actions do not promise exactly-once execution or receipt recovery after a crash.

Static publication artifacts

@pathmx/core/publish provides createPublication for hosts that consume a publication directly. Create the application with a static runtime target:

import { PathMX } from "@pathmx/core/bun"
import { createPublication } from "@pathmx/core/publish"

const app = await PathMX({
  root: "./paths",
  runtime: { target: "static" },
})
try {
  const bundle = await createPublication(app, {
    origin: "https://example.com",
  })
  const route = bundle.manifest.routes.find((route) => route.pathname === "/")
  const representation = route?.representations.find((item) => item.default)
  const artifact = bundle.artifacts.find(
    (item) => item.path === representation?.artifact,
  )
  if (!artifact) throw new Error("Home page has no default artifact.")
  const html = await (await artifact.read()).text()
  console.log(bundle.manifest.build, html)
} finally {
  await app.close()
}

The bundle separates public routes from stored artifact paths. Each route lists its HTML, Markdown, or file representations with content types and any response headers. Resolve requests through that route contract; an artifact filename is not necessarily the reader-facing URL. The manifest records the build identity, origin, mount basePath, and artifact size and cache identity. Optional basePath relocates a publication beneath an origin, such as /course/.

Status representations use a route with type: "status" and a numeric status. For an unmatched request, a host selects the route whose status is 404, negotiates its HTML or Markdown representation, and serves it with that HTTP status without redirecting. The same status applies when its /404.html route is requested directly. See missing pages for authored overrides and directory-host configuration.

The builder reads anonymous public content, collects Plugin publication routes, and validates the completed publication. Unsupported server-dependent behavior or invalid publication routes reject with PublicationError. Static output has no server Action or live-update endpoints. Use browser publications when local personal writes are required.

By default, artifact bytes stay in memory. A host can supply a PublicationStore through store; its put(path, blob) returns the stored size and a reusable asynchronous read() function. Keep staging private until the whole build succeeds, and keep stored bytes available for as long as the bundle is consumed. Artifact readers do not depend on the running application. For a ready-to-serve directory, use the export command instead of writing a storage and routing adapter.

Browser publications

publishBrowserProject from @pathmx/core/bun builds an ordinary project into a browser-hosted application. The output contains public Sources, explicitly authored starter files, the selected plugins, their browser clients, and local assets. Package activation and repository plugin configuration use the normal project setup.

Create publish-browser.ts in the project root and run it with bun run publish-browser.ts after supplying the starter files below:

import { publishBrowserProject } from "@pathmx/core/bun"

await publishBrowserProject("./paths", {
  out: "./dist",
  origin: "https://example.com",
  local: {
    name: "my-course",
    session: { type: "local", actor: "/people/local.user" },
    writableRoot: "/people/local/",
    seedSources: "./starter",
  },
})

The starter folder mirrors repository-relative paths. It may contain the declared local Actor Source and files inside writableRoot:

starter/
  people/
    local.user.md
    local/
      my.bookmarks.md

Declare the Actor in starter/people/local.user.md:

---
actor: true
---

# Local reader

Declare the Home on starter/people/local/index.md:

---
access:
  type: home
  owner: /people/local.user
---

# Local reader's files

starter/people/local/my.bookmarks.md can contain # My bookmarks. Starter files are deliberately published templates. Existing private Sources in paths are excluded; they do not become starter files automatically. The Actor declaration is read-only, while files inside the writable subtree seed IndexedDB only when that named repository is first created. Keep the repository name, Actor, and Home stable when updating a site; saved Sources and binary uploads remain intact.

Serve dist at the root of an HTTPS origin, or localhost during development. Serve existing files normally and use index.html as the fallback for page navigation. The output includes a service worker at /pathmx-browser-sw.js and requires access to the /.pmx directory. No Action server is required. origin records the canonical HTTP(S) origin; subpath hosting is not supported by this builder.

The service worker caches and verifies the complete publication before the first opening creates a personal repository. Once installed, the site can reopen offline at a deep link. Updates wait until tabs using the previous build close, then replace its cached publication. Incomplete or corrupted updates leave the installed build available. These caches are separate from the saved Source repository.

Plugins must run with the browser host's capabilities. Clients and package-relative assets are prebuilt automatically; runtime module loading and SQL are unsupported. HTML rewrites operate on complete buffered documents, including text nodes. Import reusable code through repository plugins. Externally hosted media and services still require their own network access. Include <x-browser-files-tools /> for personal-file export, atomic import, and reset. See saved files for archive limits.

Browser hosts and document runtime

createBrowserHost from @pathmx/core/browser opens an IndexedDB repository and returns a ViewConnection. Its writable subtree overlays the supplied read-only Source snapshot. Snapshot values may be Markdown strings or binary Blobs. Personal binary assets retain their bytes and media types as IndexedDB Blobs. Atomic Source batches compare expected bytes and commit together; separate asset writes are not part of a Source batch. Web Locks serialize refresh, Actions, and route requests across tabs; BroadcastChannel invalidations cause subscribed pages to read the saved Sources again. Closing this connection also closes its owned repository and storage.

A small worker can host a personal Source repository:

import { createBrowserHost } from "@pathmx/core/browser"
import { serveWorkerConnection } from "@pathmx/core/connection"
import { SourceAccessPlugin } from "@pathmx/core/plugins"

const connection = createBrowserHost({
  name: "reading-notes-v1",
  writableRoot: "/people/sam/",
  session: { type: "local", actor: "/people/sam.person" },
  sources: {
    "/index.md": "# Reading notes\n\nWelcome to your notebook.",
    "/people/sam.person.md": "---\nactor: true\n---\n# Sam",
    "/people/sam/index.md":
      "---\naccess: {type: home, owner: /people/sam.person}\n---\n# My files",
    "/people/sam/notes.md": "# My notes\n",
  },
  plugins: [SourceAccessPlugin],
  resources: {
    async loadAsset(url) {
      throw new Error(`Asset is not prebuilt: ${url}`)
    },
    async bundle(entry) {
      throw new Error(`Client is not prebuilt: ${entry}`)
    },
    async loadModule(name) {
      throw new Error(`Module is not installed: ${name}`)
    },
  },
})
serveWorkerConnection(connection, self)

The example uses only Markdown and Source access, so its resource handlers reject unused capabilities. Supply prebuilt assets, bundles, and installed modules when plugins require them. The browser environment provides Markdown, YAML, and buffered HTML and text-node rewriting; SQL is not supported by this adapter. createBrowserStorage and createBrowserEnvironment are available from the same entrypoint for hosts that assemble their own lifecycle.

A bundle resource returns { entrypoint, styles, assets, dependencies }. entrypoint and styles are output-relative paths into assets; styles contains only the entry’s initial CSS. assets retains the complete module graph, including lazy chunks and binary files, and dependencies lists local input URLs used for invalidation. A CSS-only bundle has entrypoint: null. For an already published bundle, return absolute publication paths with an empty assets array. Core emits only the selected entry and initial styles; supporting artifacts remain available for later imports.

Source snapshots must contain only data appropriate for that browser. Personal Sources seed the database on first creation; reopening preserves saved bytes and rejects malformed stored content. Use publishBrowserProject for publication filtering, resource packaging, and offline startup. Hosts assembled directly with createBrowserHost supply their own snapshot loading. Database migration and backups remain application concerns.

startViewRuntime from @pathmx/core/runtime binds canonical PathMX documents to any View connection. It installs navigation, native Action forms, and live reconciliation with focus and draft continuity:

import { createWorkerConnection } from "@pathmx/core/connection"
import { startViewRuntime } from "@pathmx/core/runtime"

const connection = createWorkerConnection(
  new Worker("/worker.js", { type: "module" }),
)
await startViewRuntime(connection, { initialRead: true })

Load this client from a shell containing one [data-pmx-view] element and an empty <div data-pmx-augmentations></div> directly under body. initialRead renders the current URL through the connection before installing navigation. Serve that shell for authored page routes so reloads can reopen them. Rendered documents must include their required prebuilt client and style assets. For an already rendered HTTP page, pass createHTTPConnection(location.origin) and omit initialRead.

HTTP representations

@pathmx/core/http shares representation handling between the Bun server and publication adapters. It depends only on web-standard Request, Response, Headers, and URL:

import { prefersMarkdown, withMarkdownAlternate } from "@pathmx/core/http"

export default {
  fetch(request: Request) {
    const path = new URL(request.url).pathname
    if (path !== "/hello.page" && path !== "/hello.page.md") {
      return new Response("Not found", { status: 404 })
    }
    const markdown = path.endsWith(".md") || prefersMarkdown(request)
    const content = markdown ? "# Hello\n" : "<h1>Hello</h1>"
    const response = new Response(content, {
      headers: {
        "Content-Type": markdown ? "text/markdown" : "text/html",
      },
    })
    return withMarkdownAlternate(response, "/hello.page.md")
  },
}

prefersMarkdown compares accepted quality and specificity, preferring HTML on a tie. withMarkdownAlternate adds Vary: Accept and a Markdown alternate Link to successful responses. appendVary(headers, name) adds a case-insensitive header name without duplicating existing entries. Hosts own routing and Source access before selecting a representation.

Plugin declarations

PathMX uses Zod 4 for validation and derives JSON Schema for field discovery. Plugins may use Zod or Zod Mini:

import { defineEvent, definePlugin } from "@pathmx/core"
import * as z from "zod/mini"

const Submitted = defineEvent({
  data: z.object({ response: z.string() }),
})

export default definePlugin({
  id: "course",
  schema: {
    sources: { course: { label: "Course" } },
  },
  events: { submitted: Submitted },
})

Plugins declare actions, queries, and events directly, alongside components and routes. Each map uses local names; the plugin id supplies the namespace, so this Event is course.submitted. Registration validates these declarations in the same installation pass. Server emission, Action provenance, bounded subscriptions, and browser observation declarations are documented in Events.

schema holds sources, blocks, props, and settings. defineSchema can extract that data contract into a reusable fragment. Query metadata remains discoverable through app.schema.query(id); executable query definitions belong in plugin.queries. Keep browser bindings and optional deployment adapters outside the portable import surface.

Render an isolated theme

@pathmx/core/theme provides the theme CSS and attributes for a standalone HTML document, such as a generated share image. It uses the same normalization and presets as the Theme plugin without applying reader preferences.

import { renderTheme } from "@pathmx/core/theme"

const theme = renderTheme({
  root: { preset: "paper", color: { accent: "#7545a0" } },
  page: { font: { heading: "Georgia, serif" } },
  scheme: "light",
})

const html = `<!doctype html>
<html data-pmx-theme="${theme.preset}" data-pmx-scheme="${theme.scheme}">
<head><style>${theme.css}</style></head>
<body style="background:var(--pmx-color-bg);color:var(--pmx-color-fg);font-family:var(--pmx-font-heading)">
  <h1>Learning to see type</h1>
</body></html>`

Page values override root values. scheme is a fallback: an authored scheme or fixed preset scheme takes priority. The result contains css, preset, and scheme. This entry point is portable; capturing a PNG belongs to a host plugin.

Source-owned derived assets

Plugin contexts expose app.assets.read(path) for local authored binary inputs and app.assets.publishDerived(sourcePath, name, blob) for generated variants. The source must exist. Returned content-addressed URLs stay beside the original and use its access boundary; static publication includes referenced variants. The default Bun Image plugin uses these methods for responsive image files.

Use app.assets.read(path) and app.assets.publishDerived(path, name, blob) from a Plugin host operation to produce a transformed download. Check the embedding boundary before reading the original. Derived bytes retain that original's access boundary and receive content-addressed URLs; do not author those generated hashes.

Keep the transformation in tracked authored preparation or a domain operation, then pass the resulting URL as plain data to a native component. A portable presenter should not open files or perform the transformation on every render. See Plugin assets.

Parse directive labels

Use parseDirectiveLabel when a plugin needs to interpret an authored directive label, such as the text of a rendered link:

import { parseDirectiveLabel } from "@pathmx/core"

const result = parseDirectiveLabel(
  '@video(thumbnail="./preview.jpg" width=640) Motion study',
)

if (result.issue) throw new Error(result.issue)

// result.label: "Motion study"
// result.directive: {
//   name: "video",
//   params: { thumbnail: "./preview.jpg", width: 640 },
//   qualifiers: [],
// }

The result contains label, directive, and issue when applicable. Ordinary text produces a label without a directive. Parameters contain scalar strings, numbers, or booleans. The parser checks shared syntax; each plugin validates its accepted names, values, and combinations. Check issue before consuming a recognized directive.

See Images and Video for authored parameter vocabularies.

Choose the Source access policy

Bare createApp has no implicit public-read or personal-folder policy. It still enforces current Actor identity, private Actor Sources, View restrictions, and bounded Action effects. Install a base policy to choose the application's ordinary Source permissions:

import { createApp } from "@pathmx/core"
import { bunEnvironment } from "@pathmx/core/bun"
import { SourceAccessPlugin } from "@pathmx/core/plugins"

const app = await createApp({
  root: "./paths",
  environment: bunEnvironment,
  plugins: [SourceAccessPlugin],
})

SourceAccessPlugin owns the standard public, explicit Home, shared Source, and attachment rules, including authoring validation, mutation checks, embedding, and permission explanations. defaultPlugins includes it, so ordinary PathMX() applications retain their standard behavior. Supplying a replacement plugins list replaces the defaults; include this policy explicitly when you need its rules.

A base policy is supplied by Plugin.access(app) once, before Source indexing. Only one base policy may be installed. Other plugins add capabilities with grants or narrow contextual Views; they do not silently replace the base policy.

This complete alternative publishes only Sources with public: true:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "publication-policy",
  access(app) {
    const published = (id: string) => app.sources.get(id)?.data.public === true
    return {
      resolve(_principal, target) {
        const id = typeof target === "string" ? target : target.id
        return published(id)
          ? { visibility: "public", capabilities: ["read"] }
          : { visibility: "denied", capabilities: [] }
      },
      authorize() {
        throw new Error("Edit these files through the host.")
      },
      canEmbed(container, dependency) {
        return published(container) && published(dependency)
      },
    }
  },
})

Install this plugin instead of SourceAccessPlugin. Its authoring has no Home or shared-Source convention. It may optionally supply home(actorId) and homeActorId(path) for personal State and ~/ paths. Trusted consumers use app.access.home(actorId); Actor identity itself contains no Home.

resolve returns visibility (public, private, or denied) and a capability array. blocked: true prevents supplemental grants from opening an invalid or protected target. An optional reason explains permission inspection; target may identify the Source owning an attachment. Inconsistent or malformed decisions fail closed. authorize(principal, change) validates mutations; an undefined principal denotes an already bounded Action effect. canEmbed checks whether a compiled dependency may be included in its container. The policy may provide diagnostics() and validate() for its authoring. Cross-Source invariants belong in the owning Plugin's validateSources(prepared, app) hook, which sees parsed changes and the proposed final repository before durable writes. Its Source hook runs before other Source consumers, after Actor indexing.

Core independently rejects ordinary changes that create/delete an Actor, promote a Source into an Actor, or change an untyped file's derived Source id. Plugins such as Auth add their own authority-field invariants. Repository-admin authoring remains the explicit management boundary for those changes.

Policy changes must use normal Source commits and indexing. Retained Views and query caches observe the new revision, and asynchronous delivery discards stale results. ~/ resolution is unavailable when the policy supplies no Home.

The standard policy reads a Home claim from the directory's canonical index Source:

---
access:
  type: home
  owner: /people/sam.person
---

The claim protects that directory and every descendant Source or asset. It remains reserved if its Actor is missing, including after restart and during static export. Duplicate, overlapping, or case-aliased claims fail closed. Ordinary Actor and Action mutations cannot create, remove, retarget, or relocate Home policy; trusted repository changes validate the complete final claim set before writing. Moving or removing a claim is rejected while any Source or asset remains in the old namespace.

A canonical directory index with access.type: shared similarly protects itself and every descendant Source and asset. The nearest nested shared index or an explicit non-index shared policy may narrow access for its subtree or Source, but cannot grant capabilities excluded by an ancestor boundary. Inheritance is based on storage location, not links. A shared subtree inside a Home can deliberately admit collaborators; Home ownership and shared-directory policy remain separate. Removing a shared index is rejected while retained descendants would become public. Permission inspection reports the index Source from which access was inherited.

Private files and attachments

The default SourceAccessPlugin reserves /private/ for files that must remain unavailable without explicit access. It requires private attachment bindings to use this namespace, an existing protected Home, or a shared directory boundary. Public file locations remain ordinary public files; private bindings outside protected storage are invalid. Protected namespaces also deny case aliases, so a URL such as /PRIVATE/file.png cannot bypass protection on a case-insensitive filesystem.

A repository-admin View does not override a policy-blocked /private/ Source. Its readSource returns no Source and its queries omit that Source, even though the same authority can inspect Homes. Trusted host reads through app.sources are a different boundary from reads through a View.

For example, a shared document can bind an image without giving its Actor a Home:

---
access:
  type: shared
  owner: /people/alice.person
  members:
    - actor: /people/alice.person
      can: [read, write]
attachments:
  - /private/sketch.png
---

# Team sketch

![Sketch](/private/sketch.png)

The owner is an Actor Source with actor: true. The binding grants the document's readers access to the file and its derived images. A public Source can explicitly share a file from /private/ or a Home. A shared directory's allowlist remains a ceiling: an attachment binding cannot grant access excluded by the file's enclosing shared indexes. Unclaimed files inherit those indexes directly. Direct reads and embedding checks use the same access resolver; contextual View restrictions still apply.

Actor-authored mutations can add a binding only when the Actor already has read access to the file. Owning a document cannot claim someone else's private file or an unbound /private/ file. Trusted repository authoring and bounded service Actions can establish new bindings, as upload handling already does.

Removing a binding or deleting its Source does not delete file bytes. Unbound files under /private/ remain denied after restart and excluded from anonymous static export. Files in a Home return to that Home's access rules. This is a convention of the default access plugin, not a new core storage API.

Add domain Source grants

An Actor is a Source with actor: true. Core does not require a role, email, or Home. With SourceAccessPlugin, a Home is declared by access.type: home and owner on that directory's index Source. An Actor may own zero or one Home. Plugins own role meaning and credential admission. @pathmx/auth supplies User admission and its staff-review convention.

A plugin's synchronous grants hook adds Source capabilities from current indexed state. This complete example lets an event host edit badges for that event:

import { definePlugin } from "@pathmx/core"

export default definePlugin({
  id: "event-access",
  grants(target, { principal }, app) {
    if (principal.type !== "actor") return
    if (typeof target === "string" || target.type !== "badge") return
    if (typeof target.data.event !== "string") return
    const event = app.sources.get(target.data.event)
    if (event?.data.host === principal.actor) return ["read", "write"]
  },
})

The badge authors event: /saturday.event; that Event authors host: /people/sam.participant. No core role or personal folder is needed. A general badge-editing Action can use available: ({ can }) => can("write").

Grant contributions combine with the installed base policy. The standard policy provides public, explicit Home, and shared-Source permissions. Contextual View restrictions still apply to every resulting capability. Grants cannot resurrect stale Actors, expose Actor Sources, override malformed shared access, or unlock reserved orphaned Home paths. Private Home/shared access requires a host-admitted Actor. Explicit repository administration retains its host-owned bypass. Attachment reads resolve through their owning Source.

Return an array of read, write, and/or execute, or undefined to decline. An empty array adds nothing. Malformed or throwing providers deny access. Providers must use indexed Sources and synchronous policy state; do not call app.access recursively from a grant hook. Mutate policy through normal Source changes so indexing, query caches, and asynchronous freshness checks update together.

Grant capabilities describe ordinary Source access. Actions that perform a narrower domain operation still declare finite prepared effects; this hook does not replace Action availability or expand those effects. See Actions and Views.

  • Components — Markdown and plugin rendering.
  • React — application bindings.
  • Packages — package boundaries and activation.
  • CLI — command behavior and plugin lint contributions.

Discover reachable pages

await app.sources.discoverable(view) returns canonical page Sources reachable from / through Sources that the supplied View can read. Layout, navigation, and other authored Source references participate in traversal. Authored queries also connect their returned internal href references, evaluated in the supplied View with the same filters, sorting, and limit as the page query. Supporting Sources can connect pages without appearing as page results. This does not change app.sources.all(), general Source queries, or direct URL access.

import { route, type Plugin } from "@pathmx/core"

export default function PageIndexPlugin(): Plugin {
  return {
    id: "page-index",
    routes: [
      route.get("", async (ctx) => {
        const view = ctx.view(await ctx.session())
        const pages = await ctx.sources.discoverable(view)
        return Response.json(
          pages.map((source) => ({
            id: source.id,
            markdown: source.path,
          })),
        )
      }),
    ],
  }
}

For an anonymous index, use ctx.view({ type: "anonymous", signIn: null }). The result is a read-only list; copy it before sorting. Discovery uses the indexed graph and View-aware query relations; it does not compile or render pages.

Shared control surfaces

Browser plugins register navigation, transport, or utility controls into the framework's augmentation dock. Core owns spacing, safe-area placement, and utility overflow; plugins own their content and availability.

import { surfaces } from "@pathmx/core/controls/browser"

const control = surfaces.register({
  id: "path.navigation",
  element: navigation,
  group: "navigation",
  presentation: { document: "full", play: "compact" },
})
// Dispose when the authored feature host becomes unavailable.
control.dispose()

Register each id once per Document. Keep an authored availability host in the View, and move only its control element. Registration moves the element without remounting its state. Disposal restores its original position when available, or removes it. React consumers can render through a portal into a registered host. Embedded Source documents have their own scoped registry.

The presentation host calls surfaces.setContext("play", { visible }) with its interaction visibility and restores "document" on exit. The entire dock fades together; hover, keyboard focus, and expanded utility controls keep it visible. Plugins retain ownership of availability, including development-only controls. Compact surfaces share --pmx-augmented-control-size (3rem). Use the shared pmx-button floating/icon variants for individual actions. Call a utility registration’s reveal() before returning focus to it from a dialog, so its overflow group is available. Contributions receive data-pmx-surface-presentation="full" or "compact" according to their declared mapping. Utility controls use More controls in Play and at narrow widths. Navigation and transport stay directly available; disabled navigation controls should retain their space. Dialogs and popovers keep their normal focus and accessibility semantics.

For a custom action flow, executeBrowserAction({ action, request, pending }) uses the mounted View’s action runner and reconciliation. request accepts { kind: "form", fields } or { kind: "input", value }. Check the returned reconciliation.status before navigation: only "applied" confirms that the current mount received the result; "failed" and "superseded" do not. Prefer the normal authored action form unless custom post-action behavior is needed.