Contents
TAM Module Instructions — @Architect
Scope:
src/tam/pg_vault_tde_tam.c,src/tam/pg_vault_tde_toast.c,src/include/pg_vault_tde_tam.h,src/include/pg_vault_tde_toast.h
Architecture Pattern
This module uses the mutable-copy pattern: a static TableAmRoutine tde_methods
struct initialized by memcpy from GetHeapamTableAmRoutine(), with specific
callbacks replaced by TDE wrappers. All structural operations (VACUUM, HOT,
CLUSTER, truncate, scan state management) delegate to heapam unchanged.
Write Path Contract
Every write callback MUST follow this sequence:
1. Materialize slot → HeapTuple (plaintext)
2. Call tde_encrypt_heap_tuple() → palloc’d encrypted HeapTuple
3. Call the heapam storage function (heap_insert, heap_update, etc.)
4. Copy physical TID back to the slot
5. OPENSSL_cleanse + pfree the plaintext copy
Write Path PG_TRY Contract (v1.6 patch — fix #1)
All four write callbacks (pg_vault_tde_tuple_insert,
pg_vault_tde_tuple_insert_speculative, pg_vault_tde_multi_insert,
pg_vault_tde_tuple_update) MUST wrap the entire pre-TOAST → encrypt →
heap_insert pipeline inside a single PG_TRY block.
Why: prior to the patch, PG_TRY started AFTER
heap_toast_insert_or_update() and tde_encrypt_heap_tuple(). An error in
either of those two stages would:
- Leak the swapped
reltoastrelid(the relation cache stayed pointing at the wrong TOAST OID until the next relcache invalidation). - Leak
palloc’d plaintext / pre-TOAST intermediates withoutOPENSSL_cleanse.
Pattern (canonical):
Oid saved_toastrelid = rel->rd_rel->reltoastrelid;
bool toastrelid_swapped = false;
HeapTuple plain_inflight = NULL; /* pre-TOAST intermediate */
HeapTuple toasted_inflight = NULL; /* post-TOAST, pre-encrypt */
HeapTuple ct_tuple = NULL; /* post-encrypt */
PG_TRY();
{
/* 1. swap toastrelid so heap_toast_insert_or_update writes into
* pg_toast_NNNNN under heap AM, not encrypted_heap. */
rel->rd_rel->reltoastrelid = pg_vault_tde_get_toast_relid(rel);
toastrelid_swapped = true;
/* 2. pre-TOAST + encrypt + heap_insert — ALL inside PG_TRY */
plain_inflight = ExecCopySlotHeapTuple(slot);
toasted_inflight = heap_toast_insert_or_update(rel, plain_inflight, ...);
ct_tuple = tde_encrypt_heap_tuple(rel, toasted_inflight);
heap_insert(rel, ct_tuple, cid, options, bistate);
/* 3. restore */
rel->rd_rel->reltoastrelid = saved_toastrelid;
toastrelid_swapped = false;
}
PG_CATCH();
{
if (toastrelid_swapped)
rel->rd_rel->reltoastrelid = saved_toastrelid;
if (plain_inflight) { OPENSSL_cleanse(plain_inflight->t_data, plain_inflight->t_len); pfree(plain_inflight); }
if (toasted_inflight && toasted_inflight != plain_inflight)
{ OPENSSL_cleanse(...); pfree(toasted_inflight); }
if (ct_tuple) { pfree(ct_tuple); }
PG_RE_THROW();
}
PG_END_TRY();
For multi_insert, the equivalent loop allocates arrays
(plain_inflight[], toasted_inflight[]) sized at ntuples, and the
PG_CATCH walks the partially-populated arrays freeing/cleansing the slots
that were already produced before the error.
TOAST chunk rollback: TOAST chunks already committed by
heap_toast_insert_or_update() before the error are rolled back by the
surrounding subtransaction — DO NOT attempt manual cleanup of TOAST chunks
in PG_CATCH.
Tests 81 (catalog registration), 83 (transactional rollback after pre-TOAST + encrypt), and 84 (per-table DEK isolation across parent + TOAST) exercise this contract.
TOAST RELKIND_TOASTVALUE bypass (v1.0–v1.6) → Transparent Encryption (v1.7)
v1.0–v1.6 behavior: pg_vault_tde.toast_encryption=on made the auto-created
TOAST relation inherit the encrypted_heap AM, but PG’s toast_save_datum()
writes chunks via heap_insert(toastrel, …) directly — bypassing the TAM
dispatch — so chunks landed plaintext on disk (documented v1 limitation).
v1.7 behavior: TOAST chunks are now encrypted at the TAM level.
- Write path: pg_vault_tde_tuple_insert() now detects RELKIND_TOASTVALUE
and encrypts each chunk using the parent table’s DEK via
tde_encrypt_heap_tuple(chunk, parent_relid).
- Read path: All 7 read callbacks automatically route TOAST relid to parent
DEK lookup via pg_vault_tde_kms_get_rel_dek() (see Phase 1 KMS routing).
No explicit RELKIND_TOASTVALUE bypass needed anymore; TOAST chunks are
decrypted transparently just like parent table tuples.
Architecture:
TOAST chunk INSERT: chunk → encrypt(parent_relid) → heap_insert
TOAST chunk SELECT: heap_fetch → decrypt(TOAST_relid)
↓ (pg_vault_tde_kms_get_rel_dek routes TOAST_relid → parent_relid)
plaintext chunk
TOAST table DEK assignment:
- Each TOAST table inherits the parent table’s DEK (no separate entry in catalog).
- Parent-child DEK linkage is implicit: TOAST relid is passed to
pg_vault_tde_kms_get_rel_dek(), which calls pg_vault_tde_get_parent_relid()
(Phase 1, KMS layer) to find the parent and loads the parent’s wrapped DEK.
Test coverage: Test #53 validates v1.7 TOAST round-trip (10 KB payload → external storage → multiple chunks → encrypt + decrypt → round-trip check).
Read Path Contract
Every read callback that populates a TupleTableSlot with buffer-backed
data MUST call pg_vault_tde_decode_slot(). The 7 callbacks that require
this are listed in the copilot-instructions.md
Section 3 table.
Critical Implementation Rules
decode_slot Invariants
Double-decode guard:
if (bslot->buffer == InvalidBuffer) return;— NEVER remove this check. AfterExecForceStoreHeapTuple, buffer is released butbase.tuplestill points to the decoded data.TID source: Read
bslot->base.tuple->t_selfBEFOREExecClearTuple. Never useExecFetchSlotHeapTuple(slot, false, ...)— it returns tupdata workspace with uninitializedt_self.tts_tid patch: After
ExecForceStoreHeapTuple, ALWAYS setslot->tts_tid = saved_tid.ExecForceStoreHeapTupledoes NOT updatetts_tidforBufferHeapTupleTableSlot.
rd_tableam Identity-Check Workaround
Required for callbacks that call heapam internal functions with
rel->rd_tableam assertions:
- index_fetch_tuple → heap_hot_search_buffer
- index_build_range_scan → heap_getnext
Pattern:
c
const TableAmRoutine *saved_am = rel->rd_tableam;
*(const TableAmRoutine **)(void *)&rel->rd_tableam = GetHeapamTableAmRoutine();
result = heapam_cb(rel, ...);
*(const TableAmRoutine **)(void *)&rel->rd_tableam = saved_am;
ALWAYS restore before any error path. RelationData is per-backend but
leaving it corrupted causes cascading failures in subsequent operations on
the same relcache entry.
TOAST Override
static Oid pg_vault_tde_toast_am(Relation rel) {
return HEAP_TABLE_AM_OID;
}
Without this, TOAST tables inherit encrypted_heap and crash. V1 limitation:
large detoasted values are stored unencrypted.
PG18-Specific API Notes
scan_bitmap_next_tuple signature changed in PG18:
c
// PG18: bool(TableScanDesc, TupleTableSlot *, bool *, uint64 *, uint64 *)
Always verify against src/include/access/tableam.h before modifying.
PG Version Compatibility Checklist (TAM)
When adding support for PostgreSQL N+1, audit every TAM callback:
- Diff
tableam.hbetween PG N and PG N+1 - Check each callback signature in the
TableAmRoutinestruct - Add
#if PG_VERSION_NUM >= (N+1)*10000guards where needed - Update the Version-Specific API Differences table in
copilot-instructions.md§ 0.5 - Run
make ci-regressagainst PG N+1
Known Version Differences (TAM)
| Callback / Function | PG 17 | PG 18 | Guard |
|---|---|---|---|
scan_bitmap_next_tuple |
3 args | 5 args (+lossy, exact) | PG_VERSION_NUM >= 180000 |
heap_beginscan flags in relation_copy_for_cluster |
SO_ALLOW_STRAT|SO_ALLOW_SYNC sufficient |
Must also include SO_TYPE_SEQSCAN; heapgettup calls heap_fetch_next_buffer which asserts scan->rs_read_stream != NULL, and the read stream is only initialized when SO_TYPE_SEQSCAN is set |
PG_VERSION_NUM >= 180000 |
Add rows here when PG 19+ introduces new TAM changes.
Testing Checklist for New TAM Callbacks
When adding a new override:
- [ ] Add a regression test in sql/regression_test.sql
- [ ] Test creates USING encrypted_heap table
- [ ] Test inserts known data and verifies round-trip
- [ ] Test forces the specific scan path (SET enable_xxx = off/on)
- [ ] For rotation paths: verify old-DEK rows are rejected
- [ ] For index paths: verify rd_tableam workaround is applied if needed
Forbidden Actions
- Do NOT call
tde_gcm_encrypt/tde_gcm_decryptdirectly — usetde_encrypt_heap_tuple/tde_decrypt_heap_tuplewrappers - Do NOT include
openssl/*.h— the crypto layer abstracts OpenSSL - Do NOT add catalog lookups or SPI calls on the hot decrypt path
- Do NOT
Assert(user_len > 0)— all-NULL rows have zero user data length
Error Path Mapping
Every TAM callback has specific failure modes. This table maps each callback
to its expected error conditions and the correct ereport level:
| Callback | Failure Mode | ereport Level | Error Message |
|---|---|---|---|
scan_getnextslot |
GCM tag mismatch | ERROR | “GCM authentication failed: tuple at (%u,%u) may be tampered or encrypted with a different DEK” |
scan_getnextslot |
DEK not set | ERROR | “pg_vault_tde: no DEK available |
tuple_insert |
encrypt returns NULL | ERROR | “pg_vault_tde: encryption failed for tuple” |
tuple_update |
old tuple GCM mismatch | ERROR | “GCM authentication failed on UPDATE source tuple” |
index_fetch_tuple |
rd_tableam restore failed | PANIC | “pg_vault_tde: failed to restore rd_tableam — relation cache corrupted” |
multi_insert |
any single tuple encrypt fail | ERROR | “pg_vault_tde: bulk insert encryption failed at tuple %d of %d” |
tuple_insert* / tuple_update / multi_insert |
pre-TOAST or encrypt fail mid-pipeline | ERROR (re-thrown via PG_RE_THROW) | Original heapam/encrypt errmsg; PG_CATCH restores reltoastrelid and cleanses plaintext intermediates (v1.6 patch — fix #1) |
Callback × Test Coverage Matrix
| Callback | Test # | Forced Via | Verified |
|---|---|---|---|
scan_getnextslot |
12, 14 | Default SeqScan | ✅ |
tuple_insert |
12 | INSERT | ✅ |
tuple_update |
14 | UPDATE | ✅ |
tuple_delete |
15 | DELETE | ✅ |
index_fetch_tuple |
17 | SET enable_seqscan = off |
✅ |
multi_insert |
18 | COPY FROM |
✅ |
scan_bitmap_next_tuple |
23 | SET enable_seqscan = off; SET enable_indexscan = off |
✅ |
scan_analyze_next_tuple |
21 | ANALYZE |
✅ |
scan_sample_next_tuple |
24 | TABLESAMPLE |
✅ |
tuple_fetch_row_version |
— | SELECT ... WHERE ctid = '(0,1)' |
via UPDATE recheck |
tuple_lock |
22 | SELECT FOR UPDATE |
✅ |
tuple_insert PG_TRY widening |
81, 82, 83 | DDL hook + 64 KB compressible + ROLLBACK | ✅ (v1.6 patch — fix #1) |
multi_insert PG_TRY widening |
18, 84 | COPY FROM + per-table DEK isolation |
✅ (v1.6 patch — fix #1) |
tuple_update PG_TRY widening |
14, 83 | UPDATE + transactional rollback | ✅ (v1.6 patch — fix #1) |
Action item for @QA: All TAM callbacks now have test coverage (tests 1-84).