{"record":{"id":"e8ee12f5c2d05ff5","repo":"instructure/canvas-lms","slug":"not-found-update-discussion-thread-read-state","errorCode":null,"errorMessage":"not found","messagePattern":"not found","errorType":"exception","errorClass":"GraphQL::ExecutionError","httpStatus":null,"severity":"warning","filePath":"app/graphql/mutations/update_discussion_thread_read_state.rb","lineNumber":30,"sourceCode":"# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY\n# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR\n# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more\n# details.\n#\n# You should have received a copy of the GNU Affero General Public License along\n# with this program. If not, see <http://www.gnu.org/licenses/>.\n#\n\nclass Mutations::UpdateDiscussionThreadReadState < Mutations::BaseMutation\n  graphql_name \"UpdateDiscussionThreadReadState\"\n\n  argument :discussion_entry_id, ID, required: true, prepare: GraphQLHelpers.relay_or_legacy_id_prepare_func(\"DiscussionEntry\")\n  argument :read, Boolean, required: true\n\n  field :discussion_entry, Types::DiscussionEntryType, null: false\n  def resolve(input:)\n    root_entry = DiscussionEntry.find(input[:discussion_entry_id])\n    raise GraphQL::ExecutionError, \"not found\" unless root_entry.grants_right?(current_user, session, :read)\n\n    read_state = input[:read] ? \"read\" : \"unread\"\n\n    DiscussionEntryParticipant.upsert_for_root_entry_and_descendants(root_entry,\n                                                                     current_user,\n                                                                     new_state: read_state,\n                                                                     forced: true)\n\n    topic = root_entry.discussion_topic\n    total_read_count = topic.discussion_entry_participants.read.where(\n      discussion_entry_participants: { user_id: current_user.id }\n    ).count\n    topic.update_or_create_participant(current_user:, new_count: topic.default_unread_count - total_read_count)\n\n    {\n      discussion_entry: root_entry\n    }\n  rescue ActiveRecord::RecordNotFound","sourceCodeStart":12,"sourceCodeEnd":48,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/graphql/mutations/update_discussion_thread_read_state.rb#L12-L48","documentation":"UpdateDiscussionThreadReadState#resolve raises GraphQL::ExecutionError \"not found\" directly (not via rescue) when DiscussionEntry.find succeeds but root_entry.grants_right?(current_user, session, :read) is false. The record exists, but the caller must not learn that, so Canvas reports it as not found.","triggerScenarios":"updateDiscussionThreadReadState with a discussion_entry_id in a topic the user cannot read: unpublished/ delayed topic, unenrolled user, wrong course context, or entry whose root topic is graded and hidden until submission.","commonSituations":"Observer accounts without read rights in that section; entry ID from a different course after client-side mixup; API key scoped to a token-user lacking enrollment; students replying before topic availability date.","solutions":["Verify root_entry.grants_right?(current_user, session, :read) before the mutation call.","Confirm the client is authenticated as the intended user (token vs session mismatch).","Check the entry's topic publish state and availability windows for that user's role.","Fix section/enrollment restrictions if the user legitimately needs access."],"exampleFix":"// before\nraise GraphQL::ExecutionError, \"not found\" unless root_entry.grants_right?(current_user, session, :read)\n// after\nunless root_entry.grants_right?(current_user, session, :read)\n  raise GraphQL::ExecutionError, \"not found\" # ensure client pre-checks read access\nend","handlingStrategy":"type-guard","validationCode":"query { discussionEntry(id: $id) { permissions { read } } }\n// only mutate when data.discussionEntry.permissions.read === true","typeGuard":"function isReadableEntry(entry) { return !!entry && entry.permissions?.read === true; }","tryCatchPattern":"if (!isReadableEntry(entry)) return skip();\ntry {\n  await updateDiscussionThreadReadState(...);\n} catch (e) {\n  if (e.message === 'not found') handleForbiddenAsNotFound();\n}","preventionTips":["Gate thread-level mutations on the root topic's read permission","Filter entry lists by user-visible topics server-side before caching","Handle 'not found' as possible authorization failure, not just missing row"],"tags":["graphql","permissions","discussions"],"backgroundTag":"permission-denied","analyzedSha":"1c9f0bb8013ed69c4f2efe11fd483025469b7e6c","analyzedAt":"2026-09-15T20:33:18.891Z","contentChangedAt":"2026-09-15T20:33:18.891Z","schemaVersion":2},"datasetVersion":"2026-09-23T02:17:17.105Z"}