Contextual Views

A story can let the same reader discover a memory in one conversation while keeping it undiscovered in another.

A contextual View restricts what that reader can encounter and do within the selected conversation. Their base account access still applies.

The complete library example below creates two such conversations. Trusted host code selects their contexts when admitting a View:

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

export function libraryViews(app: AppContext, session: CredentialSession) {
  return {
    one: app.view(session, { context: "/conversations/one.conversation" }),
    two: app.view(session, { context: "/conversations/two.conversation" }),
  }
}

An installed plugin must recognize and admit the selected context. Missing, unhandled, malformed, or denied contexts reject admission. The context Source must be readable under the caller's base authority, but the resulting View need not expose that Source. view.context exposes only its id.

Review another Actor's saved work

A trusted host can select whose saved state a View presents while retaining the reviewer's authenticated identity:

const assignmentView = app.view(session, {
  context: "/course/reflection.assignment",
  subject: "/people/alice.user",
})
const reviewerView = app.view(session)

assignmentView.subject selects Alice for Actor-relative ~/ reads, Input answers, and Actor-stored Completion. identity and principal still identify the reviewer. Ordinary Views omit subject; their Actor-relative reads use principal.actor. Custom state readers use view.subject ?? view.principal.actor after checking that the principal is an Actor. Custom write operations continue to use their admitted operation Actor.

Subject selection requires a context and explicit admission by an installed Plugin. Its existing context hook receives the requested subject; returning that same id admits the selection. An older hook that returns only ordinary restrictions does not admit observation. For example, this assignment owns its reviewer and student lists, and permits only the assignment, its State, and shared course resources:

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

export const review: Plugin = {
  id: "review",
  context(source, { principal, subject }) {
    if (source.type !== "assignment") return
    if (
      principal.type !== "actor" ||
      !subject ||
      !Array.isArray(source.data.reviewers) ||
      !source.data.reviewers.includes(principal.actor) ||
      !Array.isArray(source.data.students) ||
      !source.data.students.includes(subject)
    ) return false
    return {
      subject,
      read: candidate =>
        candidate.id === source.id ||
        (candidate.type === "state" && candidate.data.for === source.id) ||
        candidate.path.startsWith("/course/resources/"),
    }
  },
  routes: [
    route.all("alice/*", async ctx => {
      const view = ctx.view(await ctx.session(), {
        context: "/course/reflection.assignment",
        subject: "/people/alice.user",
      })
      return ctx.serve(view, { basePath: "/api/review/alice" })
    }),
  ],
}

Open /api/review/alice/course/reflection.assignment to render the original assignment. Put its shared layouts, includes and other dependencies inside the admitted course resources, or extend the course's contextual read policy to their owning Sources. Named instance State uses the complete instance reference in for; include those references when the course admits assignment instances.

The reviewer must already be able to read the assignment and Alice's State through normal access policy. Every observed read also requires Alice's visibility and all context restrictions. Observation grants no additional access, including to a repository administrator. Roster or access changes revoke retained Views and live connections.

A subject-selected View is always read-only, even when selecting oneself. Actions and draft evaluation overlays are rejected. Mounted observations expose core read transports, pages and admitted assets; mutations, uploads and service handlers are unavailable. Responses are private and non-cacheable. Input displays saved values without restoring browser drafts, and Analytics does not collect observed page activity. Render editable reviewer controls through reviewerView, outside the observed document's mount.

Evaluate Block outputs

A host-admitted View can evaluate a Source without rendering HTML or saving state:

const saved = await view.evaluate("/survey.assessment")

const preview = await view.evaluate("/survey.assessment", {
  responses: {
    route: { route: "guided" },
    reflection: { reflection: "The example helped." },
  },
})

const reflection = preview.blocks.reflection
if (reflection?.participates) {
  console.log(reflection.outputs.input?.reflection)
}

The response map uses the same Block response ids as input.submit. Each supplied response replaces that Block's draft values; omitted responses retain saved values. Unknown responses and fields are rejected, and overlays require editable Input responses. Invalid field values do not become usable outputs. The caller's View still controls Source, actor and contextual access.

