# Goal Cycles — Add `progressControlConfig` to control who can create goal progress from APP > **PR**: TBD · **Module**: goals · **Date**: 2026-05-22 · **Author**: Mauricio Cuello > **Status**: Ready for FE ## Metadata | | | |---|---| | Endpoints affected | See "Affected endpoints" below | | Scopes affected | APP, BO | | Permissions required | Unchanged — `validateViewGoals` (APP), `validateManageGoalCycles` (BO) | | Breaking change | No | | Deprecation window | N/A | | Rate limit | N/A | | Related endpoints | None | ## TL;DR - New field `progressControlConfig` on the `GoalCycle` resource. Enum, optional on create/update, **default `ADMIN_BOSS_AND_OWNER`** (matches today's behavior). - `POST /goals/{id}/progress` (APP) now consults the goal's cycle to decide who can create progress. New 403 paths possible for owner/boss depending on the cycle's config. - BO `POST /goals/{id}/progress` keeps working exactly as before — admins always bypass the config. ## Affected endpoints | Method | Path | Scope | Change | |---|---|---|---| | POST | `/goal-cycles` | BO | Request gains optional `progressControlConfig`. Response gains `progressControlConfig`. | | PUT | `/goal-cycles/{id}` | BO | Request gains optional `progressControlConfig` (only mutable while `status ∈ {DRAFT, PENDING}`). Response gains `progressControlConfig`. | | GET | `/goal-cycles` | APP, BO | Response items gain `progressControlConfig`. | | GET | `/goal-cycles/{id}` | APP, BO | Response gains `progressControlConfig`. | | GET | `/goal-cycles/{id}/goals-with-stats` | APP, BO | Same — response items that embed the cycle gain `progressControlConfig`. | | POST | `/goals/{id}/progress` | APP | **Behavior change**: 403 may be returned based on the cycle's `progressControlConfig`. Shape unchanged. | | POST | `/goals/{id}/progress` | BO | No change. Admin bypass preserved. | ## `GoalCycle` resource ### Previous contract #### Request body — `POST /goal-cycles` (BO) ```ts type CreateGoalCycleRequest = { name: string; // required, min length 1 startDate: string; // required, ISO datetime endDate: string; // required, ISO datetime, > startDate hasFormulas?: boolean; // optional, default false enforceGoalCountBounds?: boolean; // optional, default false minGoalsPerOwner?: number | null; // required when enforceGoalCountBounds === true, >= 0 maxGoalsPerOwner?: number | null; // required when enforceGoalCountBounds === true, > 0 }; ``` #### Request body — `PUT /goal-cycles/{id}` (BO) ```ts type UpdateGoalCycleRequest = CreateGoalCycleRequest & { isForIndividuals: boolean; // required status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'FINISHED'; // required }; ``` #### Response — `GET/POST/PUT /goal-cycles[/{id}]` ```ts type GoalCycle = { id: number; creatorId: number; createdAt: string; // ISO datetime updatedAt: string; // ISO datetime name: string; startDate: string; // ISO datetime endDate: string; // ISO datetime status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'FINISHED'; isForIndividuals: boolean | null; hasFormulas: boolean; enforceGoalCountBounds: boolean; minGoalsPerOwner: number | null; maxGoalsPerOwner: number | null; goalCount: number | null; goals: Goal[] | null; }; ``` ### New contract #### Request body — `POST /goal-cycles` (BO) ```ts type GoalCycleProgressControlConfig = | 'ADMIN_ONLY' | 'ADMIN_AND_BOSS' | 'ADMIN_AND_OWNER' | 'ADMIN_BOSS_AND_OWNER'; type CreateGoalCycleRequest = { name: string; startDate: string; endDate: string; hasFormulas?: boolean; enforceGoalCountBounds?: boolean; minGoalsPerOwner?: number | null; maxGoalsPerOwner?: number | null; progressControlConfig?: GoalCycleProgressControlConfig; // NEW — optional, default 'ADMIN_BOSS_AND_OWNER' }; ``` #### Request body — `PUT /goal-cycles/{id}` (BO) ```ts type UpdateGoalCycleRequest = CreateGoalCycleRequest & { isForIndividuals: boolean; status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'FINISHED'; }; ``` #### Response — `GET/POST/PUT /goal-cycles[/{id}]` ```ts type GoalCycle = { id: number; creatorId: number; createdAt: string; updatedAt: string; name: string; startDate: string; endDate: string; status: 'DRAFT' | 'PENDING' | 'IN_PROGRESS' | 'FINISHED'; isForIndividuals: boolean | null; hasFormulas: boolean; enforceGoalCountBounds: boolean; minGoalsPerOwner: number | null; maxGoalsPerOwner: number | null; progressControlConfig: GoalCycleProgressControlConfig; // NEW — always present in response goalCount: number | null; goals: Goal[] | null; }; ``` #### Behavior ##### Defaults and derived fields - If `progressControlConfig` is omitted on `POST /goal-cycles`, server persists `'ADMIN_BOSS_AND_OWNER'` and returns it in the response. This default exactly preserves the previous behavior (owner OR boss can create progress from APP). - All existing cycles created before this change are backfilled by the migration to `'ADMIN_BOSS_AND_OWNER'`. ##### Async effects / background processes - None added by this change. Notification flow on `POST /goals/{id}/progress` is unchanged. ##### Chaining with other endpoints - The value returned in `GoalCycle.progressControlConfig` is what the FE should use to decide whether to show/enable the "Add progress" action in the APP for a goal that belongs to a cycle. See "Recommended FE gating" below. - For goals **without** a `goalCycleId`, this field is not applicable — the APP rule is owner OR boss (unchanged from today). ##### Cache / consistency - No caching layer added or changed. Response is consistent with the just-written value. ##### Backwards compatibility / deprecations - Existing clients that do not send `progressControlConfig` keep working unchanged; they receive the default. - Existing clients that ignore the new response field keep working unchanged. ## Changes ### Type changes — `GoalCycle` resource - `progressControlConfig` — **added**. Enum: `'ADMIN_ONLY' | 'ADMIN_AND_BOSS' | 'ADMIN_AND_OWNER' | 'ADMIN_BOSS_AND_OWNER'`. - Request: optional on `POST /goal-cycles` and `PUT /goal-cycles/{id}` (BO). Default `'ADMIN_BOSS_AND_OWNER'`. - Response: always present on every endpoint that returns a `GoalCycle`. - No other type changes. ### Behavior changes — `POST /goals/{id}/progress` (APP) - When the goal belongs to a cycle (`goal.goalCycleId !== null`), the APP now resolves the cycle and applies its `progressControlConfig` to decide if the logged-in user can create progress. The rule, by config value: - `ADMIN_ONLY` — neither owner nor boss can create progress from APP. Only BO admins. - `ADMIN_AND_BOSS` — only the owner's direct boss can create progress from APP. - `ADMIN_AND_OWNER` — only the owner can create progress from APP. - `ADMIN_BOSS_AND_OWNER` — owner OR boss can create progress from APP (legacy behavior). - When the goal does **not** belong to a cycle (`goal.goalCycleId === null`), the previous rule applies unchanged: owner OR boss can create progress from APP. - BO (`POST /backoffice/goals/{id}/progress`) keeps behaving as before — admins always pass the check. - Error code on denial is `ACCESS_DENIED` (unchanged code; message string changed from `'You are neither the owner nor the boss of the owner.'` to `'You are not allowed to create progress for this goal.'`). FE should not depend on the error message. ## Errors | Status | Code | Change | When | Suggested FE action | |---|---|---|---|---| | 400 | `INVALID_INPUT` | Expanded conditions | Invalid `progressControlConfig` value on create/update of a cycle | Show field-level error | | 403 | `ACCESS_DENIED` | Expanded conditions + Message changed | APP `POST /goals/{id}/progress` — user does not qualify under the goal's cycle `progressControlConfig`. Also fires when goal has no cycle and user is neither owner nor boss (unchanged condition). | Hide/disable the "Add progress" action when possible; surface a meaningful empty state when triggered. | > FE should not depend on error message strings; only on status + code. The 403 path under `ACCESS_DENIED` is **not new**, but the conditions under which it fires changed for APP when the goal belongs to a cycle. Clients that already handle `ACCESS_DENIED` on `POST /goals/{id}/progress` continue to work; the new behavior just expands when this 403 is returned. ## Validations and constraints - `progressControlConfig`: enum, optional. Valid values: `ADMIN_ONLY`, `ADMIN_AND_BOSS`, `ADMIN_AND_OWNER`, `ADMIN_BOSS_AND_OWNER`. Default `ADMIN_BOSS_AND_OWNER`. - `progressControlConfig` on `PUT /goal-cycles/{id}` is honored only while `status ∈ {DRAFT, PENDING}`. When the cycle is `IN_PROGRESS` or `FINISHED`, the update endpoint preserves the existing legacy contract (only `name`, `startDate`, `endDate` are mutated) and silently ignores `progressControlConfig` (consistent with how `hasFormulas` and `enforceGoalCountBounds` already behave). - Existing cross-field rules unchanged: `minGoalsPerOwner` / `maxGoalsPerOwner` are required and validated when `enforceGoalCountBounds === true`; otherwise ignored. ## Recommended FE gating For the "Add progress" entry point in APP for a goal that belongs to a cycle: 1. From the goal, get `goalCycleId`. 2. Fetch (or read from already-loaded state) the cycle via `GET /goal-cycles/{id}` and read `progressControlConfig`. 3. Resolve the relationship between the logged-in user and the goal owner (`ownerId === loggedUserId`), and whether the user is the owner's direct boss (`bossId === loggedUserId`). The FE can use `GET /organization-charts/...` or whatever existing source provides this, since the backend does not return `bossId` on the `Goal` or `GoalCycle` resources today. 4. Decide visibility/enablement of the action using the same matrix the backend applies: | `progressControlConfig` | User is owner | User is boss | Show action? | |---|---|---|---| | `ADMIN_ONLY` | — | — | No | | `ADMIN_AND_BOSS` | — | Yes | Yes | | `ADMIN_AND_OWNER` | Yes | — | Yes | | `ADMIN_BOSS_AND_OWNER` | Yes | Yes | Yes | If the FE cannot reliably know who the boss is at the time the action is rendered, fall back to attempting the call and rendering an error UI on `403 ACCESS_DENIED`. ## FE checklist - [ ] Add the `GoalCycleProgressControlConfig` union type to the FE goals types. - [ ] Add `progressControlConfig: GoalCycleProgressControlConfig` to the `GoalCycle` response type (always present). - [ ] Add `progressControlConfig?: GoalCycleProgressControlConfig` (optional) to the `CreateGoalCycleRequest` and `UpdateGoalCycleRequest` types. - [ ] In the BO cycle create/edit form, expose a control for `progressControlConfig` with the 4 enum values and `'ADMIN_BOSS_AND_OWNER'` as the default. Allow editing only while `status ∈ {DRAFT, PENDING}`. - [ ] In the APP goal detail / cycle detail screens, consume `cycle.progressControlConfig` to gate the "Add progress" CTA per the matrix above. - [ ] Handle `403 ACCESS_DENIED` on `POST /goals/{id}/progress` (APP) gracefully — do not rely on the error message string. ## Examples ### Create a cycle with explicit `progressControlConfig` — `POST /goal-cycles` (BO) Request body: ```json { "name": "2026 Q3 OKRs", "startDate": "2026-07-01T00:00:00.000Z", "endDate": "2026-09-30T23:59:59.000Z", "hasFormulas": false, "enforceGoalCountBounds": false, "progressControlConfig": "ADMIN_AND_OWNER" } ``` Successful response (201): ```json { "id": 123, "creatorId": 42, "createdAt": "2026-05-22T17:30:00.000Z", "updatedAt": "2026-05-22T17:30:00.000Z", "name": "2026 Q3 OKRs", "startDate": "2026-07-01T00:00:00.000Z", "endDate": "2026-09-30T23:59:59.000Z", "status": "DRAFT", "isForIndividuals": null, "hasFormulas": false, "enforceGoalCountBounds": false, "minGoalsPerOwner": null, "maxGoalsPerOwner": null, "progressControlConfig": "ADMIN_AND_OWNER", "goalCount": null, "goals": null } ``` ### Create a cycle without `progressControlConfig` — default applied Request body: ```json { "name": "2026 Q3 OKRs", "startDate": "2026-07-01T00:00:00.000Z", "endDate": "2026-09-30T23:59:59.000Z" } ``` Notes: response will include `"progressControlConfig": "ADMIN_BOSS_AND_OWNER"`. ### Reject invalid enum value Request body: ```json { "name": "2026 Q3 OKRs", "startDate": "2026-07-01T00:00:00.000Z", "endDate": "2026-09-30T23:59:59.000Z", "progressControlConfig": "ANYONE" } ``` Error response (400): ```json { "code": "INVALID_INPUT", "message": "..." } ``` ### `POST /goals/{id}/progress` (APP) — denied under `ADMIN_ONLY` Scenario: goal belongs to a cycle whose `progressControlConfig === 'ADMIN_ONLY'`. Logged-in user is the owner. Error response (403): ```json { "code": "ACCESS_DENIED", "message": "You are not allowed to create progress for this goal." } ``` Notes: same status/code is returned for the boss in `ADMIN_ONLY`, for the owner in `ADMIN_AND_BOSS`, and for the boss in `ADMIN_AND_OWNER`. BO admins never hit this 403 path. ## Links - Backend PR: TBD - Design doc / spec: `docs/superpowers/specs/2026-05-22-goal-cycle-progress-control-config-design.md` - Ticket: SQPM-5024