Add create and resolve mutations for work item decision log

What does this MR do and why?

Add create and resolve mutations for work item decision log

Open questions need a way to be recorded and resolved, not just read back, to complete the Decision Log write path for Spec-Driven Development. Create and Resolve are split into dedicated services following the ServiceResponse pattern so resolution enforces its own invariants, such as requiring a non-system note on the same work item as the resolving rationale. Kept behind the same default-off decision_log flag as the prior retrieval MR.

Related to #615863 (closed)

Database review notes

DB related files:

  • db/migrate/20260908000001_remove_work_item_decisions_title_null_constraint.rb - migration dropping NOT NULL on title
  • db/schema_migrations/20260908000001 – the version marker for the above migration
  • db/structure.sql – the corresponding 1-line schema change

Files introducing new queries:

  • ee/app/services/work_items/decisions/create_service.rb
  • ee/app/services/work_items/decisions/resolve_service.rb
  • ee/app/models/work_items/decision.rb and ee/app/models/work_items/decision_option.rb

Raw SQL and schema plans

Notes:

Both tables are empty on production (feature is behind the default-off decision_log flag), so plans were produced on a Database Lab clone via Joe Bot.

We applied this MR's pending migration (ALTER TABLE work_item_decisions ALTER COLUMN title DROP NOT NULL), then seeded 10k decisions (20 each across 500 recent real issues. 50k option rows total, followed by ANALYZE.

Values in the queries are the ones used to generate the plans.

Pattern 1 — options by decision FK:

SELECT COUNT(*) FROM "work_item_decision_options" WHERE "work_item_decision_options"."work_item_decision_id" = 15000;

Call sites sharing this plan:

Query plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/56708/commands/161084

Pattern 2 — options by decision FK plus id list (padded to 5 ids, the per-decision cap, i.e. the worst case):

SELECT "work_item_decision_options".* FROM "work_item_decision_options" WHERE "work_item_decision_options"."work_item_decision_id" = 15000 AND "work_item_decision_options"."id" IN (24996, 24997, 24998, 24999, 25000) ORDER BY "work_item_decision_options"."id" ASC;

Call sites sharing this plan:

Query plan: https://console.postgres.ai/gitlab/projects/gitlab-production-main/sessions/56708/commands/161085

Pattern 3 — update selected options during resolve action

Note: generated using EXPLAIN on local db after inserting 400 options into 100 decisions. Values in SQL are the values used to generate this query plan locally.

Location: ee/app/services/work_items/decisions/resolve_service.rb::94

UPDATE "work_item_decision_options"
SET "selected" = TRUE, "updated_at" = '2026-09-10 05:08:32.134569'
WHERE ("work_item_decision_options"."id") IN (
  SELECT "work_item_decision_options"."id"
  FROM "work_item_decision_options"
  WHERE "work_item_decision_options"."work_item_decision_id" = 303
    AND "work_item_decision_options"."id" IN (285, 286, 287)
  ORDER BY "work_item_decision_options"."id" ASC
)

Query plan: https://explain.depesz.com/s/fTA7

Pattern 4 — writes

Note: These are PK-targeted single-row writes, with loop bounds of MAX_OPTIONS_PER_DECISION = 5. As such, including only the raw SQL for now.

Values used are examples from local db testing.

Location: ee/app/services/work_items/decisions/create_service.rb:48

-- open decision
INSERT INTO "work_item_decisions" ("work_item_id", "namespace_id", "author_id", "created_at", "updated_at", "title")
VALUES (9, 16, 29, '2026-09-08 22:09:11.370159', '2026-09-08 22:09:11.370159', 'Which backend?')
RETURNING "id";

-- resolved-at-create (resolved_at = created_at, no title)
INSERT INTO "work_item_decisions" ("work_item_id", "namespace_id", "author_id", "resolved_by_id", "created_at", "updated_at", "resolved_at", "resolution_rationale", "source_link")
VALUES (8, 14, 27, 27, '2026-09-08 22:01:15.177232', '2026-09-08 22:01:15.210143', '2026-09-08 22:01:15.177232', 'Settled', 'https://example.com/doc')
RETURNING "id";

Location: create_service.rb:58 and :68

