{"record":{"id":"8a177713eb9e8728","repo":"bytedance/deer-flow","slug":"change-set-deleterevisions-must-be-an-object","errorCode":null,"errorMessage":"change_set.deleteRevisions must be an object","messagePattern":"change_set\\.deleteRevisions must be an object","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py","lineNumber":1253,"sourceCode":"        \"\"\"Commit an incremental change set and return only the applied delta.\n\n        ``complete`` is deliberately false: callers that require the historical\n        full document must explicitly call ``load``.  This prevents a fresh\n        process from presenting a one-fact cache snapshot as the whole agent\n        memory while keeping the mutation path free of full fact scans.\n        \"\"\"\n        has_fact_changes = bool(change_set.get(\"upserts\") or change_set.get(\"deletes\"))\n        if has_fact_changes and agent_name is None:\n            raise ValueError(\"agent_name is required for fact repository changes\")\n        summaries = change_set.get(\"summaries\")\n        upserts = copy.deepcopy(change_set.get(\"upserts\", []))\n        deletes = change_set.get(\"deletes\", [])\n        delete_revisions = change_set.get(\"deleteRevisions\")\n        upsert_revisions = change_set.get(\"upsertRevisions\")\n        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)","sourceCodeStart":1235,"sourceCodeEnd":1271,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py#L1235-L1271","documentation":"apply_changes() validates that change_set['deleteRevisions'], when provided (not None), is a dict mapping fact id -> expected revision. It powers optimistic-concurrency checks for deletes and the safe-delete-rebase decision; a non-dict value cannot be interpreted and is rejected before any lock is taken.","triggerScenarios":"Passing deleteRevisions as a list of ids, a JSON string, or a dict serialized into another dict shape; forwarding client JSON where the field arrived as a list of [id, rev] pairs.","commonSituations":"Hand-building change sets from UI state; a client that sends revision metadata as an array; schema drift after renaming/restructuring the field.","solutions":["Pass {'fact_id': revision_int} or omit/deleteRevisions=None when no per-delete revisions are tracked.","Convert list-of-pairs to a dict at the boundary: dict(pairs).","Validate the change_set with a pydantic model before calling apply_changes."],"exampleFix":"# before\nstorage.apply_changes({\"deletes\": ids, \"deleteRevisions\": [123, 124]}, agent_name=a)\n\n# after\nstorage.apply_changes({\"deletes\": ids, \"deleteRevisions\": {fid: 123 for fid in ids}}, agent_name=a)","handlingStrategy":"type-guard","validationCode":"revs = change_set.get(\"deleteRevisions\")\nif revs is not None and not isinstance(revs, dict):\n    change_set[\"deleteRevisions\"] = dict(revs)  # or reject: raise HTTPException(400, ...)","typeGuard":"def is_revision_map(value: object) -> TypeGuard[dict[str, int | None]]:\n    return isinstance(value, dict) and all(\n        isinstance(v, (int,)) and not isinstance(v, bool) or v is None\n        for v in value.values()\n    )","tryCatchPattern":null,"preventionTips":["Build revision maps as {fact_id: revision} dicts, never arrays of pairs.","Validate change_set shape once at the boundary with a schema.","Keep deleteRevisions keys in sync with the deletes list."],"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"}