@cat-factory/app 0.225.0 → 0.227.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,22 +22,18 @@ type Entry =
22
22
  // clock-fallback `occurredAt`).
23
23
  const entries = computed<Entry[]>(() =>
24
24
  [
25
- ...props.failures.map(
26
- (failure, i): Entry => ({
27
- kind: 'failure',
28
- key: `failure-${i}`,
29
- occurredAt: failure.occurredAt,
30
- failure,
31
- }),
32
- ),
33
- ...props.outputs.map(
34
- (output, i): Entry => ({
35
- kind: 'success',
36
- key: `success-${i}`,
37
- occurredAt: output.occurredAt,
38
- output,
39
- }),
40
- ),
25
+ ...props.failures.map((failure, i): Entry => ({
26
+ kind: 'failure',
27
+ key: `failure-${i}`,
28
+ occurredAt: failure.occurredAt,
29
+ failure,
30
+ })),
31
+ ...props.outputs.map((output, i): Entry => ({
32
+ kind: 'success',
33
+ key: `success-${i}`,
34
+ occurredAt: output.occurredAt,
35
+ output,
36
+ })),
41
37
  ].sort((a, b) => b.occurredAt - a.occurredAt),
42
38
  )
43
39
  </script>
@@ -71,6 +71,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
71
71
  class_auto_merge: 'panels.mergerResult.reason.class_auto_merge',
72
72
  class_requires_review: 'panels.mergerResult.reason.class_requires_review',
73
73
  role_requires_review: 'panels.mergerResult.reason.role_requires_review',
74
+ submission_not_allowed: 'panels.mergerResult.reason.submission_not_allowed',
74
75
  dry_run: 'panels.mergerResult.reason.dry_run',
75
76
  }
76
77
  const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
