{"record":{"id":"6106bc51314a4cb0","repo":"mvanhorn/last30days-skill","slug":"angles-file-path-must-carry-a-top-level-angles","errorCode":null,"errorMessage":"Angles file {path} must carry a top-level \\\"angles\\\" list.","messagePattern":"Angles file (.+?) must carry a top-level \\\\\"angles\\\\\" list\\.","errorType":"exception","errorClass":"HandoffContractError","httpStatus":null,"severity":"error","filePath":"skills/last30days/scripts/lib/discovery_handoff.py","lineNumber":818,"sourceCode":"    \"\"\"Read the host angles file for leg 3, keyed by nomination id.\n\n    ``bundle`` is the binding target: the finalize leg passes the pending\n    report (the bundle_id echo validates against it, and the known ids are\n    its surviving ``angle_inputs`` ids), while a NominationsBundle binds\n    against the full pool. A missing angles file is legal: ``path=None``\n    returns an empty mapping and every topic ships without angles. When a\n    path is given the same strict-top-level / lenient-per-row rules as\n    judgments apply; angle sentences are word-boundary capped at 200 chars.\n    \"\"\"\n    if path is None:\n        return {}\n    payload = _load_host_file(path, \"angles\")\n    _require_bundle_binding(\n        payload, bundle, label=\"angles\", save_dir=save_dir, config_dir=config_dir,\n    )\n    rows = payload.get(\"angles\")\n    if not isinstance(rows, list):\n        raise HandoffContractError(\n            f\"Angles file {path} must carry a top-level \\\"angles\\\" list.\"\n        )\n    known = (\n        set(bundle.angle_inputs)\n        if isinstance(bundle, PendingReport)\n        else {entry.nomination_id for entry in bundle.nominations}\n    )\n    angles: dict[str, HostAngles] = {}\n    for row_id, row in _known_rows(\n        rows, known, row_label=\"angles\", unknown_label=\"angles\"\n    ):\n        podcast = _sanitized_angle(row.get(\"podcast\"))\n        x_article = _sanitized_angle(row.get(\"x_article\"))\n        if podcast is None and x_article is None:\n            # No usable hook at all: treat the row as absent.\n            continue\n        angles[row_id] = HostAngles(podcast=podcast, x_article=x_article)\n    return angles","sourceCodeStart":800,"sourceCodeEnd":836,"githubUrl":"https://github.com/mvanhorn/last30days-skill/blob/c7460f6114449ddfe6ea3fc2f23c3d910c0e740c/skills/last30days/scripts/lib/discovery_handoff.py#L800-L836","documentation":"Raised by the angles loader in discovery_handoff.py when an angles file is supplied (path is not None), passes the top-level-object and bundle-binding checks, but its 'angles' key is missing or not a JSON list. Passing path=None is the supported way to run without angles (empty mapping, topics ship without angles) — only a supplied file with the wrong shape raises. Per-row handling is lenient, mirroring the judgments loader; angle sentences are word-boundary capped at 200 chars downstream.","triggerScenarios":"Passing an angles file with key 'angle' (singular), 'suggestions', or omitting the key; \"angles\": {} instead of []; reusing a judgments file as the angles file (it has 'judgments', not 'angles').","commonSituations":"The host writes the file from memory of a similar schema; singular/plural confusion; the file was templated from the judgments example.","solutions":["Use \"angles\": [...] as the top-level list key in the angles file.","If you do not want angles for this leg, pass path=None / omit the angles file instead of shipping an empty wrong-shaped file.","Verify the file loads and has the key: assert isinstance(json.load(open(p)).get(\"angles\"), list)."],"exampleFix":"// before\n{\n  \"bundle_id\": \"...\",\n  \"angle\": [{\"nomination_id\": \"n1\", \"podcast\": \"...\"}]\n}\n\n// after\n{\n  \"bundle_id\": \"...\",\n  \"angles\": [{\"nomination_id\": \"n1\", \"podcast\": \"...\"}]\n}","handlingStrategy":"validation","validationCode":"import json\n\ndef valid_angles_file(path: str) -> bool:\n    payload = json.loads(open(path, encoding=\"utf-8\").read())\n    return isinstance(payload, dict) and isinstance(payload.get(\"angles\"), list)","typeGuard":null,"tryCatchPattern":"try:\n    load_angles(path, bundle, ...)\nexcept HandoffContractError as e:\n    # fix the \"angles\" key on the host side; or pass path=None to skip angles entirely\n    ...","preventionTips":["If angles are optional for your leg, pass path=None instead of shipping a placeholder file.","Note the plural key 'angles' (parallel to 'judgments').","Reuse the same file-building helper for both judgments and angles with the key as a parameter."],"tags":["json","contract","discovery-handoff","schema"],"backgroundTag":null,"analyzedSha":"c7460f6114449ddfe6ea3fc2f23c3d910c0e740c","analyzedAt":"2026-08-15T03:34:49.540Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}