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.
This page is the normative design for the app's permissions / access picker: one implementation with an explicit selectionMode (§I–§IV), and the containment algebra — parentScope / allowedScopes — in §VI. Both are part of the shipped component contract, live in single- and multi-select modes alike; the §VI implementation-status callout records what the picker carries today.
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.
AccessCombobox, whose contract carries selectionMode: "single" | "multiple"; selectionMode defaults to "single" so install / permissions callers stay unchanged.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.AvailableScopes); adapters may remain. A caller migrates by choosing a mode and passing that mode's shape — never by mounting a second component.installMode consumers of this single-mode picker. The extension detail modal is details-only — no footer, no install / update / restore action, and so no picker mounted there. Multiple drives the permissions grant form, the extension access-control panel, and the /connectors + /skills scope filters.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.
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.
| Scope | Token | Trigger label | Row label | Notes |
|---|---|---|---|---|
| Personal | owner | Personal: Only me | Personal: Only me | The narrowing floor. Always offered (§VI). Never “implied”. |
| Project | project:<id> | Project: <name> | Project: <name> | Never implied by an org or team selection. |
| Team | team:<id> | Team: <name> | Team: <name> | Implied (display-only) by its owning org / by Workspace: All. |
| Organization | org:<id> | Organization: <name> | Organization: <name> | Legacy bare org is read-accepted; it denotes the active org. |
| Workspace (all) | workspace | Workspace: All | Workspace: All | Broadest scope. Exclusive with Personal (multi mode). |
| Workspace (admins) | admin | Workspace: Admins only | Workspace: Admins only | Offered only when showAdmin (multi) / on the permissions tab (single). |
Personal: / Project: / Team: / Organization: / Workspace: — carrying the <Scope>: <name> row-label decomposition of §II, 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.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).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.
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.
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 select Workspace: All.”; Workspace: Admins only stays selectable.
role="combobox") rendering the current value plus a trailing chevron, at the shared form-control height (aligned to the text input). Trigger ≡ row, verbatim: the closed trigger's label is exactly the selected row's label — casing included — for every scope kind and every single-mode consumer; there is no separate bare-name or re-phrased trigger form for any kind, and no consumer-specific exception.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.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.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.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.installMode), a non-admin sees Workspace: All disabled with a lock and the reason “Only platform admins can select Workspace: All.”; Workspace: Admins only is always selectable.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.”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.
A checked Acme Corp implies its own team rows: checked, locked, “Included via Acme Corp”. Implied tokens are never written into the stored selection.
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.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.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.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.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./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.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.
| non-admin | single: Workspace: All locked + reason; multi: showAdmin=false removes Workspace: Admins only. |
| canGrantWorkspace=false | multi: Workspace: All renders disabled. |
| disabledScopes | any listed value: rendered, non-selectable, reason on tooltip. |
| installMode | owner / workspace / admin rows removed unless installWorkspaceScopes. |
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.
Only Acme’s teams + projects, plus Personal: Only me. The org itself and broader scopes (Workspace / Admins) are excluded.
A leaf parent (team:/project:), a Personal parent, and an unknown / stale parent all fail closed to Personal only — never a wrong broad set.
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.includedScopeKinds allow-list (§6.5): it means containment cannot remove Personal, not that it overrides a caller that excluded Personal.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.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).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.value and uncontrolled defaultValue are distinct props; scope identities are typed ({ kind, id }), not raw org:<id> strings threaded through the API.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.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.