affaan-m/ECC · error · ClaimError

approved text must be valid UTF-8 text

Error message

approved text must be valid UTF-8 text

What it means

The stored draft_text in the approval snapshot must be valid UTF-8-encodable text; the library hashes it as UTF-8 bytes to compare against the stored SHA-256. If the value is not a string (AttributeError on .encode) or contains unencodable content (UnicodeError), it raises this error rather than proceeding with an unverifiable snapshot.

Solutions

  1. Ensure the trusted writer stores draft_text as a Python str (TEXT column)
  2. Decode/re-encode the stored value correctly before the approval flow writes it: text = blob.decode('utf-8')
  3. Inspect the offending row's typeof(draft_text) in SQLite to confirm it is 'text', not 'blob' or 'null'
  4. Re-record the snapshot through the trusted decision writer after fixing the storage layer

Example fix

// before
db.execute('INSERT INTO approval_bound_drafts ... VALUES (?,?)', (oid, did, draft_bytes))  # BLOB

// after
db.execute('INSERT INTO approval_bound_drafts ... VALUES (?,?)', (oid, did, draft_bytes.decode('utf-8')))
Defensive patterns

Strategy: validation

Validate before calling

def draft_text_ok(db, oid, did) -> bool:
    row = db.execute('SELECT draft_text FROM approval_bound_drafts WHERE obligation_id=? AND decision_id=?',
                     (oid, did)).fetchone()
    if not isinstance(row['draft_text'], str):
        return False
    try:
        row['draft_text'].encode('utf-8')
        return True
    except UnicodeError:
        return False

Type guard

def is_utf8_text(v) -> bool:
    return isinstance(v, str) and (lambda: (v.encode('utf-8'), True)[1])() if not any(ord(c) >= 0xD800 and ord(c) <= 0xDFFF for c in v) else False

Try / catch

try:
    token = claim(db, oid, did, now=ts)
except ClaimError as e:
    if 'valid UTF-8 text' in str(e):
        re_record_snapshot_via_trusted_writer(oid, did)
    else:
        raise

Prevention

When it happens

Trigger: draft_text stored as bytes, None, or another non-str type in approval_bound_drafts; a surrogate-containing or otherwise invalid text value that fails .encode('utf-8').

Common situations: Writing the draft via raw sqlite3 with a BLOB column or bytes value; a different writer/tool storing decoded-with-errors text containing lone surrogates; schema drift where draft_text's declared type changed.

Understand the failure class

Background: "is not a compatible type" / "cannot merge" errors: when a value's type doesn't match what the library requires — this error's family across 65 libraries.

Related errors


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

Appendix: source

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

        db.execute('BEGIN IMMEDIATE')
        yield
        db.commit()
    except BaseException as error:
        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

View on GitHub (pinned to 8321021c54)