# Rust Type Safety, Unsafe, And pgrx Boundary Plan ## Outcome pgGraph's Rust core uses domain types for graph identity, state, values, capacity, and units. Closed vocabularies are enums after one boundary conversion; raw strings, integers, PostgreSQL datums, and pointers do not flow through the engine. Safe Rust APIs cannot reach undefined behavior for any input. PostgreSQL integration uses supported pgrx facilities first, with a small reviewed raw-FFI allowlist only where pgrx has no suitable safe API. This is a correctness plan, not cosmetic cleanup. Exact value semantics, stable relation identity, safe mapped storage, transaction behavior, and security-definer hardening are prerequisites for full GQL and PostgreSQL 19 property-graph support. ## Review Baseline The 2026-07-09 review used the Rust reviewing checklist across production types, GUCs, catalog rows, GQL values, persistent segments, mmap access, unsafe blocks, PostgreSQL FFI, background workers, and SQL facades. Inventory at review time: - 54 explicit unsafe blocks: 31 in production paths and 23 in test fixtures; - 7 unsafe declarations: four mapped-store constructors, two PostgreSQL callbacks, and one direct PostgreSQL error-reporting extern block; - 44 security-definer SQL facades and no explicit pgrx `#[search_path]`; - 12 `oid::integer` and 41 `graph_id::text` source occurrences; - 178 opt-in clippy possible-truncation, sign-loss, or possible-wrap warnings; - no repository-owned `unsafe impl`, manual `Send`/`Sync`, `transmute`, or `MaybeUninit`. Current positive foundations to preserve: - SPI parameters, `connect_mut`, PostgreSQL quoting helpers, `JsonB`, temporal datums, `#[pg_guard]`, and `BackgroundWorkerBuilder` already use pgrx; - `unsafe_op_in_unsafe_fn` and undocumented-unsafe-block lints are denied; - core control flow already has useful enums such as `SyncStatus`, `ReadOnlyReason`, `SchemaState`, and `MutationKind`. The actual raw PostgreSQL call/global surface is small enough to own: `pg_class_aclcheck`, permanent xact/subxact callback registration, `MyProcPid`, `DataDir`, and the private `errstart`/`errcode`/`errmsg`/`errdetail`/`errhint`/ `errfinish` family. Typed `pg_sys` ABI values such as `Oid`, `Datum`, callback events, and error-report structs are not unsafe access by themselves. The current warnings-denied clippy gate passes. That does not prove soundness: clippy cannot detect the safe mmap access paths below. ## Release-Blocking Findings | ID | Finding | Evidence | Required result | |---|---|---|---| | RUST-00A | Safe node lookup can dereference an mmap pointer out of bounds. | `graph/src/node_store.rs:315-322` | A local bounds proof dominates every dereference; invalid indexes return a typed error/`None`. | | RUST-00B | The mmap edge constructor does not require validated CSR value invariants, while safe accessors reuse unchecked offsets. | `graph/src/edge_store.rs:68-141`, `graph/src/edge_store.rs:522-597` | Only an unforgeable validated layout can construct mapped stores. | | RUST-00C | Custom SQLSTATE emission calls PostgreSQL `errfinish()` below the pgrx guard, which can longjmp across live Rust frames. | `graph/src/safety.rs:238-280` | Error reporting unwinds Rust through the supported pgrx boundary before PostgreSQL ERROR. | | RUST-00D | Durable filter deltas narrow typed values to `Option`; signed, temporal, large, and UUID updates can be changed or dropped. | `graph/src/projection/ingest.rs:32-48`, `graph/src/sql_sync.rs:1383-1391`, `graph/src/projection/segment.rs:116-126` | A versioned tagged codec preserves every registered filter value across sync, segment, and reload. | | RUST-00E | Security-definer SQL functions do not declare a hardened search path. | `graph/src/sql_facade/` (`security_definer` entrypoints) | Every definer function has a minimal explicit pgrx `#[search_path(...)]` and a catalog/attack regression. | | RUST-00F | Registered relation identity is stored and later resolved through names/search path. | `graph/sql/bootstrap.sql:75-91`, `graph/src/catalog/validate.rs:132-190`, `graph/src/builder.rs:341` | Catalog identity is OID/regclass based; names are display data only. | Do not expand the unsafe surface or make production-readiness claims until RUST-00A through RUST-00F have failing regressions, fixes, and release-matrix evidence. ## Non-Negotiable Type And Safety Invariants 1. A public or crate-visible safe function is sound for every value its type permits. Comments about expected callers do not repair an unsafe safe API. 2. A closed vocabulary is one canonical Rust enum. Strings exist only at SQL, JSON, config-file, log, or artifact compatibility boundaries. 3. An identifier or quantity with different semantics has a distinct newtype. Raw primitives exist only in ABI and serialized representations. 4. All narrowing, sign-changing, count, offset, and byte arithmetic is checked. Representation ceilings fail before allocation or persistence. 5. PostgreSQL OIDs enter through pgrx's `pg_sys::Oid`, UUIDs through pgrx `Uuid`, and PostgreSQL values through their native datum types. Text roundtrips are not an internal transport. 6. Exact GQL/PostgreSQL numeric and temporal semantics never pass through JSON or `f64` unless the source type is explicitly floating point. 7. Raw `pg_sys` calls are denied by default and confined to named adapter functions with an owner, safety contract, version matrix, and tests. 8. pgrx memory contexts are used only for PostgreSQL-owned lifetimes. They do not replace accounting for Rust heap, mmap, PostgreSQL executor memory, or spill. 9. PostgreSQL source tables remain authoritative. Stronger Rust typing must not bypass constraints, triggers, ACLs, RLS, snapshots, or transaction rules. ## Target Boundary ```text SQL / GUC / catalog / background-worker entrypoints | v postgres/{relation,guc,error,acl,callbacks,bgworker_tx,datum} | one checked conversion into domain types | v graph identity + GraphValue + typed IR + runtime + storage ports | v persistence/{format,validate,mapped_artifact,reader,writer} | validated serialized primitives only ``` Core graph modules do not accept `Datum`, `pg_sys::Oid`, enum-like `String`, or unvalidated offsets. The PostgreSQL adapter may use pgrx; a future pure core crate is considered only after this boundary is real. ## Canonical Type Model | Concept | Canonical Rust representation | Boundary rule | |---|---|---| | Graph identity | `GraphId(pgrx::Uuid)` initially; distinct `BuildId`, `JobId`, `PolicyId`, `RunId` | Bind/read UUID directly; format only for text APIs and safe path components. | | Relation identity | `TableOid`/`RelationId` constructed from pgrx `pg_sys::Oid` | Ban `oid::integer`, signed casts, and internal name re-resolution. | | Graph indexes | `NodeIdx`, `EdgeTypeId`, `FilterColumnId`, `RelationshipId` | Production types with private fields and checked constructors. Model untyped/reserved cases as enum variants, not magic bytes. | | Planner indexes | `NodeSlotId`, `RelationshipSlotId`, `PathSlotId`, `PatternId`, `ColumnIndex` | Typed arenas/indexed vectors prevent cross-family indexing. | | Generations/order | `GenerationId`, `SyncId`, `SourceWatermark`, `SegmentId` | Checked ordering and no cross-domain integer comparison. | | Counts/units | `NodeCount`, `EdgeCount`, `ByteCount`, `MemoryBudget`, `DiskBudget`, `WorkUnits` | Integer-byte arithmetic; MiB/JSON conversion is display only. | | Edge weights | `EdgeWeight` with an explicit exact/integral domain | Represent or reject negative, fractional, zero, NULL, and overflow explicitly under one documented contract; never clamp. | | Engine access | `EngineAccess::{Writable, ReadOnly(ReadOnlyReason)}` | Contradictory boolean/reason/status combinations cannot be represented. | | Errors | Typed reason/resource/capacity enums inside `GraphError` | Convert to message/detail/hint and SQLSTATE only at `postgres/error`. | | GQL values | `GraphValue` algebra | JSON is an output/input facade, not the execution value system. | `GraphValue` must cover null/missing semantics, boolean, signed and unsigned integers, exact numeric, explicit floating point, string, byte string, date, time, timestamp, timestamptz, interval, UUID, list, record/map, node, relationship, and path values. Use pgrx native datums such as `AnyNumeric`, `Date`, timestamps, intervals, bytea, and `Uuid`, or delegate coercion and comparison to PostgreSQL where that is the authoritative semantic boundary. ## pgrx-First Integration Matrix | Boundary | Required default | Raw exception policy | |---|---|---| | Closed GUCs | `PostgresGucEnum`, typed `GucSetting`, `define_enum_guc` | None. Paths and tenant-setting names remain string GUCs. | | SQL-facing enum types | Parse once into canonical Rust enums; evaluate `PostgresEnum` for private catalogs after upgrade testing | Retain text SQL signatures/checks temporarily where changing them would break upgrades. Unknown/NULL catalog values fail closed. | | UUID/OID | pgrx `Uuid`, `pg_sys::Oid`, and `PgRelation` where locking/lifetime fits | Convert once into domain wrappers; never roundtrip through text/signed integer. | | SQL execution | pgrx SPI with bound parameters and `connect_mut` for writes | Raw executor/catalog calls require a measured, documented need. | | Security-definer path | pgrx `#[search_path(...)]` | No implicit caller path. Dynamic object names still require OID identity and safe rendering. | | SQL arrays/results | Lazy pgrx `Array<'_, T>` and genuinely incremental `TableIterator`/batches | Collection requires a budget lease and explicit maximum. | | Transactions | pgrx transaction helpers with explicit success/error semantics | Permanent xact/subxact callback registration may remain raw because pgrx's safe callbacks are per transaction; callbacks must be panic-free. | | ACL checks | One adapter, preferring a supported pgrx/SPI privilege path when semantics and cost are equivalent | `pg_class_aclcheck` may remain only if the adapter tests prove no safe equivalent preserves required behavior. | | Background workers | `BackgroundWorkerBuilder` and one transaction policy adapter | `MyProcPid` is an accepted isolated raw global required by pgrx's worker API. | | Data directory | Supported pgrx/SPI setting access when legal in the current backend phase | Raw `DataDir` may remain in one adapter where SPI is unavailable, with null/lifetime/version tests. | | Error reporting | pgrx `ErrorReport`/guard boundary and built-in SQLSTATEs | Never transmute a custom code into `PgSqlErrorCode`. If custom wire SQLSTATEs remain contractual, obtain/upstream a safe pgrx arbitrary-SQLSTATE API before release. | | Memory | Rust budget governor plus bounded batches/spill; pgrx memory contexts for PostgreSQL-owned transient values | Memory contexts are not treated as a cap on Rust heap or mapped pages. | ## Work Packages ### RUST-0: Close Soundness And Security Blockers 1. Add an out-of-range mmap node regression, then make access take a validated `NodeIdx` or return `Option`/`GraphResult`. Make owned and mapped behavior identical. 2. Replace public raw mmap parts with a private parser that returns `ValidatedGraphLayout`. Validate section ranges, alignment, monotonic CSR offsets, terminal offsets, target bounds, section lengths, UTF-8, integer endianness, and overflow before a store exists. 3. Make `MappedGraphArtifact` own the `Mmap` plus typed validated ranges. Store offsets/ranges rather than free pointers; no store may outlive its owner. 4. Replace direct deep `errfinish()` use. Prefer standard SQLSTATE plus a typed `GraphDiagnosticCode` in detail/metadata. If preserving `PGxxx` wire codes is mandatory, block release until pgrx exposes a sound validated-code path. 5. Add a minimal explicit search path to every security-definer entrypoint and test shadow schemas/functions/operators plus `pg_proc.proconfig` drift. 6. Store relation OIDs/regclass identity at registration. Test table/schema rename, search-path changes, drop/recreate, concurrent DDL, and stale OIDs. 7. Replace `Option` durable filter payloads with a tagged `PersistedFilterValue`; migrate/rebuild segments and differential-test every supported type across restart. **Exit:** KI-020 through KI-024 are closed, no safe accessor can reach unchecked pointer arithmetic, error reporting respects the pgrx guard, and the security/reload matrix is green on supported PostgreSQL versions. ### RUST-1: Fix Transaction And Adapter Semantics 1. Create `postgres/bgworker_tx.rs`. A Rust `GraphResult::Err` must not accidentally return normally from a pgrx transaction closure and commit partial job state. Define atomic units versus intentionally committed progress checkpoints. 2. Put permanent transaction callbacks in one adapter. Replace panicking `RefCell::borrow_mut()` paths with non-panicking state transitions; callbacks do not allocate, use SPI, or report PostgreSQL errors. 3. Specify and test commit, abort, savepoint, PL exception subtransactions, parallel events, and `PREPARE TRANSACTION` behavior. 4. Consolidate ACL, `MyProcPid`, and early `DataDir` access in an explicit raw FFI allowlist. Remove replaceable raw access only after semantic tests. **Exit:** injected worker failures cannot partially commit unintentionally or leave jobs stuck; callback paths are panic-free and version-tested. ### RUST-2: Replace Stringly Closed State 1. Define one canonical enum each for graph kind, residency, materialization, projection mode, graph privilege, quota scope/dimension/enforcement, job status/phase, validation status, search mode, sync mode, query freshness, OOM action, and build scan mode. 2. Convert search, sync, freshness, OOM, scan, and projection GUCs to `PostgresGucEnum`. Remove the obsolete pgrx 0.18 string-GUC comment and all invalid-value fallback parsing. 3. Make catalog/SPI adapters use `TryFrom` once. Unknown, NULL, or corrupt closed state is a typed error, never `"unknown"`, `"not_found"`, or a default mode. 4. Keep human diagnostic text and genuinely open names/paths as strings. 5. Add one drift test comparing Rust spellings, SQL CHECK/enum vocabulary, configuration docs, and compatibility aliases. **Exit:** no enum-like `CString` GUC or internal enum-like `String` field remains, and invalid values fail at `SET` or the catalog boundary. ### RUST-3: Production Identity, Capacity, And Unit Types 1. Remove the test/development cfg from `NodeIdx` and `EdgeTypeId`; make fields private and add checked constructors/conversions. 2. Introduce the identity, planner-index, generation, count, budget, and work types in the canonical model above. Replace tuple-shaped overlay rows with named structs and one shared overlay contract. 3. Decide artifact capacity explicitly: either bounded `u32` local indexes or segmented/64-bit global IDs. Do not widen silently. 4. Replace `as` casts in counts, offsets, status, and persistence with checked conversions. Return SQL `bigint` for counts that can exceed `i32`. 5. Use pgrx `pg_sys::Oid` at every SPI/catalog boundary and ban `oid::integer`. 6. Enable sign-loss, possible-wrap, and possible-truncation clippy lints as deny-by-default after the inventory is burned down. **Exit:** compile-fail tests prevent cross-domain ID/slot use; boundary tests at `i32::MAX`/`u32::MAX` do not wrap; raw IDs remain only in ABI/format code. ### RUST-4: Exact GQL And Property Value Algebra 1. Replace GQL decimal `f64` and internal `serde_json::Value` transport with `GraphValue` and typed binding rows. 2. Preserve exact numeric scale/precision, temporal types, UUID, bytea, null/missing, and overflow behavior. No failed `from_f64` conversion may silently produce JSON null. 3. Convert JSONB only at explicitly JSON-facing SQL functions. Prefer typed SQL rows for future standard GQL/SQL-PGQ surfaces. 4. Replace `GraphId(String)` and internal `graph_id::text` queries with native UUID binding. Add distinct ID types for jobs, builds, policies, and runs. 5. Define `EdgeWeight`; reject unsupported values during registration/build/ sync instead of casting to bigint and clamping. **Exit:** differential tests match PostgreSQL for large integers, high-scale numeric, SUM/AVG, mixed numeric comparisons, date/time/timestamps/intervals, UUID, bytea, null/unknown, overflow, and non-finite cases. ### RUST-5: Typed Persistent Formats 1. Make format structs use checked serialized primitives and convert to domain types only after validation. 2. Version the filter-value codec and any index-width change. Document old reader, rebuild, rollback, and mixed-generation behavior. 3. Fuzz every section offset/count, malformed CSR, PK UTF-8, filter tag/value, segment, manifest, and compatibility branch. 4. Define the endian policy: decoded fallback or explicit unsupported-target rejection. Native typed loads must not reinterpret little-endian bytes on a big-endian host. **Exit:** corrupt artifacts cannot construct mapped stores; old/new artifacts have deterministic accept/rebuild/reject behavior with no partial publish. ### RUST-6: Unsafe Isolation And Proof 1. Set `unsafe_code = "deny"` at crate policy and permit it only in named mmap and PostgreSQL adapter modules. CI rejects a new unsafe file/function. 2. Keep `unsafe_op_in_unsafe_fn = "deny"` and `clippy::undocumented_unsafe_blocks = "deny"`; require `# Safety` docs on every unsafe function and a local invariant comment on every block. 3. Maintain an inventory recording owner, caller proof, lifetime/provenance, version support, and tests for each unsafe site. 4. Replace test fixtures that pretend unrelated stack allocations are one mapped region with one aligned owned backing allocation. 5. Remove ABI-incompatible fuzz stubs by extracting pure validation code from pgrx linkage; typed generated stubs are interim only. **Exit:** the allowlist is minimal and stable, every unsafe precondition is encoded or checked at the closest boundary, and no raw pointer escapes it. ### RUST-7: Streaming pgrx SQL Boundaries 1. Accept lazy `Array<'_, T>` for large SQL arrays, validate NULL/length, and collect only under an explicit budget. 2. Replace `TableIterator` wrappers over complete `Vec` results with resumable or bounded-batch execution. PostgreSQL `LIMIT`, iterator drop, `statement_timeout`, and cancellation stop graph work and release leases. 3. Keep SPI values and SQL rows typed until the outer facade. Delay JSON and display-string conversion. 4. Do not move ordinary graph allocations into PostgreSQL memory contexts; integrate pgrx contexts only where PostgreSQL must own a datum lifetime. **Exit:** large-array and `LIMIT 1` tests stay within peak-RSS/work gates and cancellation does not leave frontier/result memory live. ### RUST-8: Verification And Continuous Enforcement Required gates: - unit/property tests for all enum parsers, IDs, checked arithmetic, slot preservation, state machines, and `GraphValue` coercions; - compile-fail tests for mixed identity/slot types; - pgrx tests for enum GUCs, native UUID/OID roundtrips, search path, relation rename/drop/recreate, worker transactions, SQLSTATEs, and callbacks; - an in-memory aligned mapped backing for Miri, because OS file mappings are not Miri-supported; - ASan/LSan or Valgrind against a PostgreSQL process loading the extension, covering load/traversal/reload/replacement/corruption/callback/worker paths; - scheduled/release fuzzing for validated layouts and typed decoders without ABI-incompatible PostgreSQL stubs; - PG14 through PG19 matrix coverage where the locked pgrx version exposes the feature; - CI source guards for unsafe allowlist drift, `oid::integer`, internal `graph_id::text`, enum-like `CString` GUCs, unchecked narrowing casts, and `Result<_, String>` in production modules. Every fix starts with a failing regression and closes only with its exact command, PostgreSQL versions, compatibility impact, and artifact/SQL migration evidence recorded in `progress.md`. ## Build-Order Integration 1. Run RUST-0 in checkpoint 0 alongside the existing correctness/security reproductions. These are blockers, not checkpoint-4 cleanup. 2. Run RUST-1, budget/unit types from RUST-3, and persistent types from RUST-5 while implementing checkpoints 1A through 3. Do not create an untyped new artifact and plan to repair it later. 3. Complete RUST-2, the remaining RUST-3 work, and RUST-6 during checkpoint 4 before canonical IR is declared stable. 4. Implement RUST-4 as the first foundation of checkpoint 6 full-GQL slices. 5. Apply RUST-7 vertically as each streaming operator/facade migrates. 6. PostgreSQL 19 adapters reuse the same types and pgrx boundary; they do not introduce a parallel stringly catalog model. 7. RUST-8 is continuous and becomes release evidence, not a final cleanup phase. ## Overall Definition Of Done - All release-blocking findings are closed with supported-version regressions. - Every closed state in core/runtime/storage is a canonical enum. - Every graph identity, planner slot, count, offset, byte quantity, generation, and watermark uses the correct domain type. - No safe API can trigger UB for a value its signature accepts. - Unsafe code exists only in the reviewed allowlist and passes its Miri, sanitizer, fuzz, and PostgreSQL matrix gates. - pgrx-native UUID, OID, enum GUC, search-path, SPI, datum, guard, and worker features are used where they fit; raw PostgreSQL access has explicit proof that no suitable pgrx API exists. - Full GQL execution uses exact typed values rather than JSON/`f64` as its semantic core. - Public SQL and artifact changes have upgrade, rebuild, rollback, and release documentation; PostgreSQL source-of-truth behavior is unchanged.