{"record":{"id":"15ee53da0bfdc225","repo":"bytedance/deer-flow","slug":"change-set-upserts-and-change-set-deletes-must-be","errorCode":null,"errorMessage":"change_set.upserts and change_set.deletes must be lists","messagePattern":"change_set\\.upserts and change_set\\.deletes must be lists","errorType":"validation","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py","lineNumber":1251,"sourceCode":"        allow_manifest_rebase: bool = False,\n    ) -> dict[str, Any]:\n        \"\"\"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","sourceCodeStart":1233,"sourceCodeEnd":1269,"githubUrl":"https://github.com/bytedance/deer-flow/blob/1dd6ba1acb03700589994b0366c5d1c7d05e2eff/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/storage.py#L1233-L1269","documentation":"apply_changes() requires change_set['upserts'] and change_set['deletes'] to be lists (empty or absent defaults to []). This is the structural gate before per-element validation; anything non-list (string, dict, null explicitly set, int) is rejected without touching storage.","triggerScenarios":"apply_changes({'upserts': fact_dict}) (single dict instead of [fact_dict]); {'upserts': None}; a JSON payload where upserts was serialized as an object keyed by id; deletes given as a comma-joined string.","commonSituations":"Wrapping/unwrapping bugs when forwarding JSON from an HTTP or MCP boundary; clients treating upserts as optional-null instead of omitting it; converting between dict-based and list-based fact representations.","solutions":["Wrap single facts in a list and pass deletes as a list of id strings.","Omit empty keys instead of setting them to null: {'upserts': [fact]} not {'upserts': [fact], 'deletes': None}.","Add a schema check (pydantic model or manual isinstance) at the boundary that produces change sets."],"exampleFix":"# before\nstorage.apply_changes({\"upserts\": fact, \"deletes\": None}, agent_name=a)\n\n# after\nstorage.apply_changes({\"upserts\": [fact]}, agent_name=a)","handlingStrategy":"type-guard","validationCode":"upserts = change_set.get(\"upserts\", [])\ndeletes = change_set.get(\"deletes\", [])\nif not isinstance(upserts, list) or not isinstance(deletes, list):\n    raise HTTPException(400, \"upserts/deletes must be lists\")","typeGuard":"from typing import TypeGuard\n\ndef is_change_set(cs: object) -> TypeGuard[dict[str, Any]]:\n    return (\n        isinstance(cs, dict)\n        and isinstance(cs.get(\"upserts\", []), list)\n        and isinstance(cs.get(\"deletes\", []), list)\n    )","tryCatchPattern":null,"preventionTips":["Omit empty keys instead of setting them to null.","Wrap single facts in a list at construction time.","Parse inbound change sets with a pydantic model at the API boundary."],"tags":["memory","validation","api-contract","deermem"],"backgroundTag":null,"analyzedSha":"1dd6ba1acb03700589994b0366c5d1c7d05e2eff","analyzedAt":"2026-08-14T21:20:34.804Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}