{"record":{"id":"84ce83e7724103a7","repo":"ZhuLinsen/daily_stock_analysis","slug":"terminal-decision-signal-cannot-be-reactivated-thr","errorCode":null,"errorMessage":"terminal decision signal cannot be reactivated through status update","messagePattern":"terminal decision signal cannot be reactivated through status update","errorType":"validation","errorClass":"ValueError","httpStatus":400,"severity":"error","filePath":"src/services/decision_signal_service.py","lineNumber":372,"sourceCode":"            \"page_size\": max(1, min(int(limit), 100)),\n        }\n\n    def update_status(\n        self,\n        signal_id: int,\n        *,\n        status: str,\n        metadata: Optional[Any] = None,\n        replace_metadata: bool = False,\n    ) -> Dict[str, Any]:\n        status_norm = self._normalize_enum(status, SIGNAL_STATUSES, \"status\")\n        existing = self.repo.get(signal_id)\n        if existing is None:\n            raise DecisionSignalNotFoundError(f\"Decision signal not found: {signal_id}\")\n        if status_norm == \"active\" and (\n            existing.status in TERMINAL_STATUSES or self._is_expired(existing.expires_at)\n        ):\n            raise ValueError(\"terminal decision signal cannot be reactivated through status update\")\n        metadata_json = None\n        if replace_metadata:\n            if isinstance(metadata, dict):\n                normalized_metadata = dict(metadata)\n                if existing.decision_profile is None:\n                    normalized_metadata.pop(\"decision_profile\", None)\n                else:\n                    normalized_metadata = self._synchronize_metadata_decision_profile(\n                        normalized_metadata,\n                        existing.decision_profile,\n                    )\n                metadata_json = self._json_dumps(normalized_metadata)\n            else:\n                metadata_json = self._json_dumps(metadata)\n        row = self.repo.update_status(\n            signal_id,\n            status=status_norm,\n            metadata_json=metadata_json,","sourceCodeStart":354,"sourceCodeEnd":390,"githubUrl":"https://github.com/ZhuLinsen/daily_stock_analysis/blob/5159bd72e8373d215492dff122acc9d389e219c9/src/services/decision_signal_service.py#L354-L390","documentation":"Plain ValueError raised by DecisionSignalService.update_status (src/services/decision_signal_service.py:372) when the caller requests status='active' for a signal that is already in a TERMINAL_STATUSES status (e.g. closed/expired/cancelled) or whose expires_at is already in the past. Terminal signals are immutable by design: reactivation must go through creating a new signal, not a status flip.","triggerScenarios":"PATCH /decision-signals/{id}/status with {\"status\": \"active\"} where the row's current status is terminal, or where _is_expired(existing.expires_at) is true (lazy expiry already lapsed). Note the check runs before repo.update_status, so even racing writers hit it on the freshly-read row.","commonSituations":"Automation that 'reopens' old signals by resetting status; clock skew or a long-paused scheduler causing expires_at to pass while the signal was still displayed as active in a cached UI; a retry pipeline blindly re-sending the previous 'active' request after the signal was closed by another actor.","solutions":["Do not reactivate: create a fresh signal (POST /decision-signals) capturing the new decision instead of flipping the terminal one back to active.","If the signal should never have been terminal, investigate why it was closed/expired and fix the writer, then still create a new signal rather than mutating history.","If expiry-driven, review expires_at defaults (_default_expires_at) so live signals don't silently lapse.","For idempotent retry logic, treat this ValueError as a no-op signal that the request was already finalized."],"exampleFix":"# before\nservice.update_signal_status(signal_id, status=\"active\")  # ValueError: terminal decision signal cannot be reactivated\n\n# after\nnew_signal = service.create_signal({**payload, \"stock_code\": old[\"stock_code\"], \"market\": old[\"market\"], \"action\": old[\"action\"]})\n# keep audit trail: the terminal row stays terminal, the new row carries the reactivated decision","handlingStrategy":"validation","validationCode":"existing = service.get_signal(signal_id)\nfrom src.services.decision_signal_service import TERMINAL_STATUSES\nif status == 'active' and (existing['status'] in TERMINAL_STATUSES or is_expired(existing.get('expires_at'))):\n    raise RuntimeError('cannot reactivate; create a new signal instead')","typeGuard":"def can_transition_to(current_status: str, expires_at, target: str) -> bool:\n    if target != 'active':\n        return True\n    return current_status not in TERMINAL_STATUSES and not expired(expires_at)","tryCatchPattern":"try:\n    service.update_signal_status(sid, status='active')\nexcept ValueError as exc:\n    if 'cannot be reactivated' in str(exc):\n        new = service.create_signal(rebuild_payload_from(old_signal))  # new row instead\n    else:\n        raise","preventionTips":["Model reactivation as create-new-signal, never as status flip.","Make retry pipelines idempotent: a 'reactivate' request on a terminal row should create or no-op, not error.","Watch expires_at defaults so active signals don't silently lapse into terminal-expired state."],"tags":["decision-signal","lifecycle","validation","state-machine"],"backgroundTag":null,"analyzedSha":"5159bd72e8373d215492dff122acc9d389e219c9","analyzedAt":"2026-08-15T01:59:36.292Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}