Platform Administration
In plain terms: this is the control room for the whole platform, not any one organization — the six pages below (Organizations, Users, Products, Platform Roles, Staff, Audit Log) let P4P's own team manage every company using the platform, not just their own. It's a separate area from the "Workspace" pages a regular organization member sees.
Everything under /platform/*. Reachable from /dashboard's Platform Administration entry point, which — like every page in this module — requires platform:admin:access (middleware/platform-admin-access.ts), not just SUPER_ADMIN. Until 2026-08-14 the only gate here was holding anyPlatformRole at all, which meant a platform-staff member whose real job was Staffing (platform:staffing:*, /team/*) could still browse this whole control room — every individual route was already permission-gated underneath, so nothing was actually exploitable, but there was no reason for that person to be able to open it at all. platform:admin:access is that base gate now, required in ADDITION to each section's own permission (read vs. manage split consistently across the module — view without a :manage permission is allowed; the mutating action is hidden or blocked). See docs/iam/PERMISSIONS_CATALOG.md's 2026-08-14 entry for the full writeup, including the couple of admin-users.controller.ts routes deliberately left out of this gate because /team/members (Staffing) calls them directly ("Send on leave"/"Return from leave").
All controllers here sit behind @UseGuards(PlatformRoleGuard) at the controller level (self-contained, no TenantGuard ordering dependency — unlike the org-scoped PermissionsGuard, see IAM). SUPER_ADMIN bypasses PlatformRoleGuard unconditionally.
How to...
Grouped to mirror the technical sections below — each page's own help icon links straight into the group relevant to it, not this whole list.
Dashboard
Get to a section — /platform/dashboard, the page Platform Administration opens into: one card per section (Workspaces, Users, P4P Staff, Products, Roles), each carrying its own live counts. Only the sections you can actually read are shown at all — an administrator with a narrow platform role sees a shorter hub, not greyed-out cards.
Check what other administrators have just done — the Recent activity panel under the cards: the five newest audit entries, each with who did it and how long ago, and a red Failed marker on anything that didn't succeed. Hover a row for the full line and the failure reason. View all opens the full Audit log. The panel needs platform:audit:read and is hidden entirely without it.
Read a count of "—" — that number failed to load, or your platform role can't read that section; … means it's still loading. Each tile fails on its own rather than taking the hub down.
Organizations
Create an organization from the platform side — Organizations → New workspace → Name/Workspace URL/Type → Create. See field guide.
Take over a stuck organization — open the organization → Take ownership → give a Reason → Continue → type the workspace's name to confirm. SUPER_ADMIN only. See field guide.
Grant or revoke an organization's access to a product — open the organization → its Products section → Activate/Deactivate per product. (The Products page itself only edits the catalog — name/description/active status — not who's subscribed.)
Override an organization's default language — open the organization → Default language → pick one. SUPER_ADMIN only — this overrides the same setting the organization's own OWNER controls from inside their workspace (see IAM → Organization Settings), without needing to be a member.
Create an organization — field guide
- Name (required) — 2–100 characters.
- Workspace URL (required) — 2–50 characters, lowercase letters/numbers/hyphens only. Auto-fills from Name as you type (e.g. "Acme Corp" →
acme-corp) — start editing it yourself and that auto-fill stops permanently for the rest of this dialog, even if you go back and change Name again. Must be unique platform-wide; a taken one is only caught on submit, not as you type. - Type — Company / Individual / Agency / Service provider / Platform / Personal. Only Personal organizations are created already Active; every other type starts Pending (see the status meanings on the organization's own detail page).
Take ownership — field guide
This button only appears next to whoever currently holds the OWNER role, and only for a SUPER_ADMIN who isn't that owner themself. It doesn't let you hand ownership to a chosen member — clicking it always makes you, the admin doing the clicking, the new owner (joining the organization first if you aren't already a member); the current owner is demoted to a plain MEMBER, not removed.
- Reason (step 1, optional) — free text, purely a record for whoever reads the audit log later.
- Confirmation text (step 2, required) — you must type the organization's exact Name before the final Take ownership button stops being disabled.
Users
Delete a user account — Users → the row → Delete → confirm.
Remove someone from platform staff — open the user → Remove from platform staff. This closes out their staff membership and any platform roles, without deleting their account.
Give someone a platform role, or make them the new platform Owner — open the user → Platform roles → check the roles → Save roles. Transferring ownership itself is a separate button on the same page, Transfer ownership to this person.
Send a staff member on leave, or bring them back — this isn't on this page at all; it's the "⋯" menu on their Staffing → Employees profile.
Platform staff invitations
Invite someone to P4P's internal team — Staff → Invite to platform staff → Email, optionally pre-check Platform roles → Send invitation. See field guide.
Invite platform staff — field guide
This is a bulk invite: a dynamic list of rows, each its own Email + Platform roles pair, with a per-row +/– to add or remove rows.
- Email (required, per row) — no format check beyond "not empty"; a malformed address, or one that's already staff or already invited, is only caught on submit — and only fails that one row, not the whole batch.
- Platform roles (optional, per row) — leaving every box unchecked is fine; that row's invitee simply starts with no platform roles, which is a normal state, not a gap — Team Management access comes from being platform staff at all, independent of any specific role. This field is purely a way to pre-assign roles up front. The seeded Owner/
SUPER_ADMINroles never appear in this list — those can't be granted by invitation, only via Take ownership or a direct role transfer on a user's own page. - Send invitation submits the whole batch in one request. Submit disables while any row has a validation or server-rejection error, and re-enables the moment that specific row is edited — a failed row shows its own reason inline rather than one toast covering the whole batch.
Products & Subscriptions
Edit how a product appears in the catalog — Products → the row's ⋯ → Edit → Name/Description → Save changes. Activating/deactivating a product platform-wide is a separate action from the same menu; granting/revoking a specific organization's access to it is done from that organization's own page — see Organizations above. See field guide.
Edit a product — field guide
- Name — required, 2–100 characters.
- Description — optional, up to 2,000 characters.
- Nothing else is editable here — this dialog only touches how the product is described in the catalog.
- Activate/deactivate (platform-wide) isn't part of this dialog — it's the row's own toggle, reversible, no confirmation needed. Turning a product off hides it from every organization at once, regardless of their individual subscription.
Roles
Create a custom platform role — Roles → Create role → name it, pick permissions → save. See field guide.
Edit, duplicate, or delete a role — the role's ⋯ → Edit, Duplicate, or Delete. Edit and Delete aren't offered for a built-in system role, since it can't be renamed or removed — Duplicate and View permissions still work. Assigning or revoking a role on a user happens from that user's own page — see Users above. Duplicate pre-fills the same form from an existing role's permissions (including a system role's), named "Copy of ⟨original⟩" — nothing is saved until you actually submit it.
Create/edit a platform role — field guide
A dedicated page, not a dialog, so the permission list has room to breathe. Built-in system roles (e.g. SUPER_ADMIN) show up in the same list but have no Edit action and can never be deleted; Duplicate and View permissions still work on them.
- Name — required, up to 100 characters, at least 2. Must be unique across every platform role.
- Description — optional, up to 2,000 characters.
- Permissions — use the search box, or open one section at a time (Organizations, Users, Roles, Staffing, and so on). A role with zero permissions checked is valid — it simply grants nothing. A checkbox on each section's own header selects or deselects every permission in it at once; a second checkbox above the search box does the same across every section, respecting the active search filter — so "select all" on a filtered view only touches what's currently showing.
- Linked P4P Internal role (optional) — a picker limited to P4P Internal's own roles (Team Manager/Team Admin/Team Member, and so on); see above for what it actually does on assignment.
- Save/Create only enables once something is actually different from what was loaded.
Audit log
Nothing to configure — it's read-only. Filter by actor/action/date range directly on the page; see Audit log below for what gets logged and why it's kept separate from ordinary admin work.
Announcements
Write a short Title and Message, then Send. A confirmation dialog stands between you and actually sending — this reaches every P4P staff member's notification bell and inbox at once, and can't be recalled. There's no dedicated history screen here; the record of who sent what, and when, lives in the Audit log below (platform.announcement.sent), same as every other platform-admin action.
AI Assistants
Open the platform assistants — Settings → AI Assistants. One card per global assistant; today there is one, the Platform Assistant.
Change what it is told — open it → Behaviour → Instructions → Save changes. Don't list its tools in there: the platform already hands the model the exact tools it may use, decided per person from their own permissions, so a hand-written copy of that list goes stale the moment a tool is added — which is exactly what had happened before 2026-09-10.
Change which model answers — open it → Model. Only models switched on in the platform catalog are offered, because a switched-off one fails every answer. This is the setting most worth having here: it lets you move the default assistant to a cheaper or newer model without a deploy.
Rename it — open it → Identity. Neither the name nor the description is ever sent to the model; they are how people recognise it.
See who changed it — the link at the bottom of the list, or the Audit log filtered to AI assistants (ai_assistant.updated). Every change here lands on every workspace at once, which is why it is recorded.
What cannot be changed, and why
Three blocks appear on the page marked Platform-managed, with no controls. They are not hidden, because somebody who knows an ordinary agent's page will come looking for them:
- Tools — the built-in platform tools are always on and answer with the asker's own permissions, so there is nothing to grant. An outside MCP connection belongs to a single workspace, so granting one on a shared assistant would do nothing for everybody else.
- Reference images — a pinned image is fetched as the person asking, inside their own workspace; one uploaded here would fail to load for every other organization.
- Automation and schedules — a schedule belongs to one organization and a shared assistant has none. Allowing unattended runs would also let any workspace run this assistant in the background as itself.
Generation settings (temperature, top-p, max output tokens — Decision 20) are not exposed on this page either, unlike an ordinary workspace agent's own Configuration → Model. The underlying row carries the same three columns as any other agent; this page's form simply doesn't offer them yet.
AI models
Open the catalogue — Settings → AI models. One row per model every workspace can pick from.
Fix a model that has gone quiet — open it → choose the Model id from OpenRouter's list → Save. Providers withdraw model ids, and a withdrawn one does not fail loudly: the provider returns an empty answer, so assistants simply stop replying with nothing in any log to explain it. The list is OpenRouter's own, so a typo or a withdrawn id cannot be picked. If the saved id is no longer in the list it is shown first, marked Not in OpenRouter's list.
Add a model — the + button → pick the Provider, then the model. For OpenRouter the name, the price and whether it reads images are filled in from its list and can still be changed. A model that cannot call tools is flagged: the assistant would not be able to reach the board, the calendar or the documentation with it. The provider of an existing model cannot be changed — that is a different model, so add a new one.
Check a model on its provider's site — the small arrow icon on its row, or the link under Model id in the dialog. It opens in a new tab: for an OpenRouter model, that model's own page (price, limits, whether it is being retired); for Claude, GPT or Gemini, the provider's own list of models, since those sites have no stable page per model. A model whose id is half-typed has no link rather than one that leads to an error page.
Take a model out of use — open it → switch Availability off. It stays in the catalogue and disappears from every agent's picker. Withdraw removes it outright; agents that had already chosen it keep the setting until somebody changes it, and their next run fails.
Say a model accepts images — Images on. Chat refuses an image attachment on an agent whose model is not marked this way, rather than sending it and getting a provider error.
Say which generation settings a model accepts — the Generation parameters switches (Temperature, Top P, Max output tokens), on by default. Turn one off for a model that rejects it outright (a reasoning model and temperature, typically) — an agent's own saved value for that setting then simply never reaches this model's calls, on this model alone. For OpenRouter these prefill from the provider's own list, same as Images, and can still be changed.
A change takes effect on the next message — agents already built are rebuilt rather than left on the old model.
Dashboard (/platform/dashboard)
In plain terms: the entry hall of Platform Administration — navigation cards with real numbers on them, plus the five most recent administrative events. /platform itself is only a redirect here.
Deliberately not an analytics screen: no charts, no trends. The numbers exist so a card can tell you whether it's worth opening (78 users, 0 pending invitations), not so this page becomes something you study.
- Every count is real, fetched in parallel from the same list endpoints the section pages themselves use (
/admin/organizations,/admin/users,/admin/users/platform-staff,/admin/products,/admin/platform-roles,/admin/users/platform-staff-invitations), each guarded by the same permission as its card and each degrading to—on its own. - Recent activity is
GET /admin/audit-log?limit=5(platform:audit:read), rendered with the same action-label translation table as the Audit log page — an unknown action code falls back to showing the raw code rather than an empty row. - Cards are filtered by permission, not disabled. Contrast with a workspace's own home, which shows all three of its cards and greys out the ones you can't open — a short fixed set is worth showing in full, this longer one isn't.
- Reaching the page at all needs
platform:admin:access(middleware/platform-admin-access.ts).
Organizations (/platform/organizations, /platform/organizations/[id])
In plain terms: every company/agency/individual using the platform, searchable and manageable from one list. Includes an emergency "take ownership" action for when an organization's real owner is unreachable.
admin-organizations.controller.ts, mounted at admin/organizations.
- List / detail / members (
GET) —platform:organizations:read. - Restore an archived org (
POST :orgId/restore, transitionsARCHIVED→ACTIVE, logged asorg.restored) — confirmed (2026-08-04, greppedapps/portal): no frontend entry point. The only "restore" UI that exists anywhere in the portal is an unrelated Shared Documents feature (restoring a soft-deleted file from trash,POST /organizations/{id}/files/{fileId}/restore) — do not confuse the two. Since archive also has no UI, an organization that becomesARCHIVEDtoday has no UI path back toACTIVEat all — API-only both ways. - Force owner transfer (
POST :orgId/force-owner-transfer, "Take ownership" on the detail page) —SuperAdminGuard, not the general:managepermission. This is explicitly the "SUPER_ADMIN recovery path" (per the code comment) for when an org is stuck without a reachable OWNER — aplatform:organizations:manageholder who isn'tSUPER_ADMINcannot use it. - Force-set default language (
PATCH :orgId/default-locale, added 2026-08-18) — same shape as force owner transfer:SuperAdminGuard, not:manage, and not gated by membership either. Overrides the org's own OWNER-only default language setting from outside the organization.
The "New workspace" dialog on the list page creates an organization directly from the platform side (same underlying POST /organizations as self-service creation — see IAM); its trigger is :manage-gated even though the page itself only requires :read.
Fixed, 2026-08-04: this dialog previously crashed with a 500 on every submit — the organization was actually created in the database (the transaction commits before the HTTP response is built), but OrganizationsService.create() returned the raw Prisma row instead of mapping it to OrganizationDto, and Organization.storageQuotaBytes/ storageUsedBytes (BigInt columns, added for docs/storage/ADR-001-file-storage.md) crash JSON.stringify — so the admin saw an error and had no way to know the workspace existed. getById() already avoided this by hand-shaping its return value; create() now does the same. Caught by scripts/e2e/menu/platform-admin.mjs, which exercises this dialog for real on every run.
Users (/platform/users, /platform/users/[id])
In plain terms: every person with a P4P account, across every organization. Today the interface only lets you delete an account outright — pausing/locking one temporarily has to be done directly against the API (no button yet).
admin-users.controller.ts, mounted at admin/users.
- List / platform-staff list / platform-staff-invitations list / detail (
GET) —platform:users:read. - Delete a user account (
DELETE :id) —platform:users:manage, the only account-status action this page's UI actually exposes (row action on/platform/users). - Suspend / activate / lock / unlock (
POST :id/suspend|activate|lock|unlock) — confirmed (2026-08-04, greppedapps/portal): no frontend entry point for any of the four, despite all four being fully implemented and permission-gated server-side. If an account needs to be suspended or unlocked today, it's a direct API call — there's no button. - Impersonate (added 2026-08-20) — a button on the detail page, hidden for your own account and for any non-
ACTIVEtarget. See Impersonation for the full flow — the global banner it starts, how ending it restores your own session, and why the token backing it is deliberately access-only. - Remove from platform staff (
DELETE :id/platform-staff) —platform:users:manage. Closes out the person'sPlatformStaffMembershipand anyPlatformRoleassignments; distinct from suspending theirUseraccount entirely. - Effective permissions (2026-08-14) — the user detail page's Platform roles panel shows a live, read-only summary of the union of every checked role's permissions, grouped by scope in a closed-by-default accordion, recomputed instantly as you check/uncheck roles and before clicking Save roles — a preview of what the change would actually grant, not a report of what's currently saved. The same accordion component backs the viewer's own read of their saved permissions on
/account/profile; only the data source and copy differ. - Leave / return from leave (
POST :id/leave,POST :id/return-from-leave) —platform:users:manage. Internal-staffing status (on leave / active), not an account-level suspension — affects task/project assignment eligibility in the Staffing module, not login ability.
Platform staff invitations (/platform-staff-invitations/[token])
In plain terms: how someone joins P4P's own internal team (as opposed to being a member of a customer organization). Sent from /platform/staff, a separate flow from inviting someone into a company's workspace.
platform-staff-invitations.controller.ts. Sending an invite (POST /platform-staff-invitations) is gated by the ordinary PlatformRoleGuard + platform:users:invite (delegated 2026-08-13 — see the field guide above) — not SuperAdminGuard, the guard used for impersonation and force-owner- transfer. SUPER_ADMIN and whoever holds the P4P Owner platform role always have it seeded by default, deliberately: inviting someone to become platform staff is ordinary staffing work the company Owner should be able to do directly, unlike the security-recovery actions that stay SUPER_ADMIN-only. An invite can never bundle a SUPER_ADMIN role grant (validated server-side even though sending is already gated). Accepting (GET/POST :token[/accept], public) creates the PlatformStaffMembership row — the prerequisite for holding any PlatformRole at all (see Glossary).
The accepting page (apps/portal/pages/platform-staff-invitations/[token].vue) mirrors /invitations/[token]'s own isWrongAccount guard, one-click-accept- when-already-authenticated, and register-vs-sign-in branch shape almost exactly — but deliberately smaller, since this is a small, low-volume internal flow (docs/ROADMAP.md → Internal Company Workspace): no magic link, no OTP, no Google button, and — the one structural difference worth calling out, not obvious from a quick read — no neutral "Accept invitation" gate button at all. /invitations/[token] shows a plain gate button first and only reveals the sign-in/register form after it's clicked; this page has no started ref, so an unauthenticated visitor lands directly on whichever form applies. A no-password (Google-only) account just gets a plain instruction to sign in elsewhere and come back, rather than the org invitation page's inline password-reset shortcut.
Products & Subscriptions (/platform/products)
In plain terms: the catalog of products organizations can be subscribed to, and who's subscribed to what. Products themselves are added by engineers, not from this page — this page only edits how existing ones appear and grants/revokes an organization's access to them.
admin-products.controller.ts, mounted at admin (routes: products, products/:productId, organizations/:orgId/subscriptions, subscriptions/:subscriptionId).
- Product catalog list —
platform:products:read; create/edit/deactivate —platform:products:manage. The page's edit/activate menu is visible at:readbut only usable at:manage. - An organization's subscriptions — list
platform:subscriptions:read, grant/revokeplatform:subscriptions:manage. There is no self-service subscription flow today — an organization cannot subscribe itself to a product; a platform admin grants it.
Roles (/platform/roles)
In plain terms: the same idea as organization Roles (IAM), but for P4P's own internal team instead of a customer's — a separate set of roles and permissions, never merged with an organization's.
platform-roles.controller.ts, mounted at admin/platform-roles. Same shape as the org-level Roles page (IAM) but for PlatformRole/PlatformPermission instead of Role/Permission — a structurally separate table pair, and PermissionsGuard/TenantGuard (the org-level enforcement path) never reads a PlatformPermission grant, even for SUPER_ADMIN (docs/iam/PERMISSIONS_CATALOG.md).
Grant convenience, added 2026-08-19 — linkedOrgRoleId. A platform role may optionally name one P4P Internal Role (e.g. "Team Manager") to also grant when it's assigned to someone, in the same action — set via the field guide. This does not cross the separation above: nothing in PermissionsGuard reads a platform role's permissions, and nothing in PlatformRoleGuard reads an org role's — the platform role's own assignment flow (PlatformRolesService.assignToUser) just also creates/updates a MembershipRole row on P4P Internal as a second, independent write. Revoking removes exactly that grant, except when it would leave the membership with zero roles (silently skipped rather than failing the platform-role revocation over an unrelated membership's state). Restricted to P4P Internal's own roles — linking to another organization's role would be meaningless, since platform staff are only ever auto-enrolled into P4P Internal.
- List roles + list the permission catalog —
platform:roles:read. - Transfer the seeded
SUPER_ADMIN/owner-equivalent role (PATCH owner/transfer) — deliberately no@RequirePlatformPermissiondecorator (see the code comment at line 49):PlatformRoleGuard's own logic for this route enforces something stricter than the permission system can express. Don't assume "no decorator" means "unguarded." - Create / edit / delete a custom platform role, assign/revoke a role to/from a user, add/remove a permission on a role — all
platform:roles:manage. As covered in Platform Administration → Users's linked catalog notes: aplatform:roles:manageholder can still directly grantSUPER_ADMINto an already-active platform-staff member viaPOST :roleId/users/:userIdwith no extra guard — a known, currently accepted gap (seedocs/iam/PERMISSIONS_CATALOG.md, "Still open").
Seeded roles: SUPER_ADMIN (system, full bypass), P4P Owner and P4P Super Admin (granted the full permission catalog explicitly, for display purposes — see Glossary), P4P Admin (curated subset, excludes platform:roles:manage). P4P Member deleted, 2026-08-19 — it had been reduced to zero permission grants earlier the same day, and turned out to grant nothing else load-bearing either once traced through (the one place that ever gated real access on "holds any platform role at all" was already dead, unreferenced code by then). A new platform staff invitee with no roles picked now simply has zero PlatformRoles — real Team Management access always comes from the separate PlatformStaffMembership → P4P Internal org-membership sync, not from a platform role. See docs/iam/PERMISSIONS_CATALOG.md's own dated entry for the full reasoning.
Audit log (/platform/audit)
In plain terms: the read-only viewer for the same platform-wide history described in IAM → Audit log. Deliberately kept separate from "P4P Admin," the day-to-day admin role — reading everyone's activity history is treated as more sensitive than ordinary admin work.
admin-audit.controller.ts, mounted at admin/audit-log, platform:audit:read. Read-only view over the append-only AuditLog (written from across the whole platform, not just this module — see IAM → Audit log). Deliberately excluded from the P4P Admin convenience role — "who watches the watchers" is narrower than general admin work, same reasoning that keeps platform:roles:manage out of that role.
Announcements (/platform/announcements)
In plain terms: a way for a Super Admin to tell every P4P staff member "here's what's new" — both in their notification bell and by email — without needing a deploy to be the trigger. See docs/iam/PERMISSIONS_CATALOG.md's 2026-08-24 entry for the full reasoning.
announcements.controller.ts, mounted at admin/announcements, platform:announcements:manage (Super Admin only — deliberately excluded from the P4P Admin convenience role, same "who watches the watchers" reasoning as platform:roles:manage/platform:audit:read above). The recipient list is resolved exactly like every other Team Management broadcast — every workspace:staffing:read holder in P4P Internal — not a new "every platform user" mechanism; there isn't one. Delivered through the ordinary notification pipeline (docs/notifications/ADR-001-notifications.md) under its own announcements settings group, so a recipient who doesn't want these can mute the group like any other — everything except account_security is optional. title/body are sent verbatim as both the bell text and the email subject/body — unlike every other notification kind, these aren't rendering values for a fixed sentence, they ARE the message.
Deliberately decoupled from deploys and from package.json's own version number, shown separately in the portal's own sidebar footer — not every deploy is worth announcing, and only a person can write "here's what changed" in words a reader wants. Bumping package.json's version is a separate, fully manual git change — sending an announcement is a natural moment to also do that, but nothing here does it automatically.
AI Assistants (/platform/ai-assistants)
In plain terms: the assistant every workspace talks to before it has agents of its own, and the one place it can be configured.
It is a single Agent row with organizationId: null in the intelligence database — one shared row, not a copy per organization — which is why no workspace can edit it (AgentsService.findOwned refuses organizationId: null, answering 404) and why editing it needs a platform permission rather than the organization-level ai:agents:manage. Every workspace still sees it read-only on the agent's own page under AI Studio, and a holder of the key gets a Configure link from there to here.
platform-agents.controller.ts in intelligence, mounted at platform/agents. Two keys since 2026-09-17: the read routes ask for platform:ai_assistants:read, which P4P Admin holds, and the write route for platform:ai_assistants:manage, which stays Super Admin / Owner — same "reaches everybody" reasoning as Announcements, applied to the instructions rather than to the page. Whoever triages "the assistant stopped answering" needs to see which model is behind it; that used to cost them the write key. The page opens read-only and disables its controls instead of hiding them. It sits behind PlatformAccessGuard, not the OrgAccessGuard every other intelligence route uses: that one demands an X-Organization-Id header and answers with one organization's permissions, which is exactly wrong for a row belonging to none. The guard deliberately never sets an organization on the request either — a global row quietly scoped to whichever workspace the administrator had open is the mistake the separation exists to prevent.
Writable: name, description, instructions, model. Nothing else — see what cannot be changed above.
Changes are recorded as ai_assistant.updated in the Audit log. The record is filed into core-api by intelligence as the administrator, the same way an agent run's notification is: core-api owns the audit log and intelligence does not grow a second one. Unlike those best-effort writes this one fails loudly, and the change is undone with it: if it cannot be recorded, it is not kept. A change to a row every organization uses that nobody can account for afterwards is worse than a change that did not go through.
The seed no longer overwrites it. intelligence's container runs the database seed on every start, and until 2026-09-10 that seed rewrote the assistant's instructions and model each time — which quietly reverted any change on the next deploy. It now only creates the assistant if it is missing. To deliberately replace what is in the database with a newly shipped prompt or default model, set PLATFORM_ASSISTANT_RESEED=1 for one deploy and unset it again (see docs/DEPLOYMENT.md).
AI models (/platform/ai-models)
In plain terms: the one list of models every organization's agents choose from, and the only place it can be edited.
ModelConfig rows in the intelligence database are global — one shared catalogue, not a copy per organization. Reading it is open to any signed-in caller, because the agent form has to offer the list to everyone who may configure an agent and model names are not sensitive. Writing lives on platform-model-configs.controller.ts, mounted at platform/model-configs behind PlatformAccessGuard and platform:ai_models:manage — its own key since 2026-09-17, and one P4P Admin holds. It shared AI Assistants' key until then, on the reasoning that whoever configures the assistant is who fixes the model behind it. The holders turned out not to be the same: curating the catalogue is upkeep, while rewriting what the assistant says to every workspace is a tier above it.
The key everything runs on is set at the top of this page, not in a file. Until 2026-09-17 it lived only in OPENROUTER_API_KEY, so changing it meant an ssh session and a restart. It is stored encrypted now and set here, behind platform:ai_key:manage — Super Admin and Owner only, because it is the credential the whole installation spends money through and every workspace without one of its own runs on it. The environment variable is only a seed: read once on the first boot that finds no key, and never again, so a key removed here stays removed. The key itself is never shown back; the card says whether one is set and when it last changed.
Every change here is recorded in the Audit log as ai_model.created, ai_model.updated or ai_model.withdrawn, filterable under AI models — added with the key, and for the same reason: withdrawing a model leaves every agent that had selected it failing on its next run, and now that this is day-to-day work rather than a Super Admin's, "who withdrew it" has to be answerable. If the log cannot be written the change does not stand: it is rolled back and the screen reports the failure, rather than leaving a change nobody can account for.
Until 2026-09-15 those writes sat behind the organization-level ai:agents:manage, which meant any organization's administrator could repoint or withdraw a model every other organization was using.
Choosing a model. The dialog reads OpenRouter's own list of models, and the server does the reading (GET /platform/model-catalogue, cached for ten minutes) rather than the browser: openrouter.ai answers 403 from some regions this platform is administered from, and the portal's Content-Security-Policy does not name it. The same list is checked on save — an OpenRouter id it does not list is refused, which is what would have stopped qwen3.8-27b:free, (no vendor prefix, a trailing comma) being saved on 2026-09-21. A row whose id has since been withdrawn can still be disabled or renamed: only a changed id is checked. If the list cannot be read at all, the field becomes a text box and the save goes through unchecked — unknown is not the same as forbidden, and an installation with no internet must still be able to record a model. The other providers have no list this platform can read, since their keys belong to each workspace, so their ids are typed. The provider itself is one of four values (openrouter, anthropic, openai, google) because it decides which API a model is called through and whose key it uses.
Why this screen exists at all. A provider can withdraw a model id whenever it likes, and a withdrawn id does not error — the request succeeds and returns nothing. Every assistant on the platform goes quiet, and no log says why. That happened on 2026-09-15 with a free model that had been seeded months earlier, and the only remedy was editing the seed and redeploying.
Not in the form: the base-URL override. It decides where a platform-wide API key gets sent, and is settable only in the database. See docs/ai/ADR-001-ai-platform-architecture.md Decision 14.
Testing this module
scripts/e2e/menu/platform-admin.mjs (23 checks) runs this module's core flows end to end and sets lastVerified above, plus a unit test file per page (apps/portal/pages/platform/*.test.ts) as they're added — page-by-page, tracked as its own round-based effort:
- Dashboard —
apps/portal/pages/platform/dashboard.test.ts, 6 tests — covers which of the hub's cards/fetches are gated behind which read permission, the real total/subset count split per card, the Recent Activity panel needing BOTHplatform:audit:readAND at least one entry, and an unmapped audit action code rendering as its own raw code rather than a blank label;platform-admin.mjsalso confirms/platformreally lands on/platform/dashboardwith real counts, not just that the URL contains "/platform". - Organizations —
apps/portal/pages/platform/organizations/{index,[id]}.test.ts, 16 tests — the search/type/status/membership/owner filters, the "yours" badge's own membership check, the create dialog's slug-auto-derive-until-touched behavior and its create/reload/refresh success and failure paths, the take-ownership flow's own eligibility rule (SUPER_ADMIN, not yourself, only on another member's OWNER row) and its two-step type-to-confirm name-match gate, and the product subscription toggle's grant-vs-suspend POST/PATCH branching (REACTIVATABLE_STATUSES);platform-admin.mjsextended with a live subscription grant from the org detail page — the first live exercise of subscriptions anywhere in this e2e suite. - Users —
apps/portal/pages/platform/users/{index,[id]}.test.ts, 18 tests — the search/status/role/workspaces filters, the role-filter options deriving from the loaded users' own roles (deduped and sorted), the row action menu's self-action guard (never offered on your own row) on top ofplatform:users:manage, the delete-confirm success and failure paths, "Remove from platform staff"'s own eligibility rule (SUPER_ADMIN or Owner, target must actually be platform staff), the ownership-transfer button's rule (current Owner only, target platform staff who doesn't already hold P4P Owner) and its success/failure dialog behavior, the assignable-roles list excluding P4P Owner always and P4P Super Admin unless you're the Owner, andsaveRoles' own assign-vs-revoke diffing (only the roles that actually changed get a request);platform-admin.mjsextended with a live role-assignment-then-staff-revocation round trip on the accounttestStaff/acceptStaffInvitealready create — the detail page's own mutating actions had zero live coverage before this round. Deliberately does NOT drive "Transfer ownership" live — no bug, just a destructive path (handing the seeded SUPER_ADMIN's own Owner role to a throwaway account, no automated way back) skipped on purpose. - Roles + Products —
apps/portal/pages/platform/{roles,products}/index.test.ts, 18 tests — system-roles-first-then-alphabetical sorting, the top-3-permissions-then- "+N more" badge collapse, Duplicate being offered even on a system role while Edit/Delete require a non-system role, the view-permissions dialog wiring, product search across name/code/description, the actions-menu permission gate, the edit dialog pre-filling from the current row, and PATCH success/failure for both the edit form and the activate/deactivate toggle;platform-admin.mjsextended with a live deactivate-then- reactivate round trip on the Products page — the page's own real operational lever (per its own code comment), untested live before this round, restored back to Active afterward so a real seeded platform product isn't left off. "Duplicate" itself isn't driven live — it POSTs the exact same endpoint the existing create-a-custom-role check already exercises, just with the form pre-filled differently. - Staff —
apps/portal/pages/platform/staff/index.test.ts, 7 tests — search/status/role filters, the role-filter options deriving from the loaded staff's own roles, and "Invite to platform staff" needingplatform:users:invitespecifically (not the generalplatform:users:manage). Written when that gate was still the hardcodedisSuperAdmin || isOwner/SuperAdminOrOwnerGuardpair — delegated to the ordinary permission 2026-08-13 (see Staff invitations above);SUPER_ADMIN/P4P Ownerstill have it by default, they just no longer have it as a hardcoded special case.platform-admin.mjsalready covered invite + accept live from an earlier round — no new e2e needed here. - Platform staff invitation acceptance (
/platform-staff-invitations/[token]) —apps/portal/pages/platform-staff-invitations/[token].test.ts, 11 tests — invalid/expired load error, the roles-included vs. bare summary text, the wrong-account mismatch + sign-out, one-click accept success/failure for an already-authenticated correct account, password sign-in + accept (including the invitation-specific "incorrect password" message), the no-password account's plain "sign in elsewhere" instruction (no password field renders at all — a deliberately smaller flow than the org invitation page's own reset-link shortcut, see Accepting an invitation), and the register-form default with submit success/failure. New e2e filescripts/e2e/menu/platform-staff-invitation-accept.mjs(6 checks, 2026-08-12) covers the three branchesplatform-admin.mjs's owntestStaff/acceptStaffInvitedoesn't reach (that pair only ever drives the brand-new-user register path): an already-signed-in one-click accept, the wrong-account mismatch gate, and an existing signed-out account's password sign-in. Found a real structural difference from the org invitation page while writing this: unlike/invitations/[token], this page has no neutral "Accept invitation" gate button at all — an unauthenticated visitor lands directly on whichever form applies, with no intermediate click. - Audit log —
apps/portal/pages/platform/audit/index.test.ts, 11 tests — the default query params (limit/offset/order: desc), every filter's own re-query params including the from/until date-range validation blocking a bad query before it's ever sent, pagination offset/limit math and the reset-to-page-1 behavior,auditChanges/auditExtraMetadata/auditValueLabel's real parsing rules (a realmetadata.changesdiff takes priority over a raw key/value dump of everything else inmetadata), and the resolved actor/entity labels rendering with a copy button per id — with a raw-type + UUID fallback when the backend resolved no name.platform-admin.mjsalready covered entries rendering live from an earlier round.
This closes out unit coverage for every page under /platform/* this module documents. platform/notifications.vue (routed under /platform/ but documented in Notifications, not here) got its own round in the same effort — including a real, significant bug found there — see that module's own "Testing this module" section; platform-admin.mjs's 23-check total includes its 5 checks too.
Found, not fixed (app code, out of this testing round's scope — see scripts/e2e/README.md's own writeup for the full story): the organizations list's "yours" membership badge and DataTable's own trailing detail-link share the literal same accessible name, "Open workspace" (platform.organizations.openWorkspace and common.openWorkspace — two different i18n keys that happen to resolve to identical English text), but point at two different destinations (entering the workspace as a member vs. opening the platform-admin detail view). Only surfaces on a row where the viewing SUPER_ADMIN also happens to be a member — true for any org they just created themselves, since the creator becomes its OWNER.
Found, not fixed (a 5th instance of a known bug, first found 2026-07 on team/departments/[id]/roles.vue's own delete-confirm — see scripts/e2e/README.md's writeup for that round and the other 3 prior instances): platform/users/index.vue's delete-confirm dialog uses AlertDialogAction, which has its own internal close-on-click handler reka-ui merges with this app's @click="confirmDelete" on the same button — a real click during a FAILED delete closes the dialog immediately regardless of the async result, so the inline error never actually renders (the toast still does, since it isn't gated on dialog state).
Found, not fixed (a 6th instance of the same bug): platform/roles/index.vue's own delete-confirm dialog is the same shape — same bug, same fix deferred for the same reason.
Not covered by automation, still worth a manual pass — see docs/MANUAL_TESTING.md: read/manage permission-gating on each page's mutating controls, blocking a SUPER_ADMIN role in a staff invitation, the platform roles owner-transfer path, and platform:audit:read gating on the Audit Log page itself.