affaan-m/ECC · error · ClaimError

approved hash must be lowercase SHA-256 hexadecimal

Error message

approved hash must be lowercase SHA-256 hexadecimal

What it means

Each approval snapshot stores the expected SHA-256 of the draft in draft_sha256, and this library requires it to be exactly a 64-character lowercase hex string. Malformed hashes mean the stored approval metadata cannot be trusted, so the library refuses to verify or dispatch.

Solutions

  1. Store hashlib.sha256(draft_text.encode('utf-8')).hexdigest() (lowercase, 64 chars) when writing the snapshot
  2. Normalize an existing wrong hash: recompute it from draft_text and UPDATE the row
  3. Check the row: SELECT draft_sha256 FROM approval_bound_drafts ... and validate len==64 and all chars in 0-9a-f before calling
  4. Fix the writing tool/migration to always produce lowercase SHA-256 hex

Example fix

// before
digest = hashlib.sha256(text.encode()).hexdigest().upper()
db.execute('UPDATE approval_bound_drafts SET draft_sha256=? ...', (digest,))

// after
digest = hashlib.sha256(text.encode('utf-8')).hexdigest()  # lowercase 64-hex
db.execute('UPDATE approval_bound_drafts SET draft_sha256=? ...', (digest,))
Defensive patterns

Strategy: validation

Validate before calling

import re
HEX64 = re.compile(r'^[0-9a-f]{64}$')

def hash_ok(db, oid, did) -> bool:
    row = db.execute('SELECT draft_sha256 FROM approval_bound_drafts WHERE obligation_id=? AND decision_id=?',
                     (oid, did)).fetchone()
    return isinstance(row['draft_sha256'], str) and HEX64.fullmatch(row['draft_sha256']) is not None

Type guard

def is_sha256_hex(v) -> bool:
    return isinstance(v, str) and len(v) == 64 and all(c in '0123456789abcdef' for c in v)

Try / catch

try:
    token = claim(db, oid, did, now=ts)
except ClaimError as e:
    if 'lowercase SHA-256 hexadecimal' in str(e):
        fix_hash_from_text(db, oid, did)  # recompute and UPDATE via trusted writer
        token = claim(db, oid, did, now=ts)
    else:
        raise

Prevention

When it happens

Trigger: draft_sha256 stored as None, an int, an uppercase-hex digest, a digest with '0x' prefix, or any string whose length != 64 or containing non-[0-9a-f] characters; calling claim()/begin_dispatch() against such a row.

Common situations: A custom writer using hexdigest().upper() or hashing with SHA-1/MD5 (40/32 chars); storing the hash as bytes or a BLOB; hand-edited rows or a schema migration that reformatted the column.

Understand the failure class

Background: "Invalid ... format", "must be in format X", "does not look like a ..." — invalid argument format errors across CLI tools and libraries — this error's family across 17 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/6833d0b294dc4ab2. Report an issue: GitHub.

Appendix: source

Thrown at skills/operator-approval-loop/references/approval_claims.py:63

        db.rollback()
        if isinstance(error, sqlite3.Error):
            raise ClaimError('claim transaction failed; no permission granted') from error
        raise


def _snapshot(db, obligation_id, decision_id):
    row = db.execute('''SELECT * FROM approval_bound_drafts
        WHERE obligation_id=? AND decision_id=?''', (obligation_id, decision_id)).fetchone()
    if row is None:
        raise ClaimError('a current bound approved draft is required')
    try:
        digest = hashlib.sha256(row['draft_text'].encode('utf-8')).hexdigest()
    except (AttributeError, UnicodeError) as error:
        raise ClaimError('approved text must be valid UTF-8 text') from error
    stored_digest = row['draft_sha256']
    if (not isinstance(stored_digest, str) or len(stored_digest) != 64
            or any(character not in '0123456789abcdef' for character in stored_digest)):
        raise ClaimError('approved hash must be lowercase SHA-256 hexadecimal')
    if not secrets.compare_digest(digest, stored_digest):
        raise ClaimError('approved text hash does not match')
    return dict(row)


def _claim_row(db, token):
    if not isinstance(token, str) or not token:
        raise ClaimError('a claim token is required')
    row = db.execute('SELECT * FROM obligation_delivery_claims WHERE token=?', (token,)).fetchone()
    if row is None:
        raise ClaimError('unknown claim token')
    return row


def claim(db, obligation_id, decision_id, *, now):
    """Reserve one already-authorized decision; return only a random claim token."""
    with _transaction(db, now):
        _snapshot(db, obligation_id, decision_id)

View on GitHub (pinned to 8321021c54)