opbox

The portal.* family (36 verbs). Every verb enters through the one front door: it is capability-checked, permission-checked, scope-checked and audited before it runs (see Security & permissions). Each entry below lists its capability, risk class (which fixes the minimum autonomy level), the minimum caller permission, its availability tier, whether it is idempotent, and any outbound egress.

Createportal.access.create

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ email, name?, permissions? }. email normalised lower+trim; name sanitized; permissions allow-list-validated (defaults applied). status fixed ACTIVE; created_by_id the acting user. The (workspace_id, email) UNIQUE maps 23505 -> Conflict (the route’s 409).

Call POST /v/portal.access.create with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.access.create.

Deleteportal.access.delete

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

/SENSITIVE/ADMIN. { id }. HARD delete by id + workspace fence; rows_affected==0 -> not-found (the route’s 404). PortalAccess is a leaf - no cascade.

Call POST /v/portal.access.delete with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.access.delete.

Updateportal.access.update

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ id, name?|null, permissions?, status? }. Partial PATCH (at least one mutable field required). name discriminates absent/null/string; permissions re-validated; status validated ACTIVE|SUSPENDED. RLS fence WHERE id+workspace; rows_affected==0 -> not-found.

Call POST /v/portal.access.update with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.access.update.

Upsertportal.access.upsert

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

{ email, name?, permissions?, status? }. Keyed (workspace_id, email): INSERT … ON CONFLICT DO UPDATE SET name/permissions/status. created_by_id resolved kernel-side from the acting bearer (insert only), NEVER a passed-in id. Returns { id, email, idempotent }.

Call POST /v/portal.access.upsert with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.access.upsert.

Archiveportal.archive

PropertyValue
Capabilityportal.write
Risk classDestructive - autonomy L3
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

status=ARCHIVED (terminal ≠ reversible SUSPENDED). Composes the accessVersion scope-fan bump so all links die at once. The exposure record + audit chain are RETAINED-not-deleted (INV-8). Post-archive auth is a hard deny; refuse-reactivate.

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.archive with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.archive.

Bump access versionportal.bump-access-version

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

Bumps accessVersion; invalidates EVERY outstanding link token at once by bumping the portal token scope (token.revoke-scope - the generalised twin). A stale token → 401-equivalent

  • PORTAL_TOKEN_REUSED_AFTER_REVOCATION (the token verb emits that security event).

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.bump-access-version with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.bump-access-version.

Applyportal.change.apply

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

{ changeRequestId }. State guard: only APPROVED -> APPLIED else Conflict; already-APPLIED -> changed

(idempotent). SCOPE: this verb flips the status ONLY - the cross-domain application of the changes (csp/equity/party writes) is OUT OF SCOPE and stays in the route.

Call POST /v/portal.change.apply with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.change.apply.

Cancelportal.change.cancel

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

{ changeRequestId, linkId }. The portal CLIENT cancels their OWN pending request under the link bearer. Fence: the link must resolve in the caller’s workspace; then UPDATE WHERE id+link_id+PENDING -> REJECTED (‘Cancelled by client’). rows_affected==0 -> changed

.

Call POST /v/portal.change.cancel with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.change.cancel.

Createportal.change.create

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ portalId, linkId, matterId?, changes }. The portal CLIENT submits a PENDING change request under a portal-link session principal. portalId + linkId fenced in the caller’s workspace; changes must be a non-empty array.

Call POST /v/portal.change.create with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.change.create.

Editportal.change.edit

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ changeRequestId, keepItemIds }. Filters the stored changes JSON to keep only items whose .id is in keepItemIds. State guard: only PENDING else Conflict. A zero-length result -> BadInput (reject instead); nothing removed -> changed

(no write).

Call POST /v/portal.change.edit with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.change.edit.

Reviewportal.change.review

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ changeRequestId, action: APPROVE|REJECT, reviewNotes? }. State guard: only PENDING -> APPROVED/REJECTED else Conflict. reviewed_by_id attributed to the acting back-office user (NEVER a passed-in id); reviewed_at stamped.

Call POST /v/portal.change.review with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.change.review.

Createportal.create

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

