{"record":{"id":"e46e3e6ae7ee1e7c","repo":"bmad-code-org/BMAD-METHOD","slug":"development-status-mismatch-after-write","errorCode":null,"errorMessage":"development_status mismatch after write","messagePattern":"development_status mismatch after write","errorType":"exception","errorClass":"ValueError","httpStatus":null,"severity":"error","filePath":"src/bmm-skills/plan/bmad-sprint-planning/scripts/sprint_plan.py","lineNumber":454,"sourceCode":"        \"counts\": _counts(dev),\n        \"generated\": generated,\n        \"last_updated\": args.date,\n        \"warnings\": warnings,\n        **report,\n    }\n\n    if args.dry_run:\n        print(json.dumps(result, default=str))\n        return\n\n    try:\n        payload = _dump_bytes(yaml, doc)\n        _atomic_write(args.status_file, payload, original_mode)\n        verify_yaml = _make_yaml()\n        with io.open(args.status_file, \"r\", encoding=\"utf-8\") as fh:\n            reread = verify_yaml.load(fh)\n        if dict(reread.get(\"development_status\") or {}) != {k: v for k, v in dev.items()}:\n            raise ValueError(\"development_status mismatch after write\")\n        for field in (\"generated\", \"last_updated\", \"project\"):\n            if str(reread.get(field)) != str(doc[field]):\n                raise ValueError(f\"{field} mismatch after write\")\n    except Exception as exc:\n        if original_bytes is not None:\n            try:\n                _atomic_write(args.status_file, original_bytes, original_mode)\n                restored = True\n            except Exception:\n                restored = False\n        else:\n            Path(args.status_file).unlink(missing_ok=True)\n            restored = True\n        _fail(f\"write or validation failed, original {'restored' if restored else 'NOT restored'}: {exc}\",\n              restored=restored)\n    print(json.dumps(result, default=str))\n\n","sourceCodeStart":436,"sourceCodeEnd":472,"githubUrl":"https://github.com/bmad-code-org/BMAD-METHOD/blob/b70486b9bdcb0a404d329e2a763b57964e7f1360/src/bmm-skills/plan/bmad-sprint-planning/scripts/sprint_plan.py#L436-L472","documentation":"Raised by sprint_plan.py after it atomically writes the status YAML and immediately re-reads it to verify integrity. The `development_status` mapping that comes back must equal the `dev` dict that was written. It fires only when YAML serialization is not idempotent for the keys/values in `development_status` — i.e. a key or value that the emitter prints unquoted but the parser coerces back into a different Python type (bool, null, int, float, date). On the raise, the surrounding handler restores the original file bytes (or deletes a newly-created file) and calls `_fail`, so the on-disk file is left intact.","triggerScenarios":"A story/epic identifier or a status string in `development_status` collides with a YAML scalar word: `true`, `false`, `yes`, `no`, `on`, `off`, `null`, `~`, a bare integer like `1`, or an ISO date `2026-08-12`. The dumper writes the key bare, the loader returns a bool/None/int/date, and `dict(reread...)` differs from the all-string `dev` dict. Also triggered by NaN/Inf floats, or by keys containing characters that round-trip through a different type.","commonSituations":"A team names a story `E-true` or `S-yes`; a status value is set to the literal string `null` for 'not started'; a tracking system returns numeric IDs that get stored as the dev key. Upgrading ruamel.yaml/PyYAML can also widen the set of words it coerces, suddenly failing builds on a file that wrote fine before.","solutions":["Inspect the failed status_file and look for any `development_status` key or value that is a YAML-reserved word or bare number/date; rename it (e.g. `yes` -> `yes-story`, `null` -> `unstarted`) or quote it at the source.","Run with `--dry-run` to print the generated document as JSON and confirm every key/value is a plain string before the write is attempted.","If the collision is unavoidable, patch the generator to coerce all `development_status` keys and values to str and configure the YAML emitter to force-quote keys (default_flow_style=False plus a custom representer that emits strings with style='\"').","Check that no two stories resolve to the same key after YAML normalization (a bool and a string collapsing onto one key also produces inequality)."],"exampleFix":"# before\n[development_status]\ntrue = \"done\"        # key 'true' round-trips to Python bool True\n2026-08-12 = \"todo\"  # key parses as a date\n\n# after: keep identifiers as unambiguous strings\n[development_status]\n\"E-true\" = \"done\"\n\"S-2026-08-12\" = \"todo\"\n\n# or, in the generator, force string keys/values before writing:\ndoc[\"development_status\"] = {str(k): str(v) for k, v in dev.items()}","handlingStrategy":"validation","validationCode":"# Before writing, assert every development_status key/value is a plain\n# string that round-trips through YAML without type coercion.\nimport yaml\nRESERVED = {\"true\",\"false\",\"yes\",\"no\",\"on\",\"off\",\"null\",\"none\",\"~\",\"\"}\ndef dev_keys_roundtrip(dev: dict) -> bool:\n    for k, v in dev.items():\n        if not isinstance(k, str) or not isinstance(v, str):\n            return False\n        if k.lower() in RESERVED or k.isdigit():\n            return False\n        # confirm symmetric load/dump\n        if yaml.safe_load(yaml.safe_dump({k: v})) != {k: v}:\n            return False\n    return True\n\nif not dev_keys_roundtrip(dev):\n    raise SystemExit(\"refusing to write: development_status keys would not round-trip\")","typeGuard":"def is_plain_str_map(d: object) -> bool:\n    return isinstance(d, dict) and all(\n        isinstance(k, str) and isinstance(v, str) and k.strip()\n        and k.lower() not in {\"true\",\"false\",\"yes\",\"no\",\"on\",\"off\",\"null\",\"~\"}\n        for k, v in d.items()\n    )","tryCatchPattern":"# The script already restores the original on failure; as a caller, treat a\n# non-zero exit as fatal and inspect the status_file for YAML-reserved keys.\nimport subprocess\nres = subprocess.run([\"python\",\"sprint_plan.py\",...])\nif res.returncode != 0:\n    log.error(res.stderr.decode())  # message says whether original was restored\n    raise","preventionTips":["Restrict story/epic identifiers to unambiguous strings: letters, digits, hyphens, but never bare YAML-reserved words.","Always coerce dev keys/values to str before building the doc.","Run with --dry-run first; it skips the write and prints the JSON, exposing risky keys.","Pin the YAML library version so the set of coerced words does not change under you."],"tags":["yaml","data-integrity","round-trip","sprint-planning","config"],"backgroundTag":null,"analyzedSha":"b70486b9bdcb0a404d329e2a763b57964e7f1360","analyzedAt":"2026-08-13T01:21:12.247Z","schemaVersion":2},"datasetVersion":"2026-08-13T04:17:16.726Z"}