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. 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.
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./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: <org> - <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 (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.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 scope this to the whole workspace.”; 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). 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.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 scope this to the whole workspace.”; 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.
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 requirement | Specified by |
|---|---|
AC6 — the access-picker design lives in a new specs/app-permissions.html | this 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 |
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.