A system row, NOT a JSON blob (D1). Pins the template version (snapshot, US-PORTAL-07); accessVersion=0. The config is the pinned template-version defaults ⊕ per-portal overrides (a deep merge at create - closes the ledger open-q).

Input { name, templateId?, templateVersion?, config?, expiresAt? } (fields ending in ? are optional)

Call POST /v/portal.create with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.create.

Getportal.get

PropertyValue
Capabilityportal.read
Risk classRead - autonomy L0
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)
Render schemaYes - drives an inline chat artefact and a GUI panel

One portal + resolved config + computed counts. NEVER a secret.

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.get with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.get.

Activateportal.link.activate

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

The atomic guarded flip where exactly ONE claim wins under a race (UPDATE ... WHERE status='PENDING', the same APPROVED≠APPLIED discipline as the Gate). A second concurrent claim matches 0 rows → no-op-or-deny, never a double-activate. Until ACTIVE the link cannot authenticate (auth loads ACTIVE only).

Input { linkId } (fields ending in ? are optional)

Call POST /v/portal.link.activate with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Addportal.link.add

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

Resolves-or-creates a Party portal-principal facet (ADR-0009 - the email IS the principal identity; no second identity). Mints a PORTAL_SESSION Token (EPIC-TOKEN) bound to the portal scope; the secret is returned EXACTLY ONCE. The link lands PENDING (its PENDING→ACTIVE forward edge is portal.link.activate - never lands ACTIVE here).

Input { portalId, email, name?, expiresInSecs? } (fields ending in ? are optional)

Call POST /v/portal.link.add with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Revokeportal.link.revoke

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

status=REVOKED (the per-link axis, INDEPENDENT of accessVersion). Also revokes the link’s bound Token (the per-token kill axis). Only THIS link is cut.

Input { linkId } (fields ending in ? are optional)

Call POST /v/portal.link.revoke with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.link.revoke.

Set expiryportal.link.set-expiry

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

The GROW of [2026] VJS-CC-OPBOX 51 Part 2 (verb-count 23→24): writes the EXISTING expires_at column (no new column - C3). FAIL-CLOSED: the new expiry MUST be RFC3339 AND in the FUTURE (a <= now() expiry is refused, so set-expiry can never silently expire a live link). The CAS over status IN (‘ACTIVE’,‘PENDING’) MUST NOT resurrect a REVOKED link (a REVOKED row is the terminal per-link kill - untouched; rows_affected 0 → ‘no active link’). The portal_link_status enum is only PENDING|ACTIVE|REVOKED (0006_portal.sql) - there is NO ARCHIVED link state, so the CAS already excludes every non-live state. This NEVER weakens the portal.bump-access-version scope-fan kill (that lives on the portal accessVersion axis, untouched). The cross-client fence (assert_bound_portal) is asserted on the link’s OWN portal (resolved from the row - this verb carries no portalId). Emits exactly ONE INV-8 audit event via append_event.

Input { linkId, expiresAt } (fields ending in ? are optional)

Call POST /v/portal.link.set-expiry with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Set roleportal.link.set-role

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

(role VIEWER|EDITOR|ADMIN, upper-cased). The GROW of [2026] VJS-CC-OPBOX 39: the court CALLED PortalLink.role a regulated external-principal AUTHORITY fact, additive-grown onto the kernel SoR row + enforced HERE at the verb door (never a Prisma-desyncable frontend extension, C4). FAIL-CLOSED: an unknown role is refused on the wire; a non-existent / REVOKED link is refused (the authority of a terminated principal is never re-set). The cross-client fence (assert_bound_portal) applies on the link’s OWN portal, resolved from the row (this verb carries no portalId) so a portal-bound token can never re-authorise another client’s link. The new role check is ADDITIVE - it never relaxes the existing SENSITIVE/Admin door + the anti-enumeration fence (C1: no new external surface). Emits exactly ONE INV-8 audit event via append_event (CC-OPBOX 34: NO new audit verb). SECTION D inapplicable (C5): no append-only ledger on portal_link - the event chain is the floor.

Input { linkId, role } (fields ending in ? are optional)

