Orkestia
Blog
App Data

Ownership & workspaces

Owner rows, app-owned catalogs, and organization workspaces with capability-gated CRUD

App Data isolation is an ownership mode on each table, not a filter you remember to add. The mode is declared with the table (Declare) and enforced on every record op.

The three modes

KindRow scopeTypical use
ownerThe signed-in end-user"My orders", "my drafts"
appThe identity appShared catalogs, org-seeded reference data
organizationThe user's active workspaceMulti-tenant product data (a farm, a clinic, a shop)

Owner

Every create/read/query/update/delete is filtered to the bound end-user. An org-member token cannot write owner-scoped rows (end_user principal not bound). That is intentional: members seed app-owned tables instead.

App

Shared rows for the app. Reads may drop the owner filter; writes that are not end-user-bound go through appdata.publish-as-app (retract with appdata.retract-as-app). Use this for data every user of the app should see, not for one user's records.

Organization (workspaces)

A workspace is a tenant inside your app — not the Orkestia organization your team logs into. Examples: a farm, a company, an accounting office.

  • Create / list workspaces: identity.app-organization.create / .query
  • Add a member: identity.app-organization.add-member — the first active membership auto-sets that user's active workspace
  • The active workspace is injected as metadata["end_user"]["app_organization_uuid"]. The client does not supply it.
  • CRUD is gated by the member's workspace role (capability groups): {namespace}:{verb} such as property:read or inventory:*
identity.end-user.organization.query   → workspaces I belong to
identity.end-user.organization.get     → one workspace
# switching active workspace is an identity workflow — discover the live name

Default role slugs from identity.end-user.group.seed-defaults: owner, admin, member, support (owner gets *). Define more with identity.end-user.group.define.

Membership: the axis ownership cannot express

The three modes above all answer "whose row is this". Some tables need a different question — "am I a participant in this thing" — and the answer lives in another table: the people on a conversation, the members of a case, the watchers of a document. Owner scoping cannot reach it, and widening the table to organization would show every member of the workspace every conversation in it.

Declare it alongside ownership rather than instead of it:

      - slug: conversation_messages
        ownership:
          kind: owner
        membership:
          via: conversation_participants
          match: [conversation_id]

The two AND together. A row is reachable when the tenant predicate allows it and the caller holds a live row in the membership table.

It compiles to a RESTRICTIVE policy scoped to the app's end-user role. Permissive policies OR, so they can only widen what an end-user reaches; membership has to narrow. The membership table cannot itself be membership-scoped, which is what makes the check terminate.

See Ordered append for the full declaration and the streams it was built for.

Capability strings

If you set capability_namespace: property on the table, verbs become property:create, property:read, property:update, property:delete. Read also covers query. If you omit the namespace, the default is data:<table_slug>:<verb>.

A denial fails the workflow. It is not an empty result you might mistake for "no rows."

What you never do

  • Pass another user's UUID "because the admin screen needs it" — use app-owned or organization-owned tables and roles instead.
  • Trust a workspace id from the client. The server resolves the active one.
  • Put tenant data on an app-owned table and filter in the frontend.

Identity & multi-tenancy · App Enablement · Expose · Instances