The result contains the Source reference, its blocks, and Plugin-contributed completion.items. Each Block exposes participates, outputs grouped by producer, and its own completion.items. Authored $block.field bindings resolve those same outputs. Inactive Blocks contribute no outputs or completion items. Source order does not determine evaluation order; the compiler follows output dependencies and rejects cycles.

Draft values can change branches and computed outputs without writes. Previewing an Input response does not mark its completion committed, and Assessment grading continues to describe the last saved check. Use the normal Action to save or grade.

For a named Source instance, pass { id: "/survey.assessment", iid: "first" }. Evaluation remains isolated to the admitted actor and instance. This is a host View API; it does not install a browser endpoint or automatically preview client drafts.

Create the library files

Start with an initialized Quickstart project. Run bun run pmx plugins add auth and bun add zod. This example uses Auth's local development credentials and fictional library content. No production email configuration or personal Home is needed for the conversation update.

Create setup-library.ts in the project root and run bun run setup-library.ts in this fresh example project. It writes the following complete Source fixture:

import { mkdir, writeFile } from "node:fs/promises"
import { dirname } from "node:path"

const access = `access:
  type: shared
  owner: /users/librarian.user
  members:
    - actor: /users/librarian.user
      can: [read, write, execute]
`
const files = {
  "index.md": "# Library\n\n<x-auth-session />\n\n[Letters](./books/letters.book.md).\n",
  "users/librarian.user.md": "---\nactor: true\n---\n# Librarian\n",
  "books/letters.book.md": "# Letters\n\nFor [Eleanor](../names/eleanor.name.md).\n",
  "names/eleanor.name.md": `# Eleanor

<form action="/actions/library.recover" method="post"
  data-pmx-action-target="/names/eleanor.name">
  <input type="hidden" name="_target" value="/names/eleanor.name" />
  <label>Connection <input name="connection" required /></label>
  <button type="submit">Follow this connection</button>
</form>

\`\`\`query
query: sources
type: memory
from: /
\`\`\`
`,
  "memories/coast.memory.md": `---
${access}---
# The coast

Eleanor met the librarian at the station by the sea.
`,
  "rules/coast.recovery.md": `---
${access}memory: /memories/coast.memory
connection: Eleanor met the librarian at the station by the sea.
---
# Recovery evidence
`,
}
for (const name of ["one", "two"]) {
  Object.assign(files, {
    [`conversations/${name}.conversation.md`]: `---
${access}librarian: /users/librarian.user
recovered: []
---
# A visit
`,
  })
}
for (const [path, content] of Object.entries(files)) {
  const destination = `paths/${path}`
  await mkdir(dirname(destination), { recursive: true })
  await writeFile(destination, content)
}

The script replaces the fresh project's root page so the example has no authored layout dependency hidden by its context policy. Inspect the resulting Markdown files before continuing. Conversation and memory Sources have explicit shared access; context discovery does not turn private content into public content.

Declare a domain restriction

A plugin's synchronous context hook resolves current indexed domain state into read, Action, and service-effect restrictions. Create plugins/library-context.plugin.ts with this policy; it lets the librarian encounter books, names, and recovered memories:

import { definePlugin } from "@pathmx/core"
import { z } from "zod"

const Conversation = z.object({
  librarian: z.string(),
  recovered: z.array(z.string()),
})

export default definePlugin({
  id: "library-context",
  schema: {
    sources: { conversation: { schema: Conversation } },
  },
  context(source, { principal }) {
    if (source.type !== "conversation") return undefined
    const state = Conversation.parse(source.data)
    if (principal.type !== "actor" || state.librarian !== principal.actor) {
      return false
    }
    const recovered = new Set(state.recovered)
    return {
      read: (candidate) =>
        candidate.path === "/index.md" ||
        candidate.type === "book" ||
        candidate.type === "name" ||
        recovered.has(candidate.id),
      actions: ({ name, target }) =>
        name === "library.recover" && target?.type === "name",
      effects: ({ operation, id }) =>
        operation === "read"
          ? id === source.id || id === "/rules/coast.recovery"
          : operation === "update" && id === source.id,
    }
  },
})

