Concept · v1 · 2026-10-10Plan feature custom_typesModel: SPEC.class-and-type v0.4Stealth · noindex

Every type in the workspace. Shaped where the plan allows.

A workspace Types screen: every type a relay, Stream or Series can carry, built-in and custom, with what each one does and how much it is used. Admins make and change custom types when the plan includes them, and everyone else sees the same list, read-only. The plan is one cell in the console's plan matrix, and it reaches this screen through the same pipe every other gate already uses.

Viewer
Plan
Theme
Text

Types

What relays, Streams and Series do in Atlas Team.
4 of 25 custom
On the send sheet: anyone applies a type (never gated)
Type: Client drop

The settings line is the type's own. Sending under a frozen plan still offers every custom type; an archived one is gone from this list.

In the list: Class, then Type, then Labels
Class
RelayStreamSeries
Type
Client drop×BootSession relayEvergreen
Labels
specbrief×decision

Type filters what a relay does; labels filter what it's about. A type that changes nothing is refused in the editor and pointed at labels.

The pipe

One cell in the console, down to one button here.

Most of this exists. Plan gates already run from the console's plan matrix through effective_features to a profile field that gating.ts reads. Custom types add a catalog row, one resolver, three profile fields, three endpoints and the audit verbs, and they close one gap the pipe has today (step 5). Steps marked new are the build; the rest is plumbing that already carries graph, files and translate.

  1. Catalognew row

    FEATURE_CATALOG["custom_types"], bool, levels plan and org only. Not workspace, not user: who may change a workspace's vocabulary is the workspace's policy, and a user-level override must never be able to grant it (the narrowest-writer-wins trap REFERENCE.feature-registry.md warns about). A sibling max_custom_types (int, org) follows the max_emails pattern: unset falls through to PLAN_MAX_CUSTOM_TYPES[plan], and -1 means unlimited.

  2. Planexists

    Console → Plans → Features matrix: one cell per plan (POST /api/admin/v1/plan-features), every toggle written to plan_features_audit. The new key appears in the matrix by itself, because the matrix reads the catalog. A contract-locked org keeps what it signed (contract_base_features), so a later matrix edit never takes custom types away from a signed contract.

  3. Org exceptionexists

    Console → Org → Flags (POST /api/admin/v1/orgs/{id}/flags), for a pilot on a plan without it or a contract exception. It goes into the same audit log. The org layer is console-only today, so no workspace can turn this on for itself.

  4. Resolvenew

    custom_types_manage_for(org_id, org_role, resolved) beside graph_enabled_for: feature_enabled("custom_types", org_id=…), deliberately without user_id or workspace_id, AND the role is owner or org admin. Superadmin passes, for dogfooding. On any error it returns False, so the buttons stay hidden.

  5. Profile3 fields

    GET /profile gains custom_types_enabled (the workspace's plan includes it), can_manage_types (that, and you are an admin) and custom_types_upgrade_to: the lowest plan whose matrix cell is on, or null. The third field is needed because a plan that leaves a feature out writes nothing. effective_features only carries keys a layer grants, so the entry is absent, and gating.ts denyReasonFor reads absence as unavailable, not plan. Today's pipe cannot say "upgrade" for a plan-gated feature. An org that was explicitly turned off does carry source: org.

  6. Appgate row

    A types row in gating.ts. With no role or flag, every member of the workspace sees the page. can_manage_types decides whether New, Edit and Restore render: hide, don't disable. When it is off for an admin, the banner comes from the reason: custom_types_upgrade_to set means an upgrade (See plans → Team), an org false means a conversation with Relay, and neither means no banner, only built-ins. Adding the page touches the usual six places (pages.test.ts checks them).

  7. Server3 endpoints

    GET /api/user/v1/types: built-ins and the workspace's custom types, with settings and usage counts. Ungated. POST / PATCH /types/{id} re-check custom_types_manage_for and the ceiling, and refuse with 403 {"error":"plan","feature":"custom_types"}, because a hidden button is not a closed endpoint. POST /types/{id}/archive needs only the admin role, so a frozen workspace can still tidy up.

  8. Applyexists

    Send, forward, claim and filter resolve a type from its stored row through kinds.resolve, the same PLAIN → type default → series override → sender chain that boot uses today. None of them reads the gate. A downgrade freezes the vocabulary; it never changes what a relay does.

  9. Recordaudit verbs

    Every write goes to log_audit("type.created" | "type.changed" | "type.archived" | "type.restored") with the before and after settings, plus a system row when the plan gate flips for the workspace. The Activity tab and the console both read that one log.

  10. Consolereport

    A Types report across workspaces: how many custom types, on which plans and classes, which settings people actually change, and the latest edits. Counts first, with one workspace's vocabulary readable on drill-in. This is how we see what's being built out (below).

Who sees what

Seeing and applying are never gated.

WhoSee typesApply on sendCreate · edit · restoreArchiveActivity
MemberYes, read-onlyYesHiddenHiddenYes
Admin · plan includes itYesYesYes, up to the ceilingYesYes
Admin · plan doesn'tBuilt-insBuilt-insUpgrade bannerNothing to archiveYes
Admin · after a downgradeYes, frozenYes, unchangedFrozen bannerYesYes
An agent (MCP)Yes, in contextBy nameNot in v1NoNo
Relay consoleEvery workspace—Plan cell, org flag—Every workspace
The console

What's being built out, across workspaces.

Two screens. The plan matrix already exists and gains a row by itself. The Types report is new: it answers "what are people making types for?", which is the signal for which options deserve to be built in next.

Plans → Features · custom_types
Featurefreeproteamenterprisebeta
custom_types——✓✓✓
max_custom_types0025unlimited25

Which plans, and the ceilings, are placeholders: Erik's call (open call 1).

Types report · last 30 days
WorkspacePlanCustomBy classMost-changed optionLast change
Atlas Teamteam4 / 25Relay 2 · Series 2Claims per relaytoday · changed
Northwind Opsenterprise11Relay 7 · Series 4Relays expire after2 Oct · created
Field Studiopro frozen3 / 0Series 3The latest part only28 Sep · plan

Illustrative rows. A workspace opens to its vocabulary, read-only, plus its Activity log. Relay contents are never shown here, only type names and settings.

Edge cases

What has to hold.

Plan changes
  • Downgrade freezes, never breaks. Every custom type keeps working on send and claim. Edit, New and Restore hide, but Archive stays.
  • Upgrade unfreezes. Nothing was deleted, so nothing comes back.
  • Over the ceiling after a plan change: what exists stays, and New hides until the count is under the ceiling.
  • Contract-locked orgs resolve from their snapshot. A matrix edit doesn't reach them.
  • A gate flip mid-edit returns 403 plan. The sheet keeps the draft and shows the banner.
The vocabulary
  • Reserved names (Boot, Session, System, Relay, Stream, Series) are refused, case-insensitively, as are archived names until they are restored.
  • A type that changes nothing can't be saved. It points to labels instead.
  • Editing applies forward. A sent relay keeps the expiry and claims it was sent with. A Series' claim and replace settings apply from its next claim or part.
  • Archive, never delete, while anything carries the type. Series under an archived type keep resolving its settings.
  • Two admins, one type: PATCH carries updated_at. A stale save is refused with "changed a moment ago".
Across the edge
  • A relay sent to another workspace shows its type name and what it does. The recipient can't apply that type in their own workspace.
  • An agent sending by type name that doesn't exist gets the workspace's list back, not a silent plain relay.
  • Personal workspaces follow their plan, and the owner is the admin.
  • A user-level override can't grant management: the catalog has no user level, and the resolver doesn't read it.
Tests that pin it
  • Resolver: the plan matrix, an org override, a contract pin and a user override (no effect) → can_manage_types.
  • Endpoints: a member gets 403 role, a plan without it gets 403 plan, a downgraded workspace can still archive, and the ceiling is enforced.
  • Apply paths never import the gate (a source scan, like test_both_expiry_writers_reopen).
  • Audit: every write leaves one row with the before and after settings.
  • App: the pages.test.ts row, and a hidden (not disabled) New for a member.
Open calls

Five for Erik.

  1. Which plans, and what ceiling. The concept assumes Team and Enterprise, 25 and unlimited.
  2. Can managers manage? ORG_MANAGEMENT_ROLES includes manager. The concept limits it to owner and org admin.
  3. Members' way in. A nav item for everyone, or admins in the nav and members via "What's this type?" on the send sheet.
  4. Agents making types over MCP. v1 says no: types are made in the app and applied anywhere.
  5. Stream types wait until Streams have settings of their own. Until then, Streams take labels.

Related: the model is relay-platform docs/SPEC.class-and-type.md (v0.4, in review). The option set and its copy are the series Kind editor that shipped in relay-app #807. The gate plumbing is relay-platform relay/features.py and relay-app web/src/gating.ts.