Cinatra — Application Design — Permissions

The one access picker that decides who can see and use a thing in the Cinatra app — the consolidated AccessCombobox in its single and multiple selection modes, the closed scope vocabulary, every state, and the containment algebra that narrows the offered scopes to within a parent. Part of Cinatra Application Design; match it exactly.

Version 0.1.1

What this page rules on

This page is the normative design for the app's permissions / access picker. The consolidation onto one implementation with an explicit selectionMode (§I–§IV) documents shipped reality (cinatra#1607 AC1, live in both modes), and the containment section (§VI) — the parentScope / allowedScopes algebra — is likewise shipped (cinatra#1607 AC2–AC5; see the §VI implementation-status callout). §VII maps every cinatra#1607 AC6/AC8 requirement to the clause here that specifies it.

I. One picker, two selection modes

AccessCombobox · selectionMode

One implementation, one import, one contract. Every access decision in the app is made through a single component, AccessCombobox, parameterized by selectionMode: "single" | "multiple" (default "single"). It replaces the two former parallel pickers. The two modes render different UIs — a single-select combobox versus a checkbox multi-select list — but share one component contract, one import, and one pure label/selection module (access-scope.ts), which is the single source of truth for every scope label, scope prefix, and selection summary.

Per-mode data shapes; one picker. Single mode takes the flat active-org shape { orgName, orgId, teams[], projects[], workspaceExposed }; multiple mode takes the nested AvailableScopes { orgs[].teams[], projects[], canGrantWorkspace }. A single universal upstream data model is not required — compatibility adapters may remain — but there is exactly one picker implementation.

  1. 1.1 There is exactly one access-picker implementation, AccessCombobox, whose contract carries selectionMode: "single" | "multiple"; selectionMode defaults to "single" so install / permissions callers stay unchanged.
  2. 1.2 The two modes render distinct treatments — a single-select combobox with a trailing check, and a checkbox multi-select list — but share one component contract, one import, and one pure module (access-scope.ts): that module owns the multi-mode labels + selection summary and the shared “Unknown …” entity fallback that both modes delegate to (the single mode’s own label helper defers to it), so the unknown-entity contract has no drifting second copy.
  3. 1.3 Data shapes are per-mode (single: flat active-org; multiple: nested AvailableScopes); adapters may remain. A caller migrates by choosing a mode and passing that mode's shape — never by mounting a second component.
  4. 1.4 Single drives the extension / agent install-scope dialogs, the agent-decision actions, and the read-only project Permissions tab. Multiple drives the permissions grant form, the extension access-control panel, and the /connectors + /skills scope filters.
Where the extension surface reuses this

The extension Settings → Permissions section that mounts this picker for connectors and artifacts (and links agents / skills to their own pages) is specified alongside the extension lifecycle in Application Design — Extensions; this page is the normative design for the picker component that section reuses.

II. The scope vocabulary

access-scope.ts · closed token set

A closed set of scope tokens, each with one canonical label. Every offered option is one of six scope kinds. The trigger shows <Type>: <name>; the dropdown rows reuse the same decomposition so the Personal / Project / Team / Organization / Workspace prefixes stay consistent. Scopes order narrow → broad everywhere they render.

ScopeTokenTrigger labelRow labelNotes
PersonalownerPersonal: Only mePersonal: Only meThe narrowing floor. Always offered (§VI). Never “implied”.
Projectproject:<id>Project: <name>Project: <name>Never implied by an org or team selection.
Teamteam:<id>Team: <org> - <name>Team: <name>Implied (display-only) by its owning org / by Workspace: All.
Organizationorg:<id>Organization: <name>Organization: <name>Legacy bare org is read-accepted; it denotes the active org.
Workspace (all)workspaceWorkspace: AllWorkspace: AllBroadest scope. Exclusive with Personal (multi mode).
Workspace (admins)adminWorkspace: Admins onlyWorkspace: Admins onlyOffered only when showAdmin (multi) / on the permissions tab (single).
  1. 2.1 The offered scopes are exactly the six kinds above; no other token renders as a selectable option.
  2. 2.2 Scopes order narrow → broad: Personal → Project → Team → Organization → Workspace (all) → Workspace (admins). Both modes and the trigger summary honour this order.
  3. 2.3 The list carries no scope group headings. Every row is prefixed with its scope kind — Personal: / Project: / Team: / Organization: / Workspace: — carrying the <Scope>: <name> row-label decomposition of §II (independent of the single-mode trigger, which is bare for some kinds per §3.1), and consecutive scope groups are divided by a hairline separator (never a line between rows within a group). The two Workspace rows (All, Admins only) share the one Workspace group.
  4. 2.4 A team / project id that resolves to no name renders the explicit fallback “Unknown team” / “Unknown project”never a truncated id. (An unresolved org takes the neutral organization label of §2.5, not a truncated id.) The raw id may appear only in a tooltip or secondary line, never in the label.
  5. 2.5 An org:<id> token resolves to the active org’s name only when its id equals the supplied active orgId; otherwise it resolves to the neutral “Anyone in the organization” — never the wrong org’s name and never the raw token (the cross-org read-only Permissions-tab case).

