mvanhorn/last30days-skill · error · HandoffContractError

Nominations bundle {path} must carry a top-level "nomination

Error message

Nominations bundle {path} must carry a top-level "nominations" list, got {type(rows_raw).__name__}. {_RESWEEP_REMEDY}

What it means

`_parse_bundle_file` raises HandoffContractError when the bundle's top-level `nominations` value is not a JSON array. After the shared envelope checks pass, the reader requires a list of nomination rows; anything else (dict keyed by nomination id, null, string) is a corrupted pool. Per-row handling is deliberately lenient after this point, but the top-level shape is strict. Message includes the actual type name and the re-sweep remedy.

Source

Thrown at skills/last30days/scripts/lib/discovery_handoff.py:462

        kind=schema.DISCOVERY_NOMINATIONS_KIND,
        schema_version=schema.DISCOVERY_NOMINATIONS_SCHEMA_VERSION,
        remedy=_RESWEEP_REMEDY,
        missing_id_context="judgments cannot bind to it",
        stale_context="the momentum window it captured has moved on",
    )
    version = payload.get("schema_version")

    context = payload.get("context") or {}
    boundary = context.get("enrichment_source_boundary")
    requested = context.get("requested_sources")
    try:
        lookback_days = int(context.get("lookback_days") or 30)
    except (TypeError, ValueError):
        lookback_days = 30

    rows_raw = payload.get("nominations")
    if not isinstance(rows_raw, list):
        raise HandoffContractError(
            f"Nominations bundle {path} must carry a top-level "
            f"\"nominations\" list, got {type(rows_raw).__name__}. "
            f"{_RESWEEP_REMEDY}"
        )

    nominations: list[BundleNomination] = []
    for position, row in enumerate(rows_raw, start=1):
        # Lenient per row: the bundle is engine-written, but one corrupted
        # row must not discard the rest of the pool.
        if not isinstance(row, dict):
            _warn(
                f"skipping malformed nomination row {position} in "
                f"{path.name} (not an object)"
            )
            continue
        try:
            nomination = pipeline.Nomination(
                **schema.nomination_kwargs_from_dict(row.get("nomination") or {})

View on GitHub (pinned to c7460f6114)

Solutions

  1. Verify with `jq '.nominations | type' <path>` — must be 'array'.
  2. If you must filter, keep the array shape: `jq '.nominations |= map(select(.heuristic_junk != true))' file > tmp && mv tmp file` — but regeneration is safer.
  3. When in doubt, re-run `--discover --nominate-only`; the engine never writes a non-list pool, so this shape always means the file was altered.

Example fix

# before
jq '.nominations |= { (.nomination_id): . }' discover-nominations.json > tmp && mv tmp discover-nominations.json  # now an object
# after (keep array, or regenerate)
jq '.nominations |= map(select(.heuristic_junk != true))' discover-nominations.json > tmp && mv tmp discover-nominations.json
Defensive patterns

Strategy: type-guard

Validate before calling

import json
from pathlib import Path

def nominations_is_list(path: Path) -> bool:
    try:
        return isinstance(json.loads(path.read_text(encoding="utf-8")).get("nominations"), list)
    except (OSError, json.JSONDecodeError):
        return False

Type guard

def is_nomination_pool(value) -> bool:
    return isinstance(value, list) and all(isinstance(row, dict) for row in value)

Try / catch

from lib import discovery_handoff
try:
    bundle = discovery_handoff.load_nominations_bundle(save_dir=save_dir, config_dir=config_dir)
except discovery_handoff.HandoffContractError as exc:
    if '"nominations" list' in exc.message:
        # reshaped pool: re-sweep, do not repair
        ...

Prevention

When it happens

Trigger: `nominations` hand-converted to an object keyed by nomination_id; set to null or missing via a jq rewrite (`jq 'del(.nominations)'`); a partial rewrite that replaced the list with a placeholder string.

Common situations: Editing bundles to remove unwanted nominations by restructuring instead of filtering the array in place; model-assisted 'cleanup' of the file; sync/merge damage to that key.

Related errors


AI-assisted analysis of mvanhorn/last30days-skill@c7460f6114 (2026-08-15). Data as JSON: /api/errors/d2d403e65e068d71. Report an issue: GitHub.