# 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
