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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Destructive - autonomy L3 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - in-box (pure Postgres, no outbound call) |
{ changeRequestId }. State guard: only APPROVED -> APPLIED else
Conflict; already-APPLIED -> changed
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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.read |
| Risk class | Read - autonomy L0 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
| Render schema | Yes - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.read |
| Risk class | Read - autonomy L0 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
| Render schema | Yes - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.read |
| Risk class | Read - autonomy L0 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
| Render schema | Yes - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | External (portal / signer token) |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.read |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Sensitive - autonomy L2 |
| Min. permission | Admin |
| Availability | Admin only (CLI/HTTP; never advertised over MCP) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | Yes |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.write |
| Risk class | Write - autonomy L1 |
| Min. permission | Member |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - 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
| Property | Value |
|---|---|
| Capability | portal.read |
| Risk class | Read - autonomy L0 |
| Min. permission | External (portal / signer token) |
| Availability | On demand (MCP tier 2) |
| Idempotent | No |
| Egress | None - in-box (pure Postgres, no outbound call) |
| Render schema | Yes - 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.