S
supero.docs
Documentation/Case Studies/Case Study: A Multi-Tenant Issue Tracker over MCP

Case Study: A Multi-Tenant Issue Tracker over MCP

Ten object types, authored from an AI editor over MCP, serving every customer from one deployment — and the permission error that only appeared when a real user tried to file a bug.

An issue tracker model — workspace, team, project, issue and cycle — authored over MCP
Ten schemas in the flowline namespace, authored by an AI editor driving the platform over MCP.

The Brief

Build an issue tracker — the shape people expect, not a to-do list: workspaces at the top, teams inside them, projects owned by teams, issues that move through a status, labels, comments, and time-boxed cycles.
Then the part that makes it a product rather than an internal tool: every customer gets their own isolated space, from one deployment, with no tenancy logic written by hand. And a constraint on the build — the developer did not want to describe the app to a generator and accept what came back. They wanted to author the model themselves, from the editor they were already in, which here means driving the platform over MCP.

ℹ️ The exemplar, and its caveat

This follows bundle_m73-flowline in ~/supero-test/e2e-tests/apps/bundle_m73-flowline, namespace flowline. Its schemas.py says outright that it was reconstructed from the live application’s shape, with fields observed through CRUD — an accurate record of the model, not the generator’s original output.

The Model

Ten schemas. Eight are ordinary object types with no base type — a tracker is mostly its own vocabulary — plus one enum and one type that reaches into a platform service.
python
ALL_SCHEMAS = [
  {"name": "issue_status", "schema_type": "enum",
   "values": ["backlog", "todo", "in_progress", "done"]},
  {"name": "Workspace", "namespace": "flowline", "attributes": [
      {"name": "name", "type": "string"},
      {"name": "slug", "type": "string"}]},
  {"name": "Issue", "namespace": "flowline", "attributes": [
      {"name": "title", "type": "string"},
      {"name": "status", "type": "issue_status"},
      {"name": "priority", "type": "string"},
      {"name": "estimate_points", "type": "integer"}]},
  {"name": "Membership", "namespace": "flowline",
   "extends": "membership:base_membership",
   "attributes": [{"name": "name", "type": "string"}]},
]
TypeFieldsWhy it exists
Workspacename, slugTop of the customer’s world; the slug is the URL.
Teamname, keyThe prefix in issue identifiers.
Membername, roleA person, distinct from the user account behind them.
Projectname, slug, statusReuses issue_status, so progress reads the same way.
Issuetitle, status, priority, estimate_pointsThe record the product exists to serve.
LabelnameDeliberately thin; labels earn their keep by being cheap.
Cyclename, start_date, end_dateThe time box.
IssueCommentbodyThe conversation on an issue.
issue_statusenum: backlog, todo, in_progress, doneOne vocabulary, declared once, reused twice.
Membershipname + membership:base_membershipThe only type extending a service.
That last row is the interesting one. membership:base_membership brings status, role, joined_at, suspended_at, expires_at, suspension_reason and a state machine that can suspend, expel and transfer — the difference between an issue tracker and one you can sell.

Building It over MCP

The AI editor connects to Supero as an MCP server and drives two tool families: schema_* to author the model, crud_* to put records through it. There is no separate authoring language.
  1. 1

    Declare the vocabulary first

    schema_save for issue_status. Issue.status and Project.status reference it by name, so it must exist before they can save.

  2. 2

    Author the container types

    Workspace, Team, Project — slugs and keys here, because they end up in URLs and are painful to change.

  3. 3

    Author the working types

    Issue, Label, Cycle, IssueComment, Member, all plain objects. A tracker earns nothing from extending a service here.

  4. 4

    Extend one service on purpose

    Membership with "extends": "membership:base_membership" — where the mechanics must be exactly right.

  5. 5

    Validate before you trust it

    schema_validate and schema_list_project, to confirm what landed rather than assume a successful call meant a correct schema.

  6. 6

    Put records through it

    crud_create, crud_search, crud_get, crud_update, crud_delete — as the roles that will really use them.

🚨 The trap that silently costs you the service

