{"record":{"id":"14031506a6f75439","repo":"bytedance/deer-flow","slug":"change-set-upsertrevisions-values-must-be-null-or","errorCode":null,"errorMessage":"change_set.upsertRevisions values must be null or integers >= 1","messagePattern":"change_set\\.upsertRevisions values must be null or integers >= 1","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py","lineNumber":1268,"sourceCode":"        if not isinstance(upserts, list) or not isinstance(deletes, list):\n            raise ValueError(\"change_set.upserts and change_set.deletes must be lists\")\n        if delete_revisions is not None and not isinstance(delete_revisions, dict):\n            raise ValueError(\"change_set.deleteRevisions must be an object\")\n        if upsert_revisions is not None and not isinstance(upsert_revisions, dict):\n            raise ValueError(\"change_set.upsertRevisions must be an object\")\n\n        normalized_upsert_revisions: dict[str, int | None] = {}\n        for incoming in upserts:\n            if not isinstance(incoming, dict):\n                raise ValueError(\"change_set.upserts must contain fact objects\")\n            incoming[\"id\"] = str(incoming.get(\"id\") or f\"fact_{uuid.uuid4().hex}\")\n            fact_id = incoming[\"id\"]\n            if isinstance(upsert_revisions, dict) and fact_id in upsert_revisions:\n                expected_fact_revision = upsert_revisions[fact_id]\n            else:\n                expected_fact_revision = incoming.get(\"revision\") if \"revision\" in incoming else None\n            if expected_fact_revision is not None and (isinstance(expected_fact_revision, bool) or not isinstance(expected_fact_revision, int) or expected_fact_revision < 1):\n                raise ValueError(\"change_set.upsertRevisions values must be null or integers >= 1\")\n            normalized_upsert_revisions[fact_id] = expected_fact_revision\n\n        path = self._get_memory_file_path(agent_name, user_id=user_id)\n        key = self._cache_key(agent_name, user_id=user_id)\n        expected = expected_manifest_revision\n        notifications: list[RetrievalNotification] = []\n        memory_file: dict[str, Any] | None = None\n        safe_delete_rebase = not deletes or (isinstance(delete_revisions, dict) and all(str(fact_id) in delete_revisions for fact_id in deletes))\n        safe_upsert_rebase = all(str(incoming[\"id\"]) in normalized_upsert_revisions for incoming in upserts)\n        for attempt in range(3):\n            try:\n                with self._scope_lock(key), _process_file_lock(path.parent / \".memory.lock\", float(getattr(self._config, \"file_lock_timeout_seconds\", 10))):\n                    self._recover_if_needed(path)\n                    memory_file, notifications = self._commit_changes_locked(\n                        path,\n                        user_id=user_id,\n                        agent_name=agent_name,\n                        upserts=upserts,","sourceCodeStart":1250,"sourceCodeEnd":1286,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py#L1250-L1286","documentation":"apply_changes() validates each expected fact revision: it must be None or an int >= 1. The check explicitly excludes bools (isinstance(x, bool)) because bool is a subclass of int in Python, so True would otherwise pass as revision 1. Values come from upsertRevisions[fact_id] or the fact's own 'revision' field.","triggerScenarios":"upsertRevisions with a bool (True/False), 0, negative int, float like 1.5, or numeric string '3'; or an upserted fact carrying revision: 0 / revision: True in its own body.","commonSituations":"JSON clients sending revisions as strings; using 0 as 'no revision yet' (the API expects null); flags accidentally stored in the revision field after a schema mixup.","solutions":["Use null for 'no expected revision' and integers >= 1 otherwise.","Coerce at the boundary: int(rev) after checking str/bool, and map 0 to None.","Strip stale 'revision' fields from upserted fact bodies if you do not intend an optimistic-concurrency check."],"exampleFix":"# before\nstorage.apply_changes({\"upserts\": facts, \"upsertRevisions\": {fid: \"3\" for fid in ids}}, agent_name=a)\n\n# after\nrevisions = {fid: (int(rev) if rev not in (None, 0) else None) for fid, rev in raw.items()}\nstorage.apply_changes({\"upserts\": facts, \"upsertRevisions\": revisions}, agent_name=a)","handlingStrategy":"validation","validationCode":"def norm_rev(v):\n    if v in (None, 0, \"\"):\n        return None\n    v = int(v)\n    if v < 1:\n        return None\n    return v\n\nchange_set[\"upsertRevisions\"] = {fid: norm_rev(rev) for fid, rev in change_set.get(\"upsertRevisions\", {}).items()}","typeGuard":"def is_valid_revision(v: object) -> TypeGuard[int | None]:\n    return v is None or (isinstance(v, int) and not isinstance(v, bool) and v >= 1)","tryCatchPattern":null,"preventionTips":["Remember bool is an int subclass; never store flags in revision fields.","Use null (not 0) for 'no expected revision'.","Coerce string revisions from JSON clients at the boundary."],"tags":["memory","validation","optimistic-concurrency","deermem"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}