-- :58 candidate option
INSERT INTO "work_item_decision_options" ("work_item_decision_id", "namespace_id", "created_at", "updated_at", "content")
VALUES (19, 16, '2026-09-08 22:09:11.377548', '2026-09-08 22:09:11.377548', 'PostgreSQL')
RETURNING "id";

-- :68 derived selected option (resolved-at-create)
INSERT INTO "work_item_decision_options" ("work_item_decision_id", "namespace_id", "created_at", "updated_at", "selected", "content")
VALUES (20, 16, '2026-09-08 22:09:11.433495', '2026-09-08 22:09:11.433495', TRUE, 'Use PostgreSQL')
RETURNING "id";

Location: create_service.rb:95 — PK lookup via the existing routing function; only when an attributed resolver is given:

SELECT "users"."id", "users"."email", "users"."encrypted_password", "users"."reset_password_token", "users"."reset_password_sent_at", "users"."remember_created_at", "users"."sign_in_count", "users"."current_sign_in_at", "users"."last_sign_in_at", "users"."current_sign_in_ip", "users"."last_sign_in_ip", "users"."created_at", "users"."updated_at", "users"."name", "users"."admin", "users"."projects_limit", "users"."failed_attempts", "users"."locked_at", "users"."username", "users"."can_create_group", "users"."can_create_team", "users"."state", "users"."color_scheme_id", "users"."password_expires_at", "users"."created_by_id", "users"."last_credential_check_at", "users"."avatar", "users"."confirmation_token", "users"."confirmed_at", "users"."confirmation_sent_at", "users"."unconfirmed_email", "users"."hide_no_ssh_key", "users"."admin_email_unsubscribed_at", "users"."notification_email", "users"."hide_no_password", "users"."password_automatically_set", "users"."encrypted_otp_secret", "users"."encrypted_otp_secret_iv", "users"."encrypted_otp_secret_salt", "users"."otp_required_for_login", "users"."otp_backup_codes", "users"."public_email", "users"."dashboard", "users"."project_view", "users"."consumed_timestep", "users"."layout", "users"."hide_project_limit", "users"."note", "users"."unlock_token", "users"."otp_grace_period_started_at", "users"."external", "users"."incoming_email_token", "users"."auditor", "users"."require_two_factor_authentication_from_group", "users"."two_factor_grace_period", "users"."last_activity_on", "users"."notified_of_own_activity", "users"."preferred_language", "users"."theme_id", "users"."accepted_term_id", "users"."feed_token", "users"."private_profile", "users"."roadmap_layout", "users"."include_private_contributions", "users"."commit_email", "users"."group_view", "users"."managing_group_id", "users"."first_name", "users"."last_name", "users"."static_object_token", "users"."user_type", "users"."static_object_token_encrypted", "users"."otp_secret_expires_at", "users"."onboarding_in_progress", "users"."color_mode_id", "users"."composite_identity_enforced", "users"."organization_id", "users"."otp_secret"
FROM find_users_by_id(29) AS users
WHERE ("users"."id" IS NOT NULL)
LIMIT 1;