The hook receives (source, { principal }, app). app supplies trusted plugin capabilities when an indexed domain model is needed. Return undefined for a context this plugin does not handle, false to deny it, or a restriction object. Promises and malformed results are invalid. Predicate exceptions deny access.

Every applicable provider narrows the result. Omitted dimensions add no additional restriction; a predicate returning false permits nothing in that dimension. A later permissive provider cannot override an earlier restriction, and contextual read permission cannot grant access denied by ordinary Source authority.

Capture immutable decision state, such as the fresh Set above, rather than a live mutable index entry. Core refreshes the snapshot after Source changes.

Add the recovery Action

Create plugins/library.plugin.ts. The operation can read the private rule and update only its selected conversation:

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

export default definePlugin({
  id: "library",
  actions: {
    recover: defineAction({
      title: "Follow this connection",
      target: "name",
      input: z.object({ connection: z.string() }),
      available: ({ view }) => Boolean(view.context),
      prepare({ connection }, { view }) {
        const context = view.context!
        return {
          effects: { read: [context, "/rules/coast.recovery"], update: [context] },
          async run({ tx }) {
            const rule = await tx.readSource("/rules/coast.recovery")
            if (connection !== rule.data.connection)
              throw new Error("Connection does not match.")
            const current = await tx.readSource(context)
            tx.source(context).data({
              recovered: [...new Set([
                ...z.array(z.string()).parse(current.data.recovered),
                z.string().parse(rule.data.memory),
              ])],
            })
            return { recovered: true }
          },
        }
      },
    }),
  },
})

The form in Eleanor's Source invokes library.recover. A wrong connection rejects the operation without changing either conversation. The rule is trusted fixture data; user input does not choose the effect ids or the memory it reveals.

Keep encounter and service access separate

read(candidate) determines which Sources the View may encounter. It applies to readSource, built-in sources and blocks queries, link-query anchors, and Source descriptions. Queries filter before ordering and limits. Contextual relations omit hidden Source targets and unresolved or asset references.

actions({ name, target }) narrows named Action availability and execution. It intersects with target admission, execute authority, and the Action's own available callback. Untargeted Actions have target: undefined.

effects({ operation, id }) further restricts an Action's declared service effects. An Action may read the exact hidden recovery rule and update this conversation without exposing either Source through ordinary View reads. It cannot write another conversation merely by declaring that id. Newly created Sources remain readable within their permitted creation transaction.

Action outputs still belong to the explicitly admitted domain operation. A recovery Action should return a small result such as { recovered: true }, then let ordinary View reads expose the newly encounterable memory. Core does not redact arbitrary strings returned by trusted plugin code.

Retain a live View

Keep the same View after a successful recovery. Context snapshots and query caches include the context identity and current repository revision. A change to one conversation refreshes its effective policy without granting access in another.

Base policy and grant-aware query scopes also include the repository revision. While semantic indexing is incomplete, reads through an installed Source policy or contextual View fail closed. Source changes conservatively invalidate contextual query results, including cached empty results. Removing the context or its Actor makes retained Views unable to read or invoke Actions. Async descriptions, queries, and Action preparation reject results computed under a superseded revision; callers may retry with current state. Failed Action preparation publishes neither staged writes nor Events.

Bind context in the trusted host

A server plugin can obtain a scoped View with ctx.view(await ctx.session(), { context: conversationId }). The host selects the conversation for the operation; a client-supplied _context field or Actor id does not establish admission. A View cannot select a different context or expose an unrestricted parent View.

Serve ordinary pages through that View with ctx.serve(view, { basePath }). With the policy, Action, and Auth setup above, create plugins/library-host.plugin.ts:

import { definePlugin, route } from "@pathmx/core"

export default definePlugin({
  id: "library-host",
  routes: ["one", "two"].map((name) =>
    route.all(`${name}/*`, async (ctx) => {
      const view = ctx.view(await ctx.session(), {
        context: `/conversations/${name}.conversation`,
      })
      return ctx.serve(view, { basePath: `/api/library-host/${name}` })
    }),
  ),
})

