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 ontitledb/schema_migrations/20260908000001– the version marker for the above migrationdb/structure.sql– the corresponding 1-line schema change
Files introducing new queries:
ee/app/services/work_items/decisions/create_service.rbee/app/services/work_items/decisions/resolve_service.rbee/app/models/work_items/decision.rbandee/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_logflag), 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 byANALYZE.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:
- ee/app/models/work_items/decision_option.rb:32 — options-per-decision cap validation, runs before each option insert
- ee/app/services/work_items/decisions/resolve_service.rb:75 — decision.options.exists? on the reject-all path, same filter (SELECT 1 ... LIMIT 1)
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:
- ee/app/services/work_items/decisions/resolve_service.rb:18 — COUNT variant
- ee/app/services/work_items/decisions/resolve_service.rb:75 — SELECT 1 ... LIMIT 1 variant
- ee/app/services/work_items/decisions/resolve_service.rb:79/:83 — materializing SELECT
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)