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