Run bun run dev, open /, and sign in as Librarian. Open /api/library-host/one/books/letters.book and /api/library-host/two/books/letters.book. Internal links, native form Actions, Markdown alternates, Source previews, and live refreshes retain their mount. Follow Eleanor in each mount. Both memory lists start empty. In conversation one, submit “Eleanor met the librarian at the station by the sea.” The coast appears there; conversation two still has no memories. Reload both routes to verify that distinction was saved. Explicit links to other origins keep their destination.

basePath is the canonical mount path, without a trailing slash. Construct the View from the current handler's await ctx.session() result; copied credentials, Views admitted from a different credential session or application, inspection Views, and fabricated View objects are rejected. Mounted serving does not resolve credentials or reconstruct an unscoped View internally. Each subsequent HTTP request is admitted again by the host route.

The mount serves HTML, Source Markdown, named Actions, built-in queries, live subscriptions, and Source previews through the supplied View. It does not forward arbitrary plugin API routes: binary-upload and other service endpoints need their own scoped integration. Input forms use the generic Action runner and retain the mounted View. Ordinary unmounted URLs still use ordinary admission; choosing a context does not change an Actor's base grants elsewhere.

Query through the mount

The standard Query Plugin is already installed. From the browser console on your signed-in local site, request conversation one's list using the current session:

const response = await fetch("/api/library-host/one/api/query", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ query: "sources", type: "memory" }),
})
console.log(await response.json())

After recovery, the result contains The coast. Change one to two: that list is still empty. The browser supplies its same-origin session; no unexplained cookie file or caller-supplied Actor is needed.

Built-in sources and blocks queries use the exact admitted View. Recovery in conversation one changes its results; the same query in conversation two remains restricted. Returned navigation href values retain the mount, including a Block row's nested Source href. Canonical ids and authored data remain unchanged, so follow-up from and relation queries use canonical ids, without .md.

The endpoint accepts POST JSON. Hidden and missing link anchors both return empty results. Contextual named queries remain unavailable. Policy changes during an asynchronous request discard its result; the next request uses current policy.

Include content and assets deliberately

Compiled includes, layouts, and other declared Source dependencies must also be readable in the View. An unavailable dependency makes the page unavailable; core does not return a partially redacted document. Automatically generated site-name metadata is read through the current View at rendering time and omitted when its root Source is unavailable; it does not make every page depend on the root.

Named scripts and styles collected for a contextual document stay in a separate, bounded asset store keyed by the current View scope and repository revision. Their mounted URLs require an admitted owning page. They are not published into the public generated-asset store. Authored attachments follow the owning Source returned by the installed access policy. With SourceAccessPlugin, bind them in that Source’s attachments field. Both the original file and images derived from it must pass the owner’s base access and contextual read restrictions, including when used as a compiled page dependency. Recovery and revocation apply to both. Private attachments must use /private/ or a protected Home. Removing a binding leaves its bytes in place under the storage location's protection; /private/ remains closed after restart. See private files and attachments. Binary paths and generated assets with unknown contextual ownership remain unavailable; package-provided document scripts and styles work through the scoped collector.

Responses use private, no-store, including errors. Rendering checks policy freshness after asynchronous work and before releasing its body. With the live runtime connected, revocation removes the displayed document and refreshes to an unavailable response. Native pages without JavaScript recheck on their next request. A reconnect resolves current credentials through the same host route.

Installed compile hooks and components remain trusted code. Components must read through the supplied ctx.view; compile hooks must declare included Source dependencies. Direct reads from plugin indexes or arbitrary calls to app.assets.publish do not acquire contextual provenance automatically. Audit those producers before using them in a contextual document.

Named queries compose admitted reads through their supplied context.view, including nested view.query calls. Query context has no database capability. Direct, missing, denied, and nested reads register dependencies for their consuming View. Apply admission before projection, counting, or ranking; a derived index must preserve its backing Sources' admission and invalidation semantics. Plugin-generated contextual relations remain unavailable because their opaque outputs do not carry that admission contract. Ordinary visible Source relations are supported.

Allowed Source bodies and metadata remain authored content. Store revealing links and private facts in protected Sources; this API does not provide arbitrary prose or field redaction. Public static export uses ordinary anonymous authority, so private memories and recovery rules must retain base access protection even after a conversation recovers them.