III. Single-select mode — the flat combobox

selectionMode="single" · install + permissions

One scope, chosen from a scope-prefixed list. The single-select trigger is an outline combobox showing the current selection as <Type>: <name> with a trailing chevron, at the shared form-control height. Opening it lists the scopes as a flat, scope-prefixed list — each row prefixed Personal: / Project: / Team: / Organization: / Workspace:, with a hairline dividing consecutive scope groups and no group heading; a trailing check marks the selected row; choosing a row closes the popover. There is no text filter in single mode.

Example — permissions tab, admin viewer
Anyone in Acme Corp
Personal:Only me
Project:Atlas
Team:Revenue
Team:Engineering
Organization:Acme Corp
Workspace:All
Workspace:Admins only
Example — install-scope dialog (installMode)
Anyone in Acme Corp
Project:Atlas
Team:Revenue
Team:Engineering
Organization:Acme Corp

No Personal: Only me, Workspace: All or Workspace: Admins only — those are not install targets. With installWorkspaceScopes the two workspace rows return as server-driven target rows.

Example — disabled rows with reason (permissions tab, non-admin viewer)
Team:Revenue
Team:EngineeringNot a member
Workspace:All
Workspace:Admins only

Disabled ≠ removed. A value in disabledScopes still renders — non-selectable, muted, with its disabledReasons text on a wrapper-span tooltip outside the disabled item (a disabled item suppresses pointer events on its own content). For a non-admin, Workspace: All is locked with the reason “Only platform admins can scope this to the whole workspace.”; Workspace: Admins only stays selectable.

  1. 3.1 The trigger is an outline combobox (role="combobox") rendering the current value plus a trailing chevron, at the shared form-control height (aligned to the text input). In single mode only Team and Project selections carry the uppercase <Type>: prefix; Personal, Organization, Workspace and Admin resolve to a bare name with no prefix.
  2. 3.2 The open list renders the scopes as prefixed rows in five groups, narrow → broad, a hairline between groups and no heading: (1) Personal: · (2) Project: · (3) Team: · (4) Organization: <name> (always rendered) · (5) Workspace: (the All then Admins only rows share the one Workspace group). Project and Team rows render when they have rows or an unhydrated selection to synthesize.
  3. 3.3 Selection is single: a trailing check marks the selected row, and choosing any row closes the popover. Single mode does not filter rows by typed text.
  4. 3.4 A team: / project: selection that does not hydrate to any available row synthesizes an explicit, checked “Unknown team” / “Unknown project” row, so a selection is always visible with its check.
  5. 3.5 Row states are visually distinct: idle rows are white; the hover / keyboard-active row is muted; the selected row keeps the muted tint.
  6. 3.6 A value in disabledScopes renders the row non-selectable with its disabledReasons text (else “Not available”) on a wrapper-span tooltip outside the disabled item. Outside installWorkspaceScopes, only the org / team: / project: rows consult this list — the owner / admin / workspace rows have separate semantics (workspace gates on the admin check); under installWorkspaceScopes (§3.8) the workspace and admin rows also become server-driven and consult it.
  7. 3.7 installMode hides the Personal: Only me, Workspace: All and Workspace: Admins only rows entirely (they are permissions values, not install targets), leaving org / team: / project: rows.
  8. 3.8 installWorkspaceScopes (with installMode) additionally renders the Workspace: All and Workspace: Admins only rows as server-driven target rows whose enabled / disabled + reason state comes from disabledScopes / disabledReasons, not the client admin gate.
  9. 3.9 On the permissions tab (not installMode), a non-admin sees Workspace: All disabled with a lock and the reason “Only platform admins can scope this to the whole workspace.”; Workspace: Admins only is always selectable.
  10. 3.10 The org row value is the id-carrying org:<id> token (matching the server-built install-target row exactly); it falls back to the legacy bare org only when no orgId is supplied. The empty state reads “No matches.”

IV. Multi-select mode — the checkbox picker

selectionMode="multiple" · grant + filters