Location: resolve_service.rb:8 — PK lookup on existing index (decision's work item):

SELECT "issues"."id", "issues"."title", "issues"."author_id", "issues"."project_id", "issues"."created_at", "issues"."updated_at", "issues"."description", "issues"."milestone_id", "issues"."iid", "issues"."updated_by_id", "issues"."weight", "issues"."confidential", "issues"."due_date", "issues"."moved_to_id", "issues"."lock_version", "issues"."title_html", "issues"."description_html", "issues"."time_estimate", "issues"."relative_position", "issues"."service_desk_reply_to", "issues"."cached_markdown_version", "issues"."last_edited_at", "issues"."last_edited_by_id", "issues"."discussion_locked", "issues"."closed_at", "issues"."closed_by_id", "issues"."state_id", "issues"."duplicated_to_id", "issues"."promoted_to_epic_id", "issues"."health_status", "issues"."sprint_id", "issues"."blocking_issues_count", "issues"."upvotes_count", "issues"."work_item_type_id", "issues"."namespace_id", "issues"."start_date", "issues"."imported_from", "issues"."namespace_traversal_ids"
FROM "issues"
WHERE "issues"."id" = 9
LIMIT 1;

Location: resolve_service.rb:83 — single-row update by PK, at most 5 per resolve:

UPDATE "work_item_decision_options"
SET "updated_at" = '2026-09-08 22:09:11.466802', "selected" = TRUE
WHERE "work_item_decision_options"."id" = 17;

Location: resolve_service.rb:60 — single-row update by PK:

UPDATE "work_item_decisions"
SET "resolved_by_id" = 29, "resolving_note_id" = 2, "updated_at" = '2026-09-08 22:09:11.469138', "resolved_at" = '2026-09-08 22:09:11.467655', "resolution_rationale" = 'Consensus'
WHERE "work_item_decisions"."id" = 19;

FE Integration testing notes

Note: provided params are meant to be examples only

GraphQL query for creating a resolved decision (manual decision create flow):

This creates a decision that is already resolved - different from the create open decision -> resolve open decision flow.

The only difference between this and the create an open decision flow is a resolution object is sent instead of **options **- this is what distinguishes the creation of a resolved decision from an open one.

mutation createResolvedDecision(
  $workItemId: WorkItemID!
  $title: String!
  $sourceLink: String
  $resolution: WorkItemDecisionResolutionInput!
) {
  workItemDecisionCreate(input: {
    workItemId: $workItemId
    title: $title
    sourceLink: $sourceLink
    resolution: $resolution
  }) {
    decision {
      id
      title
      sourceLink
      resolvedAt
      resolvedBy { id name avatarUrl }
      resolutionRationale
      options { nodes { id content selected } }
    }
    errors
  }
}

{
  "workItemId": "gid://gitlab/WorkItem/123",
  "title": "Which storage backend should we use?",
  "sourceLink": "https://docs.google.com/document/d/abc123",
  "resolution": {
    "decision": "Use PostgreSQL for the storage backend",
    "rationale": "Settled in the design sync",
    "resolvedById": "gid://gitlab/User/42"
  }
}

Note: Notes: resolution.decision is required and comes back as the single selected: true option; rationale and resolvedById are optional (resolver defaults to the current user; a named resolver must have read access). Do not send options — the BE rejects the combination.

GraphQL query for creating a decision (automated flow):

mutation createOpenDecision(
  $workItemId: WorkItemID!
  $title: String!
  $description: String
  $discussionId: String
  $options: [WorkItemDecisionOptionInput!]
) {
  workItemDecisionCreate(input: {
    workItemId: $workItemId
    title: $title
    description: $description
    discussionId: $discussionId
    options: $options
  }) {
    decision {
      id
      title
      description
      discussionId
      resolvedAt
      options { nodes { id content description recommended selected } }
    }
    errors
  }
}

{
  "workItemId": "gid://gitlab/WorkItem/123",
  "title": "Which storage backend should we use?",
  "description": "We need to settle this before implementation",
  "discussionId": "a1b2c3…40-char-sha",
  "options": [
    { "content": "Use PostgreSQL", "recommended": true, "description": "Matches the existing stack" },
    { "content": "Use Redis" }
  ]
}

GraphQL query for resolving a decision (automated flow):

mutation resolveDecision(
  $id: WorkItemsDecisionID!
  $selectedOptionIds: [WorkItemsDecisionOptionID!]
  $resolutionRationale: String
  $resolvingNoteId: NoteID
) {
  workItemDecisionResolve(input: {
    id: $id
    selectedOptionIds: $selectedOptionIds
    resolutionRationale: $resolutionRationale
    resolvingNoteId: $resolvingNoteId
  }) {
    decision {
      id
      resolvedAt
      resolvedBy { id name }
      resolutionRationale
      noteUrl
      options { nodes { id content selected } }
    }
    errors
  }
}

{
  "id": "gid://gitlab/WorkItems::Decision/7",
  "selectedOptionIds": ["gid://gitlab/WorkItems::DecisionOption/31"],
  "resolutionRationale": "Team consensus in the thread",
  "resolvingNoteId": "gid://gitlab/Note/9001"
}

MR acceptance checklist

Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

Related to #615863 (closed)

Edited by Richard Chan

Merge request reports

Loading
Loading