extends must live inside schema_content. At the wrapper level it is stripped without warning: the call succeeds, the schema appears in schema_list, and you have a plain object type with none of the membership lifecycle. Nothing fails — you discover much later that suspending a member does nothing. Re-read every extends save with schema_get.

Tenancy and Access

Nothing in the ten schemas mentions a customer or an account. That is the point: isolation is not modelled in the application, it is a property of where a record sits. The hierarchy is domain, project, tenant, record, and a record’s address is its immutable fq_name. One customer per tenant means one customer’s issues cannot be addressed from another’s, whatever the query says.
json
fq_name: ["m73-corp", "m73-flowline", "acme-tenant", "ISS-142"]

GET /api/v1/crud/{domain}/{type}?limit=50&cursor=...
Authorization: Bearer <jwt>        // OR  X-API-Key: ak_...

⚠️ There is no tenant header

No X-Domain, no X-Tenant, no X-Project. Tenancy comes from the authenticated principal and the path, never a header a client could set. The two auth headers are mutually exclusive: a bearer JWT or an API key. Pagination is cursor-based; page and page_size are silently ignored, which on a long issue list means the same first page forever.
Access is decided by a fixed role set. The eight you assign are domain_admin, domain_user, project_admin, project_user, developer, tenant_admin, tenant_user and viewer; platform_admin and platform_user are Supero-operator roles you will not hand out. There is no super_admin. For a tracker, tenant_admin and tenant_user are the two that matter daily, and the boundary between them is most of the security design. See Building Multi-Tenant Apps.

Notifying on Assignment

For the complete set, see Workflow Engine Reference.
A tracker that does not tell you an issue landed on your desk is one people stop opening. This is the piece of the build that is automation rather than modelling. The mechanism is an event binding: a CRUD event fires a workflow. The syntax is @<verb>:<namespace>:<schema>, with an input_map carrying record fields into the workflow. It is itself a record of type wf:workflow_definition; each run writes a workflow_instance, each step a workflow_step_log.
json
// The verified shape, from app_ctr/event_bindings.json
[{"event": "@create:trialhub:adverse_event",
  "workflow_id": "adverse_event_alert",
  "input_map": {"event_uuid": "uuid", "severity": "severity"}}]
For the tracker, the workflow is a crud_operation creating a notification record — a type extending notification:base_notification — then a service_call handing it to the email service. Step types are service_call, crud_operation, condition, parallel and transform.

ℹ️ Stated plainly

bundle_m73-flowline ships no event_bindings.json and no notification type. The binding above is quoted from bundles that do — app_ctr and app_m71-gym. This is the shape you would add, not something already in the tracker. Design the assignment event as a record creation you can bind to; @create is the verb these working examples use.

What It Took

What came free

  • •Tenancy. Ten schemas, zero lines of isolation logic, one deployment serving every customer.
  • •Seat lifecycle from one line — "extends": "membership:base_membership" on a type contributing one name field.
  • •The platform roles, and a CRUD surface with cursor pagination and filtering.

What had to be decided

  • •That issue_status is one enum shared by Issue and Project, rather than two that drift apart.
  • •That Member is a tracker record distinct from the user account behind it — the alternative looks simpler and hurts the moment someone leaves.
  • •That Label stays one field, and a Cycle two dates and a name, because rollover is a policy argument nobody wanted yet.

What would bite you

The recorded run is the useful part. Two operations went through as a real tenant_user. Creating a membership returned HTTP 201. Creating an issue returned HTTP 403:
text
HTTP 403 as [email protected]
{"error": "Forbidden: Your role does not have create access to flowline:issue"}

🚨 The model was right and the app was unusable

Every schema saved. The tracker looked finished. But the one action the product exists for — a team member filing an issue — was refused for the role every team member holds. A model carries no opinion about who may write to it, and reading schemas.py never reveals this. It surfaced only by putting a record through the API as the role that would really use it. Do that on day one, for the operations that define your product.
The second lesson is the same in kind: this bundle’s header admits it is a reconstruction. Treat the running application as the source of truth about your model. For the complete set, see Workflow Engine Reference.