custom_typesModel: SPEC.class-and-type v0.4Stealth · noindexEvery 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.
Types
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.
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.
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.
- 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 siblingmax_custom_types(int, org) follows themax_emailspattern: unset falls through toPLAN_MAX_CUSTOM_TYPES[plan], and -1 means unlimited. - Planexists
Console → Plans → Features matrix: one cell per plan (
POST /api/admin/v1/plan-features), every toggle written toplan_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. - 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. - Resolvenew
custom_types_manage_for(org_id, org_role, resolved)besidegraph_enabled_for:feature_enabled("custom_types", org_id=…), deliberately withoutuser_idorworkspace_id, AND the role is owner or org admin. Superadmin passes, for dogfooding. On any error it returns False, so the buttons stay hidden. - Profile3 fields
GET /profilegainscustom_types_enabled(the workspace's plan includes it),can_manage_types(that, and you are an admin) andcustom_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_featuresonly carries keys a layer grants, so the entry is absent, andgating.tsdenyReasonForreads absence asunavailable, notplan. Today's pipe cannot say "upgrade" for a plan-gated feature. An org that was explicitly turned off does carrysource: org. - Appgate row
A
typesrow ingating.ts. With no role or flag, every member of the workspace sees the page.can_manage_typesdecides 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_toset means an upgrade (See plans → Team), an orgfalsemeans a conversation with Relay, and neither means no banner, only built-ins. Adding the page touches the usual six places (pages.test.tschecks them). - 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-checkcustom_types_manage_forand the ceiling, and refuse with403 {"error":"plan","feature":"custom_types"}, because a hidden button is not a closed endpoint.POST /types/{id}/archiveneeds only the admin role, so a frozen workspace can still tidy up. - 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. - 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. - 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).
Seeing and applying are never gated.
| Who | See types | Apply on send | Create · edit · restore | Archive | Activity |
|---|---|---|---|---|---|
| Member | Yes, read-only | Yes | Hidden | Hidden | Yes |
| Admin · plan includes it | Yes | Yes | Yes, up to the ceiling | Yes | Yes |
| Admin · plan doesn't | Built-ins | Built-ins | Upgrade banner | Nothing to archive | Yes |
| Admin · after a downgrade | Yes, frozen | Yes, unchanged | Frozen banner | Yes | Yes |
| An agent (MCP) | Yes, in context | By name | Not in v1 | No | No |
| Relay console | Every workspace | — | Plan cell, org flag | — | Every workspace |
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.
| Feature | free | pro | team | enterprise | beta |
|---|---|---|---|---|---|
custom_types | — | — | ✓ | ✓ | ✓ |
max_custom_types | 0 | 0 | 25 | unlimited | 25 |
Which plans, and the ceilings, are placeholders: Erik's call (open call 1).
| Workspace | Plan | Custom | By class | Most-changed option | Last change |
|---|---|---|---|---|---|
| Atlas Team | team | 4 / 25 | Relay 2 · Series 2 | Claims per relay | today · changed |
| Northwind Ops | enterprise | 11 | Relay 7 · Series 4 | Relays expire after | 2 Oct · created |
| Field Studio | pro frozen | 3 / 0 | Series 3 | The latest part only | 28 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.
What has to hold.
- 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.
- 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:
PATCHcarriesupdated_at. A stale save is refused with "changed a moment ago".
- 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.
- 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 403plan, 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.tsrow, and a hidden (not disabled) New for a member.
Five for Erik.
- Which plans, and what ceiling. The concept assumes Team and Enterprise, 25 and unlimited.
- Can managers manage?
ORG_MANAGEMENT_ROLESincludesmanager. The concept limits it to owner and org admin. - Members' way in. A nav item for everyone, or admins in the nav and members via "What's this type?" on the send sheet.
- Agents making types over MCP. v1 says no: types are made in the app and applied anywhere.
- 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.