Architecture¶
portalr's design is lattice RFC-0002, and its plan RFC-0001. Its decisions are in adr/. This page describes what exists.
The parts¶
| Part | Holds |
|---|---|
portalr.actions |
portalr's catalog: portalr:read_telemetry, portalr:read_spend and portalr:read_deployment, for Grants(catalogs=...) |
portalr.tenant |
Tenant, read-only, which only allowed() makes; the layering test keeps allowed() to the router, which calls it after grantr allows the read |
portalr.readers |
One reader per tool: Tempo, Loki, Prometheus (through prom-label-proxy), Langfuse and LiteLLM, and Grafana for operators' deep links. Readers holds those the stack runs. The only code that talks to the tools |
portalr.invites |
Invite, and SupabaseInvite over Supabase Auth's admin API |
portalr.people |
People, the hook that names people by email, and SupabasePeople over Supabase Auth's admin API, with a short in-memory cache (ADR-0038) |
portalr.fastapi |
portalr_router, its Site settings, the UIs' serving, and its OpenAPI document |
web/ |
The Bun workspace: client, ui, app and admin (ADR-0008) |
The router¶
An application mounts portalr_router(resolve_actor=, grants=, readers=, invite=, site=, people=) at /portalr (ADR-0011). grants is grantr's Grants, with portalr's catalog, portalr.actions.ACTIONS, among its catalogs: grantr's roles give a tenant's owners and admins its reads, and operators the deployment's.
| Route | Answers | Needs |
|---|---|---|
/portalr/app/, /portalr/admin/ |
The built UIs: hashed assets cached for good, the index for every client-side route, a strict Content-Security-Policy | Nothing |
GET /portalr/config.json |
Supabase's URL and publishable key, the surfaces' paths, which tools are configured, whether people are named, and each native file's app's hosted editor (ADR-0049) | Nothing |
GET /portalr/v1/traces, /traces/{id} |
The tenant's traces, a session's or a run's, and one trace's spans, each once, with only the attributes known to be safe: the rest are named, and given for one span with content=true&span=, not kept (ADR-0030) |
portalr:read_telemetry |
GET /portalr/v1/logs, /logs/{record_id} |
The tenant's newest logs in a window of at most 24 hours, a thread's or a run's, without their lines and with only the labels known to be safe; one record's line, no-store, when asked for (ADR-0031) |
portalr:read_telemetry |
GET /portalr/v1/metrics |
turn_latency, or failed_turns counted over each step, over time, from Prometheus through prom-label-proxy |
portalr:read_telemetry |
GET /portalr/v1/scores |
The tenant's feedback and evaluation scores, newest first, each with its trace's workspace, from Langfuse, and whether they are all there were (ADR-0027) | portalr:read_telemetry |
GET /portalr/v1/feedback |
The tenant's feedback and evaluations in a window of at most 91 days, or one workspace's, summed by day in UTC from its scores, within the read's bounds (ADR-0027) | portalr:read_telemetry |
GET /portalr/v1/spend |
The tenant's spend against its budget, from its LiteLLM team | portalr:read_spend |
GET /portalr/v1/usage |
The tenant's spend per day in a window of at most 91 days, from its LiteLLM team, each named workspace's from its tenant-workspace: tag, and a day's share of its budget, within the read's bounds (ADR-0028) |
portalr:read_spend |
GET /portalr/v1/deployment/tenants |
Every tenant, and their spend from one LiteLLM team list | portalr:read_deployment |
POST /portalr/v1/deployment/tenants |
Creates a tenant, its first owner invited or found by email through Supabase Auth, then made by grantr; the tenant and the owner's user id are answered, never the email (ADR-0034) | portalr:read_deployment, and grantr:create_tenant |
GET /portalr/v1/deployment/links |
Deep links into Langfuse and Grafana for a trace, a session, a run or a tenant, each recorded on portalr.access with who and the kinds of id, never an id's content (ADR-0035) |
portalr:read_deployment |
GET /portalr/v1/people |
The emails of up to 100 people by user id: a tenant's members see its other members', an operator anyone's, with or without a tenant; an id not allowed, unknown or without an email is left out, alike, and Supabase is asked only about the ids allowed. no-store (ADR-0038) |
Membership of X-Tenant, or portalr:read_deployment |
POST /portalr/v1/invites |
Invites someone through Supabase Auth, and adds them to the tenant in grantr | grantr:add_member |
Each read names its tenant with X-Tenant. Each answers ok, unavailable or unconfigured, so a tool that is down fails only its own read. A refused request answers 400, 401, 403 or 422 with a Refused body, as artifactr's and reflexr's routers do: lattice-core's, from which refusals builds the four statuses it documents (lattice ADR-0021). schemas/portalr.openapi.json is the routes' document, and a test holds it to the router.
The readers¶
Each reader takes a Tenant and typed parameters, and builds its query with the tenant first:
| Tool | The tenant, in every query |
|---|---|
| Tempo | span.tenant.id, in TraceQL. A trace read by its id is the tenant's only if one span names it and none names another |
| Loki | tenant_id, the structured metadata the collector's tenant.id becomes |
| Prometheus | tenant_id in every selector, and prom-label-proxy's tenant_id parameter, which rewrites every selector |
| Langfuse | The traces' tenant:<id> tag: Langfuse 4's observations API finds the tenant's traces by it, checked again on each, and only scores on those traces are kept |
| LiteLLM | The tenant's team, tenant-<id>, checked again on its answer |
tests/readers/test_queries.py builds every query each reader can send, and fails if one lacks its tenant.
The acceptance tests¶
moon run portalr:api starts a test application, tests/app/, beside Tempo, Loki, Prometheus and prom-label-proxy, from compose.yaml and stackr's configuration (ADR-0012). Then:
tests/acceptance/seeds two tenants' traces, logs and metrics, one trace shared between them, and reads as each, through the readers and raw through prom-label-proxy: neither sees the other's;- the Bruno collection's
up/reads every route, and checks the refusals; - Tempo, Loki and the proxy stop, and
down/finds each readunavailable; - Schemathesis runs over
schemas/portalr.openapi.json.
Langfuse and LiteLLM aren't in that stack, so their reads answer unavailable there; contract tests cover them, and stackr's Smoke job reads both up through scripts/smoke_readers.py.
The app¶
web/app is the people's UI, at /portalr/app/. It reads config.json first, signs in through Supabase Auth's client, @supabase/auth-js, and asks the server through client's queries, each carrying the current token and the tenant its route names (ADR-0014). Commands go over each library's stream, except a stored rule's, which goes over reflexr's REST, for an answer to every one; and each live event on a workspace's streams makes stale the reads it changed (ADR-0020). What a workspace's runs filed in its tracker is read from relayr's own route, where config.json's surfaces.relayr names one, and its runs' chains and the tracker: events that fired them come from reflexr's stream (ADR-0047). A workspace's integrations are read once as it opens, from grantr, and kept: they say which of Markdown's add-ons it may draw, each is its registry default where they can't be read, and a command artifactr refuses as not_enabled reads them again (ADR-0045).
| Route | Shows |
|---|---|
/sign-in |
An email and a password |
/ |
The person's tenants, from grantr's /me: none is no access, and one goes straight to it |
/t/$tenant |
The workspaces grantr says they can open there |
/t/$tenant/w/$workspace |
A workspace's threads, and a new one; its artifactr stream is open while any of its routes is, and its integrations are read as it opens |
/t/$tenant/w/$workspace/threads/$thread |
A thread's chat: its messages, its runs as they stream, what a paused run waits on, its proposals, and a message to post where grantr allows; and its menu, to restrict and share it (ADR-0022) |
/t/$tenant/w/$workspace/artifacts |
The workspace's artifacts that aren't archived, each by kind and latest version |
/t/$tenant/w/$workspace/artifacts/$artifact |
An artifact through its kind's renderer, its revisions newest first with what each changed, and its open proposals; for a native file, who has it, its pages, a download, and each revision's words compared on demand (ADR-0048), checking it out, which opens its app's hosted editor on an origin of its own, and in, with the upload's progress (ADR-0049), and its open proposals as on the workspace's proposals (ADR-0050) |
/t/$tenant/w/$workspace/proposals |
Every proposal in the workspace, the open ones first, to accept or reject; a native file's with how it was made, whether it is outdated, and its words and pages beside the head's (ADR-0050) |
/t/$tenant/w/$workspace/runs |
The agent's turns and the rules' runs, newest first, with Stop, Retry, Skip and Cancel as each library and grantr allow, and thumbs on each; a rule's run links to the tracker items its chain filed, from relayr's read, and to those whose tracker: events set it off (ADR-0046) |
/t/$tenant/w/$workspace/rules |
The workspace's rules, each with its origin, version and how far it has read, the schedules that tick into it, and a stored rule installed from its JSON where grantr allows |
/t/$tenant/w/$workspace/rules/$rule |
A rule: a code rule's JSON, or a stored rule's, edited and archived where grantr allows |
TanStack Router's route tree, src/routeTree.gen.ts, is committed, and a test holds it to src/routes/. moon run app:bdd runs one feature per area through playwright-bdd, in Bun, against the test application with Supabase Auth (GoTrue, alone) in compose's bdd profile, served at /auth/v1 on its origin (ADR-0013). The native files' scenarios check files out to the hosted editor, a stand-in for WordCraft's build that compose's editor profile serves on a port of its own, which the script starts first and names to the application (ADR-0050).
The chat¶
ui's chat renders what client's artifactr store holds, and hands each command to its caller (ADR-0015):
| Component | Renders | Sends |
|---|---|---|
Messages, Message, Streaming |
A thread's messages, oldest first, and what a run is streaming | Thumbs on a message; a message copied as the Markdown it was written in (ADR-0043) |
Composer |
A message being written | The message |
ToolCall |
A tool a run called, and how it returned | Nothing |
Request |
What a paused run waits on: a call to approve, or a question | An approval, a denial or an answer |
ProposalCard |
A proposed change, its rationale and how it was resolved, and why it can't be accepted where its caller says so | Accept or reject |
RunStatus |
How a run is going | Nothing |
ArtifactContent, rendererFor, markdown, json |
An artifact, through the renderer its kind is registered with, or as JSON; a renderer also gives its title (ADR-0029) | Nothing |
SchemaForm |
A form an agent wrote as data, in lattice RFC-0005's JSON Schema subset, with the kit's controls; what it or the server refuses is said under each field | The values |
DashboardView, PANELS |
A dashboard's snapshot: figures, the charts, a table, Markdown and forms by id, each panel drawn by the renderer panels registers for its type |
Nothing of its own |
UI_RENDERERS, FormHost |
The renderers of artifactr.ui's form, submission, dashboard and app; a form sends through the page's FormHost, and without one shows its fields, read-only; an app shows its label and summary, and runs nothing |
A submission |
Diff |
What a revision changed, as client's changes cut it |
Nothing |
nativeFile, FileHolder, PageStrip, PageCompare |
A native file's manifest, its name, type, size, pages and outline; who has it, as a shape and words; its pages, as its app draws them (ADR-0048); and two versions' pages side by side, each said changed or not (ADR-0050) | Nothing |
FileChooser, Progress |
A file to choose through its labelled input, or drop instead; how far an upload has gone (ADR-0049) | Nothing |
Thumbs |
The reader's thumbs on a message, an artifact or a run, and why | Thumbs, then a comment (ADR-0018) |
CommandButton |
One action, waiting while its command is in flight | The command |
Markdown |
Markdown through Streamdown: no raw HTML, links to the web, mail, the page and the application alone, images from this origin alone; GitHub's callouts as alerts, footnotes of each message's own, and code blocks with their language, Wrap and Copy (ADR-0043); highlighting in fifteen languages, and math as MathML, each loaded when a message needs it (ADR-0044) | Nothing |
IntegrationsContext |
Whether each of lattice's integrations is on, by its key, which the app gives from a workspace's integrations: Markdown's add-ons, where one that isn't drawn shows its source with a quiet line, and whether a form may be sent and a dashboard's charts drawn. Each is its registry default where no page says: all on but Mermaid, which is parked, and mini apps (ADR-0042, ADR-0045, ADR-0047) | Nothing |
ui imports client's types alone, which a root test holds it to. Markdown and a dashboard are held to the UIs' Content-Security-Policy too: ui's csp tests build a page of them as the UIs are built, serve it with the policy a portalr test holds to the router's, draw each from the page's own task, and fail on any violation in Chromium (ADR-0043, ADR-0047).
A dashboard's panels are plain JSON, with nothing in them that runs, and each is drawn by a registry of renderers keyed by its type, PANELS by default: StatCard, Markdown, and the dashboards add-on, loaded on demand while ui:dashboards is on, which holds the kit's charts and a table panel's sorting, filtering and pages on TanStack Table. Another drawing of charts or tables replaces a renderer there, and neither the panels' format nor the artifact's schema changes; each chart's figures are built from its panel, whatever draws it, and are open where the add-on is off or won't load (ADR-0047).
Forms, dashboards and mini apps are switched on and off per workspace by an integration's key, ui:forms, ui:dashboards and ui:mini-apps, as Markdown's add-ons are by markdown:math and markdown:mermaid, which every part asks through one seam, IntegrationsContext, an isEnabled(key) over the registry's keys. The app gives it a workspace's integrations through client's useEnabled, so each is its registry default until they are read and where they can't be; a page with no read, as admin, has the same defaults, which ui writes once and a test holds to the registry (ADR-0045, ADR-0047). A key that is off degrades to read-only, said as "Not available in this workspace": a form keeps its fields, disabled, says which is off, and has no button; a dashboard still shows its snapshot, and its forms can't be sent; an app is its label and summary, as always in phase 1. With dashboards off, a chart isn't drawn and its figures are open. A command the server refuses as not_enabled, which names the integration, degrades the same way, at once, and makes the app read the workspace's integrations again; once that read says the integration is on, the form can be sent again.
admin¶
web/admin is the UI for a tenant's owners and admins and the deployment's operators, at /portalr/admin/, on the app's session (ADR-0023). The session, where each UI opens, allowedIn and useMe are client's; signing in, the account and the tenant switch are ui's.
| Route | Shows |
|---|---|
/sign-in |
An email and a password |
/ |
Where admin opens: its last choice while still the person's, their one tenant, every tenant for an operator, a choice, or no access |
/t/$tenant |
A tenant someone owns or administers, in admin's frame, with the areas grantr allows |
/t/$tenant/ |
Home: members, workspaces, feedback, failed turns and spend, each a card from one read, with its own state |
/t/$tenant/access/members, /groups, /keys, /roles, /grants |
Access: members, invited or made as service principals, and removed; groups; keys, a secret shown once, and revoked; roles, built in and custom; and grants, given and revoked. Each part where grantr allows its read, as a table or, on a phone, cards (ADR-0024, ADR-0025) |
/t/$tenant/integrations |
Integrations: on the tenant or in a workspace the address chooses, each integration switched on or off, or back to what it inherits, with where its state comes from, the tenant's off and the deployment's not installing as ceilings, and who set it there and when (ADR-0045) |
/t/$tenant/audit |
Audit: grantr's log for the tenant, the newest on top, a sentence per change with who made it and when, filtered by type and maker in the address (ADR-0026) |
/t/$tenant/feedback |
Feedback: people's thumbs per day, each evaluation's mean per day, each workspace's share and thumbs, and what people said, from portalr's summary, in a window and a workspace the address keeps (ADR-0027) |
/t/$tenant/traces, /traces/$trace |
Traces: the tenant's newest traces in a window, or a thread's or a run's, and a trace's spans as a tree and a waterfall, with what a span withheld read for that span when asked, from Tempo (ADR-0030) |
/t/$tenant/logs |
Logs: the newest records in a window, or a thread's or a run's, with a line read for one record when asked, and Health: a turn's latency and failed turns over time, at a step it says (ADR-0031) |
/t/$tenant/runs |
Runs: a workspace's rule runs live from reflexr's stream, those needing attention or every one, each retried, skipped or cancelled after a confirmation, once, with what people did to it, and the tracker items its chain filed; beyond the RFC's read-only row (ADR-0032, ADR-0047) |
/t/$tenant/usage |
Usage: the spend per day, each day against its share of the budget where there is one, and each workspace's part, from portalr's usage read, in a window the address keeps (ADR-0028) |
/deployment |
Every tenant, for operators, with the deployment's areas alone |
/deployment/ |
Home: the tenants, and their spend |
/deployment/tenants |
Tenants: every tenant with its spend and budget, fifty at a time, and a tenant created with its owner by email, once confirmed (ADR-0034) |
/deployment/audit |
The deployment's audit log: operators made and revoked, and which tenant each operator created and for whom, newest first (ADR-0034) |
/deployment/tools |
Tools: links into Langfuse and Grafana for the ids given, a notice that they are outside the gates of Traces and Logs, and only web addresses as links (ADR-0035) |
moon run admin:bdd runs admin's features against the test application, which sends its telemetry through stackr's Collector to Tempo, Loki and Prometheus, with Langfuse and LiteLLM unconfigured.
The kit's look¶
ui's tokens are lattice ADR-0014's, with --edge, --wash, --accent-hover and --scrim beside them (ADR-0019), and --danger, for destructive actions alone (ADR-0021), each pair's contrast asserted in both schemes. Its typefaces, Schibsted Grotesk and Fragment Mono, are served from the UIs' own origin. The frame and its parts:
| Component | What it is |
|---|---|
AppShell |
The frame: a rail from 1024 pixels, a bar and a drawer below, and the mark of app or admin |
SideNav, NavLink |
The areas, the current one marked, with how many wait for the reader |
Switcher |
The tenant and the workspace, or the tenant alone, and a menu of the others |
AccountMenu |
Who is signed in, their scheme, and signing out |
PageHeader |
A page's way back, its one heading, a line about it, and its actions, set in the kit's type scale (type.ts: weights stop at 600), under a page width that centres a wide screen's content |
Card, EmptyState, Table |
What asks something of the reader; nothing yet, and what to do; rows, named by a caption or by a heading beside them, a row's header in the row's own type |
Rows, Row, RowLink, SectionTitle |
A lined list, each row's link stretched over it; a section's heading |
Plain |
A page outside the frame: the lockup as a link home, the account if signed in, and one column |
StatusBadge |
How a thing is going, as a shape and a word, the word in the ink: what failed and a budget over edged and marked in the rose red, what is retrying and a budget near in the gold, succeeded neutral; only waiting for you in the working colour (ADR-0039) |
Alert, Reading, Skeleton |
A note, a warning or an error in the page, with a shape beside its words and the announcement its tone calls for; what is being read as a status over placeholders in its shape |
ConfirmDialog |
A destructive action, asked first: what will be lost, the focus on its way out (Cancel, or words of its own such as "Keep running"), and a name to type where asked; its trigger shows its words alone in a row and is named in full aloud (ADR-0037) |
SegmentedFilter |
One filter of a few, such as a window, side by side, with its label and hint written above and under it when asked, and a name to submit with its form |
TenantSwitcher |
admin's tenant, or every tenant, and a menu of the others |
StatCard |
One figure, read on its own, or why there's none |
ItemTable |
Items as a table from 640 pixels, each row named by its first cell or a name of its own, and as headed cards below, one in the accessibility tree; an EmptyState with none |
TabNav, TabLink |
An area's parts as tabs of links, the current one marked with aria-current, on one row that scrolls on a phone |
CheckboxGroup |
Several choices, any of them checked, named by a legend |
SignIn, Account |
Signing in, and the account menu over a session, for both UIs |
BarChart, LineChart, DonutChart, Tracker |
Tremor's charts, adapted, on Recharts, over one chart theme (chart-theme.tsx): violet-led series as marks, patterns only where marks touch, a legend and a tooltip on the menus' surface, and the figures as a table; data-cvd on the root switches to a colour-blind palette with every pattern on (ADR-0027, ADR-0036) |
Num |
A figure with tabular digits only, so its decimal point keeps its width (ADR-0036) |
TraceView |
A trace's spans as an ARIA tree with a waterfall, virtualized, and the chosen span's details; what a span withheld is shown when asked for (ADR-0030) |
LogList, AnsiText |
Log records as a virtualized ARIA listbox with the chosen record's details; a line in the eight palette colours and bold, with every other escape stripped (ADR-0031) |
IntegrationList |
A level's integrations as a table, or cards on a phone: each a switch named by it and described by its state in words, a badge whose shape says on or off, where the state comes from, who set it and when, and a way back to what it inherits; a ceiling's switch disabled with its reason (ADR-0045) |
RunList, RuleRunStatus |
Rule runs as a table, or cards on a phone, with each row's actions, and a run's status as a word and a shape (ADR-0032) |
Tracker |
Tremor's tracker, adapted: a block per day, each in its level's mark, a pattern past the second, the levels in a legend, and the figures as a table (ADR-0028) |
Moment |
A time in the reader's zone, and how long ago on hover (ADR-0026) |
TrackerItems, safeHref |
Items in a task tracker after a label, such as "Filed as": each a link that opens it in a new tab, named with its tracker, a deleted one struck through and said so; only an http or https address is ever a link (ADR-0047) |
Dialog |
A dialog over the page, opened by its caller, with where focus starts and where it returns (ADR-0022) |
Menu |
A button and the actions it opens, each of which may be disabled while it's being done |
SelectField |
A labelled choice among several, Base UI's Select on the menus' surface with the keyboard of a native select, which submits with its form |
FormField |
A control's label, hint and refusal, and the 28rem its width is capped at, which TextField and SelectField share |
Icon, Mark, Lockup |
A Lucide icon beside its word; portalr's marks |
gallery.screenshot.test.tsx pins how they sit together, wide and narrow, in each scheme. It runs in CI alone, against Linux baselines.
Every area of a workspace renders in AppShell. The screens outside one are ui's Plain page: choosing a tenant or a workspace, with the account, and what went wrong, without it. Signing in is a card of its own. admin renders its frame, with its own mark.
The wheel¶
Vite builds app and admin into src/portalr/static/, which git ignores and the wheel carries. portalr's build depends on theirs, and ui-in-wheel, in check and the release workflow, fails unless this version's wheel holds both, each with its typefaces, and the typefaces' licences, and each UI's third-party notices (ADR-0033): the licences of the packages its bundle holds, written by its build, which fails on one the policy in web/notices/policy.json doesn't allow.