{"record":{"id":"ae5aea308607f844","repo":"bytedance/deer-flow","slug":"change-set-upsertrevisions-must-be-an-object","errorCode":null,"errorMessage":"change_set.upsertRevisions must be an object","messagePattern":"change_set\\.upsertRevisions 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":1255,"sourceCode":"        ``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)\n        key = self._cache_key(agent_name, user_id=user_id)\n        expected = expected_manifest_revision","sourceCodeStart":1237,"sourceCodeEnd":1273,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py#L1237-L1273","documentation":"apply_changes() validates that change_set['upsertRevisions'], when provided, is a dict mapping fact id -> expected revision (int >= 1 or null). Like deleteRevisions it feeds optimistic-concurrency checks; a non-dict is rejected up front. Note upsert_fact() builds this dict internally, so this fires only on direct apply_changes calls.","triggerScenarios":"Direct apply_changes with upsertRevisions as a list, string, or number; passing expected_fact_revision (an int) straight into the change set instead of the {fact_id: rev} map.","commonSituations":"Bypassing the upsert_fact convenience wrapper without replicating its {'id': revision} shape; JSON clients serializing the map as an array; copy-paste from deleteRevisions handling.","solutions":["Use the wrapper storage.upsert_fact(fact, agent_name=..., expected_fact_revision=rev) for single-fact upserts.","For batch calls, build {'<fact-id>': revision_or_None} per upserted fact.","Omit the key entirely when you have no expected revisions."],"exampleFix":"# before\nstorage.apply_changes({\"upserts\": facts, \"upsertRevisions\": 7}, agent_name=a)\n\n# after\nstorage.apply_changes({\"upserts\": facts, \"upsertRevisions\": {f[\"id\"]: None for f in facts}}, agent_name=a)","handlingStrategy":"type-guard","validationCode":"revs = change_set.get(\"upsertRevisions\")\nif revs is not None and not isinstance(revs, dict):\n    raise HTTPException(400, \"upsertRevisions must be an object\")\n# or simply use the wrapper for single facts:\nstorage.upsert_fact(fact, agent_name=a, expected_fact_revision=rev)","typeGuard":"def is_upsert_revision_map(value: object) -> TypeGuard[dict[str, int | None]]:\n    return isinstance(value, dict) and all(\n        v is None or (isinstance(v, int) and not isinstance(v, bool) and v >= 1)\n        for v in value.values()\n    )","tryCatchPattern":null,"preventionTips":["Prefer upsert_fact()/delete_fact() wrappers; they construct the revision maps for you.","Never pass a bare int revision into upsertRevisions.","Omit upsertRevisions when no optimistic check is needed."],"tags":["memory","validation","optimistic-concurrency","deermem"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}