@@ -152,6 +153,12 @@ const reasonText = computed(() => {
152
153
  return t(REASON_KEYS[d.reason], {
153
154
  preset: d.thresholds.presetName,
154
155
  axes: axisLabels,
156
+ // The classes the initiator's role MAY land, so the refusal names what the remedy is
157
+ // measured against. Empty is a real policy (that role lands nothing) and reads as such
158
+ // through its own translated phrase rather than as a blank in the middle of a sentence.
159
+ classes: d.thresholds.submissionClasses?.length
160
+ ? d.thresholds.submissionClasses.map((c) => t(CLASS_KEYS[c])).join(', ')
161
+ : t('panels.mergerResult.noSubmittableClasses'),
155
162
  // Never blank: a role-scoped reason is only ever produced for a run that pinned one, and the
156
163
  // fallback keeps the sentence readable rather than leaving a hole if that ever changes.
157
164
  role: d.thresholds.initiatorRole
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import type { RiskPolicy } from '~/types/merge'
3
- import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
3
+ import {
4
+ refusedRiskPolicySelections,
5
+ resolveRiskPolicyPicker,
6
+ } from '~/components/riskPolicy/RiskPolicyPicker.logic'
4
7
 
5
8
  // The resolver reads ids only, so the fixture carries just enough to be identifiable.
6
9
  const policy = (id: string, name: string) => ({ id, name }) as unknown as RiskPolicy
@@ -73,3 +76,60 @@ describe('resolveRiskPolicyPicker', () => {
73
76
  expect(state).toEqual({ policy: null, viaWorkspaceDefault: false })
74
77
  })
75
78
  })
79
+
80
+ describe('refusedRiskPolicySelections', () => {
81
+ const rolePolicy = (id: string, over: Partial<RiskPolicy>) =>
82
+ ({ id, name: id, classRules: {}, classRulesByRole: {}, dryRunRoles: [], ...over }) as RiskPolicy
83
+
84
+ const sandboxed = rolePolicy('mp_sandboxed', { dryRunRoles: ['member'] })
85
+ const open = rolePolicy('mp_open', {})
86
+ const member = { role: 'member' as const, managesPolicy: false }
87
+
88
+ it('marks the rows a member may not move a sandboxed task to', () => {
89
+ // The picker must not offer what the backend refuses, including the "workspace default" row,
90
+ // which is a real policy (here: the open one) and not the absence of a choice.
91
+ const refused = refusedRiskPolicySelections({
92
+ options: [sandboxed, open],
93
+ defaultPolicy: open,
94
+ modelValue: 'mp_sandboxed',
95
+ actor: member,
96
+ })
97
+ expect(refused.get('mp_open')).toBe('relaxes_role_sandbox')
98
+ expect(refused.get('')).toBe('relaxes_role_sandbox')
99
+ expect(refused.has('mp_sandboxed')).toBe(false) // the row already selected
100
+ })
101
+
102
+ it('judges a task that picked nothing against the workspace default, as the engine does', () => {
103
+ // The create-form shape: nothing selected yet, so the policy being moved AWAY from is the one
104
+ // the task would have been governed by.
105
+ const refused = refusedRiskPolicySelections({
106
+ options: [sandboxed, open],
107
+ defaultPolicy: sandboxed,
108
+ modelValue: '',
109
+ actor: member,
110
+ })
111
+ expect(refused.get('mp_open')).toBe('relaxes_role_sandbox')
112
+ expect(refused.has('mp_sandboxed')).toBe(false)
113
+ })
114
+
115
+ it('refuses nothing to an editor who manages the policy library', () => {
116
+ const refused = refusedRiskPolicySelections({
117
+ options: [sandboxed, open],
118
+ defaultPolicy: open,
119
+ modelValue: 'mp_sandboxed',
120
+ actor: { role: 'admin', managesPolicy: true },
121
+ })
122
+ expect(refused.size).toBe(0)
123
+ })
124
+
125
+ it('refuses nothing between policies that treat every initiator alike', () => {
126
+ // Every built-in ships with an empty role layer, so the common case offers the whole library.
127
+ const refused = refusedRiskPolicySelections({
128
+ options: [open, rolePolicy('mp_other', {})],
129
+ defaultPolicy: open,
130
+ modelValue: 'mp_open',
131
+ actor: member,
132
+ })
133
+ expect(refused.size).toBe(0)
134
+ })
135
+ })
@@ -6,6 +6,11 @@
6
6
  // riskPolicies store's `resolve()` falls back to the workspace default for both an empty id
7
7
  // and a dangling one; this mirrors that, so the picker can never tell the user "no risk policy
8
8
  // configured" about a task the default is quietly governing.
9
+ import {
10
+ refuseRiskPolicySelection,
11
+ type BlockEditActor,
12
+ type RiskPolicySelectionRefusal,
13
+ } from '@cat-factory/contracts'
9
14
  import type { RiskPolicy } from '~/types/merge'
10
15
 
11
16
  export interface RiskPolicyPickerState {
@@ -45,3 +50,38 @@ export function resolveRiskPolicyPicker(input: RiskPolicyPickerInput): RiskPolic
45
50
  if (named) return { policy: named, viaWorkspaceDefault: false }
46
51
  return { policy: input.defaultPolicy, viaWorkspaceDefault: !!input.defaultPolicy }
47
52
  }
53
+
54
+ /**
55
+ * Which of the offered policies this user may not move THIS task to, keyed by option id (`''`
56
+ * for the "workspace default" row), with the reason.
57
+ *
58
+ * A task's policy decides whether its runs are sandboxed for the initiator's role and how their
59
+ * auto-merge is narrowed, so the backend refuses a selection that would relax what the selector's
60
+ * own role is held to (ADR 0037). The picker applies the SAME contracts rule rather than offering
61
+ * a row and handing back a 403: an authoring surface that offers what the engine discards is
62
+ * telling someone they made a choice they did not make.
63
+ *
64
+ * The policy being moved AWAY from is resolved exactly as the engine resolves it: the named
65
+ * option, else the workspace default. That is what makes the same call correct on the create form
66
+ * (nothing picked yet, so the default is what would have governed the task) and in the inspector.
67
+ */
68
+ export function refusedRiskPolicySelections(input: {
69
+ options: readonly RiskPolicy[]
70
+ defaultPolicy: RiskPolicy | null
71
+ modelValue: string
72
+ actor: BlockEditActor
73
+ }): Map<string, RiskPolicySelectionRefusal> {
74
+ const refusals = new Map<string, RiskPolicySelectionRefusal>()
75
+ const from =
76
+ (input.modelValue ? input.options.find((p) => p.id === input.modelValue) : undefined) ??
77
+ input.defaultPolicy
78
+ if (!from) return refusals
79
+ const judge = (id: string, to: RiskPolicy | null) => {
80
+ if (!to) return
81
+ const refusal = refuseRiskPolicySelection({ from, to, actor: input.actor })
82
+ if (refusal) refusals.set(id, refusal)
83
+ }
84
+ judge('', input.defaultPolicy)
85
+ for (const policy of input.options) judge(policy.id, policy)
86
+ return refusals
87
+ }
@@ -14,7 +14,10 @@
14
14
  import { computed, ref } from 'vue'
15
15
  import type { RiskPolicy } from '~/types/merge'
16
16
  import RiskPolicyPreview from '~/components/riskPolicy/RiskPolicyPreview.vue'
17
- import { resolveRiskPolicyPicker } from '~/components/riskPolicy/RiskPolicyPicker.logic'
17
+ import {
18
+ refusedRiskPolicySelections,
19
+ resolveRiskPolicyPicker,
20
+ } from '~/components/riskPolicy/RiskPolicyPicker.logic'
18
21
 
19
22
  const props = withDefaults(
20
23
  defineProps<{
@@ -37,6 +40,7 @@ const props = withDefaults(
37
40
 
38
41
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
39
42
  const { t } = useI18n()
43
+ const access = useWorkspaceAccess()
40
44
 
41
45
  const open = ref(false)
42
46
  // The row the pointer or the keyboard is currently on, driving the right-column preview.
@@ -56,6 +60,39 @@ const preview = computed(() =>
56
60
  }),
57
61
  )
58
62
 
63
+ /**
64
+ * The policies this user may not move THIS task to, and why (ADR 0037): a selection may not
65
+ * relax what the selector's own role is held to, and the backend refuses one that does. Offering
66
+ * the row anyway would be telling someone they made a choice the engine then discards.
67
+ *
68
+ * Empty on every workspace whose policies treat each initiator alike (which is every built-in),
69
+ * and always empty for someone holding `settings.manage`, who owns the library.
70
+ */
71
+ const refused = computed(() =>
72
+ refusedRiskPolicySelections({
73
+ options: props.options,
74
+ defaultPolicy: props.defaultPolicy,
75
+ modelValue: props.modelValue,
76
+ actor: { role: access.role.value, managesPolicy: access.canManageSettings.value },
77
+ }),
78
+ )
79
+
80
+ /** The reason the previewed row is refused, if it is: the pane explains what the row cannot. */
81
+ const previewRefusal = computed(() => refused.value.get(activeId.value ?? props.modelValue))
82
+
83
+ /**
84
+ * The same reason as a tooltip on the row itself, `undefined` when the row is selectable.
85
+ *
86
+ * A refused row is `aria-disabled`, NOT `disabled`: a disabled button is unfocusable, so keyboard
87
+ * users could never land on it, and landing on it is precisely what puts the reason in the detail
88
+ * pane (the `@focus` handler below drives the preview). Refusing the click is `choose`'s job, so
89
+ * the row stays reachable by both routes and explains itself on both.
90
+ */
91
+ function refusalText(id: string): string | undefined {
92
+ const refusal = refused.value.get(id)
93
+ return refusal ? t(`riskPolicy.picker.refused.${refusal}`) : undefined
94
+ }
95
+
59
96
  /**
60
97
  * Tabbing BETWEEN two rows fires `focusout` on the one being left, so only focus leaving the
61
98
  * panel altogether may drop the preview back to the selection.
@@ -68,6 +105,7 @@ function onPanelFocusOut(event: FocusEvent) {
68
105
  }
69
106
 
70
107
  function choose(id: string) {
108
+ if (refused.value.has(id)) return
71
109
  emit('update:modelValue', id)
72
110
  open.value = false
73
111
  }
@@ -101,14 +139,22 @@ function choose(id: string) {
101
139
  <li>
102
140
  <button
103
141
  type="button"
104
- class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
105
- :class="modelValue ? 'text-slate-300' : 'text-slate-100'"
142
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm"
143
+ :class="[
144
+ refused.has('') ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-800/60',
145
+ modelValue ? 'text-slate-300' : 'text-slate-100',
146
+ ]"
147
+ :aria-disabled="refused.has('')"
148
+ :title="refusalText('')"
106
149
  data-testid="risk-policy-option-none"
107
150
  @mouseenter="activeId = ''"
108
151
  @focus="activeId = ''"
109
152
  @click="choose('')"
110
153
  >
111
- <UIcon name="i-lucide-rotate-ccw" class="h-4 w-4 shrink-0 text-slate-400" />
154
+ <UIcon
155
+ :name="refused.has('') ? 'i-lucide-lock' : 'i-lucide-rotate-ccw'"
156
+ class="h-4 w-4 shrink-0 text-slate-400"
157
+ />
112
158
  <span class="flex-1 truncate">{{ noneLabel }}</span>
113
159
  <UIcon
114
160
  v-if="!modelValue"
@@ -120,14 +166,22 @@ function choose(id: string) {
120
166
  <li v-for="p in options" :key="p.id">
121
167
  <button
122
168
  type="button"
123
- class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm hover:bg-slate-800/60"
124
- :class="modelValue === p.id ? 'text-slate-100' : 'text-slate-300'"
169
+ class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm"
170
+ :class="[
171
+ refused.has(p.id) ? 'cursor-not-allowed opacity-50' : 'hover:bg-slate-800/60',
172
+ modelValue === p.id ? 'text-slate-100' : 'text-slate-300',
173
+ ]"
174
+ :aria-disabled="refused.has(p.id)"
175
+ :title="refusalText(p.id)"
125
176
  :data-testid="`risk-policy-option-${p.id}`"
126
177
  @mouseenter="activeId = p.id"
127
178
  @focus="activeId = p.id"
128
179
  @click="choose(p.id)"
129
180
  >
130
- <UIcon name="i-lucide-git-merge" class="h-4 w-4 shrink-0 text-slate-400" />
181
+ <UIcon
182
+ :name="refused.has(p.id) ? 'i-lucide-lock' : 'i-lucide-git-merge'"
183
+ class="h-4 w-4 shrink-0 text-slate-400"
184
+ />
131
185
  <span class="flex-1 truncate">{{ p.name }}</span>
132
186
  <UIcon
133
187
  v-if="modelValue === p.id"
@@ -141,6 +195,13 @@ function choose(id: string) {
141
195
  <!-- right: what the active (or selected) policy actually does -->
142
196
  <div class="w-1/2 overflow-y-auto p-3">
143
197
  <template v-if="preview.policy">
198
+ <p
199
+ v-if="previewRefusal"
200
+ class="mb-2 text-[11px] leading-snug text-amber-400"
201
+ data-testid="risk-policy-refusal"
202
+ >
203
+ {{ t(`riskPolicy.picker.refused.${previewRefusal}`) }}
204
+ </p>
144
205
  <p
145
206
  v-if="preview.viaWorkspaceDefault"
146
207
  class="mb-2 text-[11px] leading-snug text-slate-500"
@@ -23,11 +23,12 @@ const ROLE_LABEL: Record<WorkspaceRole, () => string> = {
23
23
  viewer: () => t('merge.role.viewer'),
24
24
  }
25
25
  const roleLayer = computed(() => {
26
- const { sandboxed, narrowed } = rolePolicySummary(props.policy)
26
+ const { sandboxed, narrowed, scoped } = rolePolicySummary(props.policy)
27
27
  return {
28
28
  sandboxed: sandboxed.map((r) => ROLE_LABEL[r]()).join(', '),
29
29
  narrowed: narrowed.map((r) => ROLE_LABEL[r]()).join(', '),
30
- any: sandboxed.length > 0 || narrowed.length > 0,
30
+ scoped: scoped.map((r) => ROLE_LABEL[r]()).join(', '),
31
+ any: sandboxed.length > 0 || narrowed.length > 0 || scoped.length > 0,
31
32
  }
32
33
  })
33
34
 
@@ -94,6 +95,9 @@ const ceilings = computed(() =>
94
95
  <p v-if="roleLayer.sandboxed" class="text-[12px] leading-snug text-slate-400">
95
96
  {{ t('riskPolicy.preview.roleSandboxed', { roles: roleLayer.sandboxed }) }}
96
97
  </p>
98
+ <p v-if="roleLayer.scoped" class="text-[12px] leading-snug text-slate-400">
99
+ {{ t('riskPolicy.preview.roleScoped', { roles: roleLayer.scoped }) }}
100
+ </p>
97
101
  <p v-if="roleLayer.narrowed" class="text-[12px] leading-snug text-slate-400">
98
102
  {{ t('riskPolicy.preview.roleNarrowed', { roles: roleLayer.narrowed }) }}
99
103
  </p>
@@ -1,13 +1,17 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { MERGE_CLASS_RULES } from '@cat-factory/contracts'
3
- import type { ClassRulesByRole, MergeClassRules } from '~/types/merge'
2
+ import { MERGE_CLASS_RULES, RULEABLE_CHANGE_CLASSES } from '@cat-factory/contracts'
3
+ import type { ClassRulesByRole, MergeClassRules, SubmissionClassesByRole } from '~/types/merge'
4
4
  import {
5
5
  INHERIT_RULE,
6
6
  narrowingOptionsFor,
7
7
  roleClassRuleRows,
8
8
  roleNarrowedCount,
9
+ roleSubmissionRows,
10
+ roleSubmissionScoped,
9
11
  setRoleClassRule,
12
+ setRoleSubmissionScoped,
10
13
  toggleDryRunRole,
14
+ toggleSubmissionClass,
11
15
  } from '~/components/settings/MergeRolePolicyEditor.logic'
12
16
 
13
17
  describe('narrowingOptionsFor', () => {
@@ -144,3 +148,62 @@ describe('roleNarrowedCount', () => {
144
148
  expect(roleNarrowedCount({ docs: 'never', source: 'never' })).toBe(2)
145
149
  })
146
150
  })
151
+
152
+ // The submission allowlist's editing shape turns on one distinction the wire contract makes and a
153
+ // tick-box grid cannot render on its own: an ABSENT entry (unrestricted) and an EMPTY list (lands
154
+ // nothing) are different policies. Every case below is about keeping those two apart.
155
+ describe('roleSubmissionScoped', () => {
156
+ it('reads an EMPTY allowlist as scoped, not as the unrestricted default', () => {
157
+ expect(roleSubmissionScoped({ member: [] }, 'member')).toBe(true)
158
+ expect(roleSubmissionScoped({ member: ['docs'] }, 'member')).toBe(true)
159
+ })
160
+
161
+ it('reads a role with no entry as unrestricted', () => {
162
+ expect(roleSubmissionScoped({ member: ['docs'] }, 'admin')).toBe(false)
163
+ expect(roleSubmissionScoped({}, 'member')).toBe(false)
164
+ })
165
+ })
166
+
167
+ describe('roleSubmissionRows', () => {
168
+ it('renders one row per ruleable class, in the shared order, ticked by membership', () => {
169
+ const rows = roleSubmissionRows(['source', 'docs'])
170
+ expect(rows.map((r) => r.changeClass)).toEqual([...RULEABLE_CHANGE_CLASSES])
171
+ expect(rows.filter((r) => r.allowed).map((r) => r.changeClass)).toEqual(['docs', 'source'])
172
+ })
173
+ })
174
+
175
+ describe('setRoleSubmissionScoped', () => {
176
+ // Turning the switch ON must not silently impose a policy nobody clicked: seeding today's
177
+ // landable classes leaves the role exactly where it was, and the operator subtracts from there.
178
+ it('seeds every class when scoping is turned on', () => {
179
+ expect(setRoleSubmissionScoped({}, 'member', true).member).toEqual([...RULEABLE_CHANGE_CLASSES])
180
+ })
181
+
182
+ it('removes the entry entirely when scoping is turned off', () => {
183
+ const byRole: SubmissionClassesByRole = { member: [], admin: ['docs'] }
184
+ const next = setRoleSubmissionScoped(byRole, 'member', false)
185
+ expect('member' in next).toBe(false)
186
+ expect(next.admin).toEqual(['docs'])
187
+ })
188
+ })
189
+
190
+ describe('toggleSubmissionClass', () => {
191
+ it('adds and removes one class, keeping the shared class order', () => {
192
+ const ticked = toggleSubmissionClass({ member: ['source'] }, 'member', 'docs', true)
193
+ expect(ticked.member).toEqual(['docs', 'source'])
194
+ expect(toggleSubmissionClass(ticked, 'member', 'source', false).member).toEqual(['docs'])
195
+ })
196
+
197
+ // Unticking the last class is the policy "this role lands nothing", so it must NOT prune back
198
+ // to an absent entry, which would silently invert the click into "unrestricted".
199
+ it('leaves an EMPTY list rather than un-scoping the role', () => {
200
+ const next = toggleSubmissionClass({ member: ['docs'] }, 'member', 'docs', false)
201
+ expect(next.member).toEqual([])
202
+ expect(roleSubmissionScoped(next, 'member')).toBe(true)
203
+ })
204
+
205
+ it('leaves other roles untouched', () => {
206
+ const next = toggleSubmissionClass({ admin: ['docs'] }, 'member', 'docs', true)
207
+ expect(next.admin).toEqual(['docs'])
208
+ })
209
+ })
@@ -23,6 +23,7 @@ import type {
23
23
  MergeClassRule,
24
24
  MergeClassRules,
25
25
  RuleableChangeClass,
26
+ SubmissionClassesByRole,
26
27
  WorkspaceRole,
27
28
  } from '~/types/merge'
28
29
 
@@ -131,3 +132,74 @@ export function toggleDryRunRole(
131
132
  export function roleNarrowedCount(entry: MergeClassRules | undefined): number {
132
133
  return entry ? Object.keys(entry).length : 0
133
134
  }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // The per-role SUBMISSION ALLOWLIST: which change classes a role may LAND at all. A third
138
+ // setting rather than a fourth rule value, because it answers a different question: a class rule
139
+ // decides how much review landing takes, where this decides whether the platform lands it at all,
140
+ // and it is refused at BOTH exits, the manual merge included.
141
+ //
142
+ // The editing shape follows the wire shape's own distinction: an ABSENT entry is unrestricted
143
+ // and an EMPTY list is "this role lands nothing", which are different policies. So the row is a
144
+ // switch (scoped / not) plus a set of tick boxes, never a set of tick boxes alone. With tick
145
+ // boxes alone, "unrestricted" and "lands nothing" would be the same rendered state.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /** One class's tick box inside one role's allowlist. */
149
+ export interface RoleSubmissionRow {
150
+ changeClass: RuleableChangeClass
151
+ allowed: boolean
152
+ }
153
+
154
+ /** Whether this role carries an allowlist at all (as opposed to being unrestricted). */
155
+ export function roleSubmissionScoped(
156
+ byRole: SubmissionClassesByRole,
157
+ role: WorkspaceRole,
158
+ ): boolean {
159
+ return byRole[role] !== undefined
160
+ }
161
+
162
+ /** One role's tick boxes, in the shared class order. Empty rows for an unscoped role. */
163
+ export function roleSubmissionRows(entry: readonly RuleableChangeClass[]): RoleSubmissionRow[] {
164
+ return RULEABLE_CHANGE_CLASSES.map((changeClass) => ({
165
+ changeClass,
166
+ allowed: entry.includes(changeClass),
167
+ }))
168
+ }
169
+
170
+ /**
171
+ * Turn the allowlist on or off for a role. Turning it ON seeds the classes that are landable
172
+ * TODAY, which is behaviourally where the role already was, so the operator subtracts from a
173
+ * known state rather than starting from a policy ("lands nothing") they did not ask for.
174
+ *
175
+ * That seeded list is NOT the identity, and deliberately: from here on the role is scoped, so a
176
+ * class the vocabulary gains in a later release falls outside it. Opting in is what earns that.
177
+ */
178
+ export function setRoleSubmissionScoped(
179
+ byRole: SubmissionClassesByRole,
180
+ role: WorkspaceRole,
181
+ scoped: boolean,
182
+ ): SubmissionClassesByRole {
183
+ const next: SubmissionClassesByRole = { ...byRole }
184
+ if (scoped) next[role] = [...RULEABLE_CHANGE_CLASSES]
185
+ else delete next[role]
186
+ return next
187
+ }
188
+
189
+ /**
190
+ * Tick or untick one class for a role that is already scoped, keeping the shared class order so
191
+ * two presets carrying the same policy carry the same array. Unticking the last class leaves an
192
+ * EMPTY list rather than removing the entry: that is the real policy "this role lands nothing",
193
+ * and silently promoting it to unrestricted would be the exact inversion of what was clicked.
194
+ */
195
+ export function toggleSubmissionClass(
196
+ byRole: SubmissionClassesByRole,
197
+ role: WorkspaceRole,
198
+ changeClass: RuleableChangeClass,
199
+ allowed: boolean,
200
+ ): SubmissionClassesByRole {
201
+ const current = new Set(byRole[role] ?? [])
202
+ if (allowed) current.add(changeClass)
203
+ else current.delete(changeClass)
204
+ return { ...byRole, [role]: RULEABLE_CHANGE_CLASSES.filter((c) => current.has(c)) }
205
+ }