Call POST /v/portal.link.set-role with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Listportal.list

PropertyValue
Capabilityportal.read
Risk classRead - autonomy L0
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)
Render schemaYes - drives an inline chat artefact and a GUI panel

Each portal’s {id,name,status,accessVersion,linkCount,objectCount}. Counts are COMPUTED at read (INV-6), never a stored denormalised field. IDOR-scoped (INV-11).

Input { status? } (fields ending in ? are optional)

Call POST /v/portal.list with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.list.

Postportal.message.post

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

A write operation in the portal.* family. Called through the one governed front door: capability, permission tier, autonomy, scope and egress are all resolved before the handler runs, and the call is audited (INV-1, INV-8).

Input { portalId, linkId?, direction, body, attachments?, external?, origin? } (fields ending in ? are optional)

Call POST /v/portal.message.post with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.message.post.

Attachportal.object.attach

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

Validates row-exists-and-in-workspace AT ATTACH (closes the integrity open-q). Default visibility=FULL, hiddenFields=[], writeBack=false. Un-attached = hard deny (INV-11) - this is the ONE canonical exposure write (INV-1): nothing else widens exposure (not even a client request).

Input { portalId, objectType, objectId, sectionKey?, visibility?, hiddenFields?, writeBack?, traversal? } (fields ending in ? are optional)

Call POST /v/portal.object.attach with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.object.attach.

Detachportal.object.detach

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

Un-attached = hard deny (INV-11); reversible re-attach.

Input { portalId, objectType, objectId } (fields ending in ? are optional)

Call POST /v/portal.object.detach with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.object.detach.

Listportal.object.list

PropertyValue
Capabilityportal.read
Risk classRead - autonomy L0
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)
Render schemaYes - drives an inline chat artefact and a GUI panel

The exposure manifest: each PortalObject’s ref/visibility/hiddenFields/writeBack/traversal. “Exactly what is exposed” from one read.

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.object.list with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.object.list.

Reactivateportal.reactivate

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

SUSPENDED→ACTIVE (idempotent on an already-ACTIVE portal). An ARCHIVED portal can NEVER be reactivated (terminal - refuse).

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.reactivate with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.reactivate.

Request accessportal.request-access

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionExternal (portal / signer token)
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

The deny-default “request more” affordance (G-PORTAL-REQ). Captured as gate.propose kind=PORTAL_ACCESS_REQUEST. It NEVER widens exposure itself - the firm approving the Gate runs portal.object.attach (the ONE canonical exposure write, INV-1); the client can NEVER widen their own exposure. Anti-enumeration rate-limited; audited (INV-8).

Input { portalId, linkId?, objectType, objectId, reason?, external?, origin? } (fields ending in ? are optional)

Call POST /v/portal.request-access with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Startportal.session.start

PropertyValue
Capabilityportal.read
Risk classSensitive - autonomy L2
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

A sensitive operation in the portal.* family. Called through the one governed front door: capability, permission tier, autonomy, scope and egress are all resolved before the handler runs, and the call is audited (INV-1, INV-8).

Call POST /v/portal.session.start with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.session.start.

Set visibilityportal.set-visibility

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

The 5-state enum; the write-back precondition is writeBack && visibility===FULL (NFR-PORT-3) - enforced at the read/edit resolver, recorded here as the policy.

Input { portalId, objectType, objectId, visibility?, hiddenFields?, writeBack? } (fields ending in ? are optional)

Call POST /v/portal.set-visibility with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Shareportal.share

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

C16: a Portal with exactly one PortalObject READ_ONLY, no named link, maxDepth:0, and a Token bound to it. The SAME exposure model - not a fourth bespoke token scheme.

Input { objectType, objectId, name?, expiresInSecs? } (fields ending in ? are optional)

Call POST /v/portal.share with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Suspendportal.suspend

PropertyValue
Capabilityportal.write
Risk classSensitive - autonomy L2
Min. permissionAdmin
AvailabilityAdmin only (CLI/HTTP; never advertised over MCP)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

SUSPENDED blocks all link auth (auth loads ACTIVE only); reversible (≠ archive).