Many scopes, chosen with checkboxes. The multi-select trigger shows a composed summary of the selection; opening it reveals a searchable, scope-prefixed checkbox list — divided by a hairline between scope groups, no group heading — that stays open as you toggle. Each row leads with a checkbox — there is no trailing check. A checked broad scope display-implies its narrower rows (checked + locked) without writing them into the stored selection.

Example — permissions grant form
1 project, 1 team
Personal:Only me
Project:Atlas
Team:Revenue
Team:Engineering
Organization:Acme Corp
Workspace:All
Workspace:Admins only
Example — downward implication (display-only)
1 organization
Team:RevenueIncluded via Acme Corp
Team:EngineeringIncluded via Acme Corp
Organization:Acme Corp
Workspace:All

A checked Acme Corp implies its own team rows: checked, locked, “Included via Acme Corp”. Implied tokens are never written into the stored selection.

  1. 4.1 The trigger renders the selection summary: one token shows its full Type: Name; N>1 tokens show a pluralised, narrow → broad category breakdown (e.g. “1 project, 1 team”, “2 teams, 1 organization”); an empty selection falls back to the Personal label. When N>1, a tooltip on the trigger lists every selected scope’s full label.
  2. 4.2 A Search field filters the rows client-side; a small ✕ clears the query once present.
  3. 4.3 Each row leads with a checkbox and its scope-prefixed label (Personal: / Project: / Team: / Organization: / Workspace:); there is no trailing check and no group heading. Consecutive scope groups are divided by a hairline separator (never a line between rows within a group), on a white surface.
  4. 4.4 The popover stays open on toggle (Escape or an outside click closes it).
  5. 4.5 Downward implication is display-only. A checked org implies its own team rows (checked + disabled, “Included via <org>”); a checked Workspace: All implies every scope row (“Included via Workspace: All”); projects are never implied by org/team. Implied tokens are never written to the stored selection.
  6. 4.6 owner (Only me) and workspace (All) are exclusive: toggling either collapses the selection to just that token; unchecking workspace falls back to owner. owner is the narrowing floor — disabled while workspace is selected and when it is the sole selection (the floor cannot be unchecked); it becomes an enabled clear-to-owner action once a broader scope is also selected.
  7. 4.7 Scoped tokens (org / team / project) and admin add / remove through the canonicaliser (dedupe, owner-strip-when-mixed, non-empty floor, no upward collapse).
  8. 4.8 A value in disabledScopes renders non-selectable (a lock affordance) with its disabledReasons tooltip — used e.g. to render a connector’s access ceiling as a locked picker. The lock is an affordance only; the server write path independently rejects an out-of-ceiling grant.
  9. 4.9 showAdmin (default true) gates the Workspace: Admins only row; a non-admin filter surface passes false so admin-only scopes are never selectable. When canGrantWorkspace is false, the Workspace: All row renders disabled.
  10. 4.10 The /connectors + /skills filter surface mounts this mode via ScopeFilterCombobox with the filter-mode toggleSelection / rowState: “personal” is an ordinary OR-token, nothing is implied, and “workspace” is the cleared default (removed from the ?scope= URL). The popover stays open with an optimistic local selection reconciled to the server-resolved value.

V. State axes — the finite render matrix

empty · loading · disabled · gated · filtered

A closed set of states, per mode. The picker’s conformance is checked against this finite matrix: an empty offering, a loading offering, a disabled-with-reason row, a permission-gated row, and a containment-narrowed offering (§VI). Every cell renders per this spec, and nothing renders that this spec does not specify.

Example — empty offering
No matches.
Example — permission-gated rows
non-adminsingle: Workspace: All locked + reason; multi: showAdmin=false removes Workspace: Admins only.
canGrantWorkspace=falsemulti: Workspace: All renders disabled.
disabledScopesany listed value: rendered, non-selectable, reason on tooltip.
installModeowner / workspace / admin rows removed unless installWorkspaceScopes.

VI. Containment — parentScope & allowedScopes

strict descendants + Personal always

Narrow the offered scopes to within a parent. An optional parentScope (distinct from the selected value) restricts the offered options to scopes within the parent — strict descendants, plus Personal always. A lower-level allowedScopes constraint covers arbitrary policies that do not reduce to a single parent identity. Both are display affordances: the server is the authority and independently rejects a forged or stale selection.

Example — parentScope = org:Acme (org parent)
Personal:Only me
Project:Atlas
Team:Revenue
Team:Engineering

Only Acme’s teams + projects, plus Personal: Only me. The org itself and broader scopes (Workspace / Admins) are excluded.

Example — leaf / unknown parent → Personal only
Personal:Only me

