Skip to content

symfonic.memory.backends.postgres_erase_receipts

postgres_erase_receipts

Completed erase receipts and the claim keyset index (CORE-199 S2/S3).

A finished job leaves one row here and nothing else: its id and a digest of its tenant and prefix. The prefix itself, which names every level of the erased subtree, is not retained; the CHECK refuses anything but a lowercase SHA-256 hex digest in that column. The receipt lets an operator who resumes by job id learn that the job finished, and lets an audit holding the key prove which subtree it erased.

The digest is HMAC-SHA256 under the operator's receipt key, never a bare hash: tenant ids and scope levels are short and guessable, so anyone who can read receipts could otherwise confirm a guessed subtree offline. The key is supplied explicitly to the eraser and never derived from any id. A legacy receipt does not record which key made it: after rotation it verifies only under the key in force at completion. Opt-in permanent retirement uses postgres_retirement_receipt and the CORE-428 migration instead: a versioned digest binds job, root, stable graph identity and external anchor identity to the signing key ID pinned at initiation. Legacy rows are never relabelled.

The chunk driver walks the subtree's claims by (scope_path, token) after a cursor persisted on the job row. The owner range index alone cannot seek a token inside one scope_path, and a node rewritten forty thousand times keeps all its generations in one scope_path; restarting from the prefix instead revisits every dead index entry earlier chunks left behind while any old snapshot keeps them from being pruned, which is quadratic.

Both objects come from scripts/migrations/core199_erase_chunks.sql, never from application startup. They sit beside memory_nodes, like the job table, and are resolved in that schema.

eraser_tables async

eraser_tables(conn: Any) -> tuple[str, str]

Schema-qualified receipts and claims beside memory_nodes, keyset index sound.

Source code in src/symfonic/memory/backends/postgres_erase_receipts.py
async def eraser_tables(conn: Any) -> tuple[str, str]:
    """Schema-qualified receipts and claims beside memory_nodes, keyset index sound."""
    row = await conn.fetchval(_RESOLVE)
    receipts, indexed, claims = row if row is not None else (None, None, None)
    if receipts is None or claims is None or indexed is not True:
        raise MemoryContractError(MISSING)
    return receipts, claims

no_access_drift async

no_access_drift(conn: Any, role: str) -> str | None

Runtime roles hold nothing on receipts: only the eraser writes them.

Source code in src/symfonic/memory/backends/postgres_erase_receipts.py
async def no_access_drift(conn: Any, role: str) -> str | None:
    """Runtime roles hold nothing on receipts: only the eraser writes them."""
    receipts = await conn.fetchval(
        "SELECT format('%I.%I', n.nspname, r.relname) FROM pg_class c "
        "JOIN pg_class r ON r.relnamespace=c.relnamespace AND r.relname=$1 "
        "JOIN pg_namespace n ON n.oid=r.relnamespace "
        "WHERE c.oid=to_regclass('memory_nodes')",
        RECEIPTS_TABLE,
    )
    if receipts is None:
        return None
    held = await conn.fetchval(
        "SELECT p FROM unnest(ARRAY['SELECT','INSERT','UPDATE','DELETE','TRUNCATE',"
        "'REFERENCES','TRIGGER']) AS p WHERE CASE WHEN p IN ('SELECT','INSERT','UPDATE',"
        "'REFERENCES') THEN has_any_column_privilege($1::name, $2::regclass, p) "
        "ELSE has_table_privilege($1::name, $2::regclass, p) END LIMIT 1",
        role,
        receipts,
    )
    return None if held is None else f"{role} holds {held} on the erase receipts {receipts}"

prefix_digest

prefix_digest(key: bytes, tenant_id: str, prefix: str) -> str

The only trace a completed job keeps of the subtree it erased.

Source code in src/symfonic/memory/backends/postgres_erase_receipts.py
def prefix_digest(key: bytes, tenant_id: str, prefix: str) -> str:
    """The only trace a completed job keeps of the subtree it erased."""
    message = _DOMAIN + f"{tenant_id}\x00{prefix}".encode()
    return hmac.new(key, message, hashlib.sha256).hexdigest()

require_receipt_key

require_receipt_key(key: Any) -> bytes

The operator's receipt key: at least 32 random bytes, or refuse.

Source code in src/symfonic/memory/backends/postgres_erase_receipts.py
def require_receipt_key(key: Any) -> bytes:
    """The operator's receipt key: at least 32 random bytes, or refuse."""
    if type(key) is not bytes or len(key) < RECEIPT_KEY_BYTES:
        raise ValueError(
            f"the native eraser needs an explicit receipt key of at least "
            f"{RECEIPT_KEY_BYTES} bytes (CORE-199)"
        )
    return key