Skip to content

symfonic.memory.backends.postgres_eraser_roles

postgres_eraser_roles

The eraser principal's role contract (CORE-199 D6).

A large forget's job is created, advanced and completed by a separate login, never by the turn runtime, the staged writer or the publisher: those read the job table only, so none of them can fence a live subtree or reopen one still being erased. This role holds exactly what the eraser's SQL needs:

  • memory_erase_jobs: SELECT, INSERT, DELETE, and UPDATE on the two cursor columns only, plus the phase and content cursor columns once core199_erase_content.sql added them (re-run install after it). It can never rewrite a job's tenant or prefix. CORE-426 adds permanent/state UPDATE after opt-in migration; its guard prevents reopening retirement.
  • memory_erase_receipts: SELECT and INSERT; a receipt is never rewritten.
  • memory_nodes, memory_edges, memory_workset_progress, memory_pending_claims: SELECT and DELETE. DELETE is also what lets it take the sweep's EXCLUSIVE lock. It cannot INSERT or UPDATE content.
  • memory_erase_generations (CORE-199): SELECT, INSERT and UPDATE, since its first transaction advances the erased subtree's generation. The table's forward-only trigger, not the grant, keeps it from lowering or deleting a generation and so reopening a stale turn. The table is required (CORE-171): an eraser that could not advance it is not D6.

install runs as the table owner, after all three migrations. require_eraser runs on the eraser's own connection at startup and refuses any drift, missing or excess, as well as a login that could assume another role, run DDL, bypass row security or own the tables. No SECURITY DEFINER function is involved: every privilege is a plain, auditable grant.

EraserNotReady

Bases: RuntimeError

The eraser login or its grants violate the D6 contract.

install async

install(conn: Any, *, eraser: str) -> None

Owner side, one transaction: reset eraser to exactly D6, then attest it.

Source code in src/symfonic/memory/backends/postgres_eraser_roles.py
async def install(conn: Any, *, eraser: str) -> None:
    """Owner side, one transaction: reset ``eraser`` to exactly D6, then attest it."""
    if type(eraser) is not str or not _NAME.fullmatch(eraser) or eraser.startswith("pg_"):
        raise EraserNotReady("eraser role must be a plain lowercase identifier")
    async with conn.transaction():
        if await conn.fetchval("SELECT current_user=$1::name", eraser):
            raise EraserNotReady("the owner cannot be the eraser")
        tables = await _schema_tables(conn)
        schema = tables[JOBS].rsplit(".", 1)[0]
        await conn.execute(f"GRANT USAGE ON SCHEMA {schema} TO {eraser}")
        for table, target in tables.items():
            await conn.execute(f"REVOKE ALL ON {target} FROM {eraser}")
            await conn.execute(f"GRANT {', '.join(GRANTS[table])} ON {target} TO {eraser}")
        present = await conn.fetchval(
            "SELECT array_agg(attname::text) FROM pg_attribute WHERE attrelid=$1::regclass "
            "AND attname=ANY($2::text[]) AND NOT attisdropped",
            tables[JOBS], list(CONTENT_COLUMNS + RETIREMENT_COLUMNS),
        )
        optional = CONTENT_COLUMNS + (RETIREMENT_COLUMNS if "permanent" in (present or ()) else ())
        columns = CURSOR_COLUMNS + tuple(c for c in optional if c in (present or ()))
        await conn.execute(f"GRANT UPDATE ({', '.join(columns)}) ON {tables[JOBS]} TO {eraser}")
        row = await conn.fetchrow(_SOUND, eraser)
        if row is None or any(row[field] for field in ("elevated", "member", "ddl", "owner")):
            raise EraserNotReady("eraser must be an existing unprivileged login owning nothing")

require_eraser async

require_eraser(conn: Any) -> None

Eraser side, read-only: refuse startup unless this login holds exactly D6.

Source code in src/symfonic/memory/backends/postgres_eraser_roles.py
async def require_eraser(conn: Any) -> None:
    """Eraser side, read-only: refuse startup unless this login holds exactly D6."""
    if not await conn.fetchval("SELECT session_user=current_user"):
        raise EraserNotReady("eraser must not run under SET ROLE")
    role = await conn.fetchval("SELECT current_user")
    row = await conn.fetchrow(_SOUND, role)
    for field, reason in (
        ("elevated", "eraser is privileged"),
        ("member", "eraser is a member of another role"),
        ("ddl", "eraser may create schemas or objects"),
        ("owner", "eraser owns relations"),
    ):
        if row is None or row[field]:
            raise EraserNotReady(reason)
    for table, target in (await _schema_tables(conn)).items():
        for privilege in _PRIVILEGES:
            await _require_privilege(conn, role, table, target, privilege)
            # A column-level grant option is invisible to has_table_privilege.
            if await conn.fetchval(
                HELD, role, target, f"{privilege} WITH GRANT OPTION", True, list(COLUMN_KINDS)
            ):
                raise EraserNotReady(f"eraser may grant {privilege} on {table}")