A leaf parent (team:/project:), a Personal parent, and an unknown / stale parent all fail closed to Personal only — never a wrong broad set.

  1. 6.1 parentScope restricts the offered options to strict descendants of the parent, plus Personal. parentScope = org:<id> offers only that org’s teams + projects (the org itself and broader Workspace / Admins scopes are excluded). A leaf parent (team: / project:) offers Personal only. A Personal parent offers Personal only. No parent (default) offers all options.
  2. 6.2 Containment never drops Personal: under any parent, Personal is still offered — parent narrowing never removes it. This holds within the caller’s includedScopeKinds allow-list (§6.5): it means containment cannot remove Personal, not that it overrides a caller that excluded Personal.
  3. 6.3 An unknown / stale parent fails closed to Personal-only — never a wrong broad set.
  4. 6.4 allowedScopes is a lower-level containment constraint (a set / predicate) for arbitrary policies that do not reduce to one parent identity. parentScope is sugar for the common single-parent case; allowedScopes covers the general case; both may be honoured together (intersection). The agent-run form’s three-field visibility intersection maps onto allowedScopes, not onto a single parentScope.
  5. 6.5 Precedence. includedScopeKinds is the caller’s explicit allow-list — a scope kind absent from it never renders. Within that set, parentScope / allowedScopes containment filters the org / team / project / workspace options but never removes Personal (containment never drops Personal, provided the caller included it); it is not an override of an explicit caller exclusion. A rendered Personal may be disabled (disabled ≠ removed).
  6. 6.6 Reconciliation. When parentScope / allowedScopes changes and a current selection falls out of scope, the picker clears the now-invalid selection(s) and surfaces the invalidation inline — it never silently retains an invisible, out-of-scope value. Single-select resets to empty (forcing a re-pick) or to the in-scope default when one is given; multi-select drops only the out-of-scope entries and notes it.
  7. 6.7 Controlled value and uncontrolled defaultValue are distinct props; scope identities are typed ({ kind, id }), not raw org:<id> strings threaded through the API.
  8. 6.8 Server authority is the boundary. parentScope and the scope-kind params are display input only and are never accepted as proof of containment: the server derives the authoritative parent relationship from stored entities and independently rejects a forged or stale scope selection.
Implementation status — shipped (cinatra#1607 AC2–AC5)

The first-class parentScope parameter, the allowedScopes constraint, the §6.5 precedence, the §6.6 reconciliation, and the §6.7 typed identities are shipped component contract: the picker carries them as props, and the agent-run permissions form mounts the general allowedScopes case (its three-field visibility intersection — §6.4) through the first-class prop. §6.8 (server authority) is enforced independent of the picker and holds request-level negative coverage: a scope outside the parent’s containment set is rejected server-side.

VII. Conformance mapping & spec status

cinatra#1607 AC6/AC8 · publish decision

Every cinatra#1607 AC6/AC8 requirement maps to a clause here. This page is the conformance target the picker-consolidation issue pins against. The mapping below carries no unmapped requirement.

cinatra#1607 requirementSpecified by
AC6 — the access-picker design lives in a new specs/app-permissions.htmlthis page — §I–§VI; publish decision in §VII below
AC8 — each included scope-kind row renders per spec (scope-prefixed entries, groups divided by separators, no heading)§II · #c-2-1 · #c-2-3 · #c-3-2 · #c-4-3
AC8 — single vs multi-select§III · §IV · #c-1-1
AC8 — disabled + reason rows#c-3-6 · #c-4-8 · #c-3-9
AC8 — empty states#c-3-10 · §V (access-picker-empty)
AC8 — parentScope-filtered render (org parent → only its teams/projects + Personal) & no-parent (all options)#c-6-1 · #c-6-2
AC8 — nothing renders that the spec does not specify (bidirectional)§II · §V · #c-2-1
AC1 — one implementation + selectionMode (context AC8 renders against)#c-1-1 · #c-1-2
AC2 — parentScope containment algebra (strict descendants + Personal)#c-6-1#c-6-4
AC3 — prop precedence + selection reconciliation + typed identities#c-6-5 · #c-6-6 · #c-6-7
AC4 — the demonstrated generic params (selectionMode, included scope-kinds, disabled + reason, defaults)#c-3-7 · #c-4-9 · #c-6-5
AC5 — server authority; parentScope is display input only#c-6-8
Spec status — published (cinatra#1607 AC6)

This page is published as part of Cinatra Application Design, per the owner’s cinatra#1607 AC6 ruling. It is the normative conformance target for the app’s permissions / access picker: a generated conformance manifest is published alongside the other Application Design conformance artifacts, the published Extensions page cross-references this page for the picker it reuses, and conformance consumers pin the exact manifest bytes they verified against.