{"record":{"id":"b26fa2deb16fbc0c","repo":"instructure/canvas-lms","slug":"cannot-archive-a-deleted-learningoutcome","errorCode":null,"errorMessage":"Cannot archive a deleted LearningOutcome","messagePattern":"Cannot archive a deleted LearningOutcome","errorType":"validation","errorClass":"ActiveRecord::RecordNotSaved","httpStatus":null,"severity":"error","filePath":"app/models/learning_outcome.rb","lineNumber":412,"sourceCode":"    # in case this got called in a console, delete the alignments also. the UI\n    # won't (shouldn't) allow deleting the outcome if there are still\n    # alignments, so this will be a no-op in that case. either way, these are\n    # not outcome links, so ContentTag#destroy is just changing the\n    # workflow_state; use update_all for efficiency.\n    ContentTag.learning_outcome_alignments.active.where(learning_outcome_id: self).update_all(workflow_state: \"deleted\")\n\n    self.workflow_state = \"deleted\"\n    save!\n  end\n\n  def archive!\n    # Only active outcomes can be archived\n    if workflow_state == \"active\"\n      self.workflow_state = \"archived\"\n      self.archived_at = Time.now.utc\n      save!\n    elsif workflow_state == \"deleted\"\n      raise ActiveRecord::RecordNotSaved, \"Cannot archive a deleted LearningOutcome\"\n    end\n  end\n\n  def unarchive!\n    # Only archived outcomes can be unarchived\n    if workflow_state == \"archived\"\n      self.workflow_state = \"active\"\n      self.archived_at = nil\n      save!\n    elsif workflow_state == \"deleted\"\n      raise ActiveRecord::RecordNotSaved, \"Cannot unarchive a deleted LearningOutcome\"\n    end\n  end\n\n  def assessed?(course = nil)\n    if course\n      learning_outcome_results.active.where(context_id: course, context_type: \"Course\").exists?\n    elsif learning_outcome_results.active.loaded?","sourceCodeStart":394,"sourceCodeEnd":430,"githubUrl":"https://github.com/instructure/canvas-lms/blob/1c9f0bb8013ed69c4f2efe11fd483025469b7e6c/app/models/learning_outcome.rb#L394-L430","documentation":"LearningOutcome#archive! moves an active outcome to 'archived' and stamps archived_at. Archiving a deleted outcome is an invalid transition, so when workflow_state is 'deleted' it raises ActiveRecord::RecordNotSaved.","triggerScenarios":"Calling outcome.archive! on a LearningOutcome whose workflow_state is 'deleted' — e.g. acting on a stale record after another process deleted it.","commonSituations":"Race between deletion (importer, outcome UI) and an archive request; background jobs replaying archive on already-deleted outcomes.","solutions":["Guard before calling: archive! only if workflow_state == 'active'.","Reload the record to check current state before archiving.","Rescue ActiveRecord::RecordNotSaved and treat as a no-op when deleted.","Permanently delete instead of archiving if that was the intent."],"exampleFix":"// before\noutcome.archive!\n// after\noutcome.reload.archive! if outcome.reload.workflow_state == \"active\"","handlingStrategy":"try-catch","validationCode":"outcome.reload.archive! if outcome.reload.workflow_state == \"active\"","typeGuard":null,"tryCatchPattern":"begin\n  outcome.archive!\nrescue ActiveRecord::RecordNotSaved\n  Rails.logger.info(\"outcome #{outcome.id} already deleted; skipping archive\")\nend","preventionTips":["Only archive outcomes verified active","Reload before state transitions on long-lived objects","Treat RecordNotSaved on deleted records as a no-op"],"tags":["activerecord","state-machine","outcomes"],"backgroundTag":"invalid-state-transition","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"}