Input { portalId } (fields ending in ? are optional)

Call POST /v/portal.suspend with a JSON body; returns JSON (or { error, code }). Not advertised over MCP (admin-only); reachable via the CLI and HTTP doors.

Assignportal.task.assign

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

(firm/agent side).

Input { portalId, linkId?, taskType, title, target? } (fields ending in ? are optional)

Call POST /v/portal.task.assign with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.task.assign.

Cancelportal.task.cancel

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

(the FIRM cancels, NOT the client - NOT EXTERNAL). The GROW of [2026] VJS-CC-OPBOX 51 Part 2 (verb-count 22→23): a PURE status compare-and-set PENDING|IN_PROGRESS → CANCELLED writing ONLY existing columns (status, updated_at). WHO cancelled rides the INV-8 audit event (ctx.actor.id, appended by the dispatch) - the draft’s cancelled_by is STRUCK by the ruling: portal_task carries no cancelled_by/cancelled_at column (0006_portal.sql), so writing them would be an ALTER TABLE ADD COLUMN that falsifies verbs-only AND escapes the FI-1 schema_consolidation_gate (C2). The CANCELLED enum value already exists (read by task_complete). Idempotent: a re-cancel of an already-CANCELLED task is a no-op-in-effect - the CAS matches 0 rows on a terminal row, so we re-read the row and echo the existing CANCELLED state (never an error on a double-cancel); a non-existent task is a friendly BadInput (matches task_complete’s not-found shape).

Input { taskId } (fields ending in ? are optional)

Call POST /v/portal.task.cancel with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.task.cancel.

Completeportal.task.complete

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

EXTERNAL/L1/idempotent. A FORM task flows via form.ingest→matter.advance (INV-1) when its target carries a {formId, matterId?}; otherwise it just records completion. Idempotent on completionKey (a callback/retry is a no-op).

Input { taskId, completionKey?, result?, external?, origin? } (fields ending in ? are optional)

Call POST /v/portal.task.complete with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.task.complete.

Createportal.template.create

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ name, description?, addonKeys?, sectionDefs?, defaultConfig?, isBuiltin?, isActive? }. created_by_id the acting user. No (workspace_id, name) UNIQUE exists, so the route keeps its name-collision / builtin-existence findFirst pre-checks (RLS reads); this is a plain INSERT.

Call POST /v/portal.template.create with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.template.create.

Deleteportal.template.delete

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentYes
EgressNone - in-box (pure Postgres, no outbound call)

{ id }. SOFT delete (is_active=false), NOT a row delete, so portals referencing the template survive. RLS fence WHERE id+workspace; rows_affected==0 -> not-found.

Call POST /v/portal.template.delete with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.template.delete.

Updateportal.template.update

PropertyValue
Capabilityportal.write
Risk classWrite - autonomy L1
Min. permissionMember
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)

{ id, name?, description?|null, sectionDefs?, defaultConfig? }. Partial PATCH (at least one field). RLS fence WHERE id+workspace; rows_affected==0 -> not-found.

Call POST /v/portal.template.update with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.template.update.

Traverseportal.traverse

PropertyValue
Capabilityportal.read
Risk classRead - autonomy L0
Min. permissionExternal (portal / signer token)
AvailabilityOn demand (MCP tier 2)
IdempotentNo
EgressNone - in-box (pure Postgres, no outbound call)
Render schemaYes - drives an inline chat artefact and a GUI panel

The exposure-preview read source AND the external client read path (the SAME path - INV-1, no second exposure computation).

An EXTERNAL caller (external:true) resolves ONLY the allow-list (NFR-PORT-1): an un-attached node is a HARD DENY (INV-11). The walk honours maxDepth + precedence (per-object → section → template → {explicit, maxDepth:0} deny-by-default). Un-pre-listed reachable nodes surface as REQUEST_ONLY ephemerals (an affordance, NOT a read). The secret is NEVER in the payload.

Input { portalId, rootObjectType, rootObjectId, external?, origin? } (fields ending in ? are optional)

Call POST /v/portal.traverse with a JSON body; returns JSON (or { error, code }). Also exposed as the MCP tool portal.traverse.