| name | awcms-mini-data-lifecycle |
| description | Daftarkan tabel bervolume tinggi ke registry lifecycle AWCMS-Mini (retensi/partisi/arsip/legal hold/purge), atau kerjakan bagian mana pun modul data_lifecycle sendiri. Gunakan saat menambah tabel baru yang tumbuh besar (log, telemetry, outbox, queue, provider attempt) dan butuh kebijakan retensi/purge aman, saat membuat/melepas legal hold, atau saat mengubah engine dry-run/archive/purge/archive-port. Sesuai Issue |
AWCMS-Mini — Data Lifecycle (Registry, Legal Hold, Dry-Run, Archive/Purge)
Sumber kebenaran: src/modules/_shared/module-contract.ts
(HighVolumeTableDescriptor), src/modules/data-lifecycle/ (domain/
application/infrastructure/api), src/modules/data-lifecycle/README.md
(detail teknis lengkap), docs/awcms-mini/data-lifecycle.md (panduan
operasional + pemetaan kepatuhan), ADR-0013 §6 (data ownership matrix —
"no shared-table write").
Kapan pakai skill ini
- Mendaftarkan tabel bervolume tinggi baru ke registry (kasus paling
umum) — lihat §Playbook di bawah.
- Membuat/melepas legal hold dari kode (service layer, bukan hanya
via API).
- Mengubah engine (
dry-run-planner.ts, archive-purge-job.ts,
local-archive-adapter.ts) — baca §Jangan ulangi bug presisi cursor
di bawah SEBELUM menyentuh perbandingan batas cursor mana pun.
Playbook: mendaftarkan tabel bervolume tinggi baru
-
Di module.ts modul PEMILIK tabel (bukan data-lifecycle/module.ts
— descriptor didaftarkan oleh modul yang memiliki tabelnya sendiri),
tambah entry ke dataLifecycle: [...]:
dataLifecycle: [
{
key: "your_module.your_table",
tableName: "awcms_mini_your_table",
ownerModuleKey: "your_module",
scope: "tenant",
cursorColumn: "created_at",
retentionClass: "operational_queue",
retentionMinDays: 7,
retentionMaxDays: 365,
defaultRetentionDays: 90,
partition: { eligible: false, rationale: "..." },
archive: { archivable: false, rationale: "..." },
deletion: { mode: "hard_delete", rationale: "..." },
legalHold: { applicable: true, precedence: "overrides_retention" },
requiredIndexes: [
{ columns: ["tenant_id", "created_at"], purpose: "..." }
],
batchLimit: 5000,
backupRestoreNotes: "...",
executionMode: "delegated",
existingAdopter: {
jobCommand: "bun run your:purge:job",
purgeFunctionRef:
"src/modules/your_module/application/your-purge.ts#purgeYourTable",
description: "..."
}
}
];
-
Pilih executionMode:
- Sudah punya job purge sendiri (kasus paling umum)? →
"delegated" — TETAP pakai job/fungsi yang sudah ada, jangan
duplikasi logic-nya. data_lifecycle's engine hanya membaca tabel
ini untuk dry-run (read-only, aman); purge asli tidak pernah
disentuh mesin ini.
- Belum punya mekanisme purge sama sekali dan ingin
data_lifecycle's engine yang mengeksekusi bounded archive/purge
untukmu? → "generic" — WAJIB kolom id uuid PRIMARY KEY (asumsi
global doc 04) dan index komposit tenant+cursor (dicek registry
gate). Hanya deletion.mode: "hard_delete" yang dieksekusi mesin
ini hari ini — mode lain ditolak (error jelas), bukan salah
eksekusi diam-diam.
-
bun run data-lifecycle:registry:check — perbaiki error yang
dilaporkan (menyebut field dan alasan persis).
-
Update docs/awcms-mini/data-lifecycle.md §Retensi data (tabel) dan
§Pemetaan kepatuhan dengan rasional retensi tabel barumu — jangan
klaim satu periode retensi legal universal; jelaskan alasan spesifik
kelas data ini.
-
bun run changeset.
"Append-only" ≠ "simpan selamanya" (#932 — kelas bug, jangan diulang)
Sebelum mendaftarkan descriptor untuk tabel yang dijaga trigger immutability,
cek dulu apakah barisnya benar-benar BISA dihapus oleh role mana pun.
payment_gateway punya 4 tabel dengan trigger BEFORE UPDATE OR DELETE yang
RAISE tanpa syarat. Efeknya bukan "role app tidak boleh menghapus" (itu batas
yang memang diinginkan) melainkan TIDAK ADA role yang bisa menghapus,
termasuk pemilik tabel — jadi retensi mustahil, legal hold tak punya objek
untuk di-override, dan tak ada jalur erasure sama sekali. Descriptor yang
didaftarkan di atas tabel seperti itu adalah kepatuhan di atas kertas.
Pola yang benar (usage_metering migration 087, lalu payment_gateway
migration 102): trigger immutability hanya BEFORE UPDATE; batas DELETE ada
di GRANT — awcms_mini_app tidak pernah, awcms_mini_worker ya — dengan
satu fungsi purge teraudit sebagai satu-satunya jalur hapus.
Cek cepat:
SELECT tgname, pg_get_triggerdef(oid) FROM pg_trigger
WHERE tgrelid = 'awcms_mini_<tabel>'::regclass AND NOT tgisinternal;
Kalau definisinya memuat OR DELETE, retensi belum mungkin — perbaiki
trigger-nya dulu, jangan hanya menambah descriptor.
Rantai FK: umur saja BUKAN cutoff yang aman
Kalau tabel yang di-purge punya anak dengan FK NOT NULL, induk selalu lebih
tua dari anaknya — purge murni berbasis umur akan mencoba menghapus induk
sementara anak yang belum menua masih mereferensinya, lalu gagal di FK.
Tiap tingkat harus hanya menghapus baris tanpa anak yang masih hidup
(NOT EXISTS), daun lebih dulu. Efeknya: bukti menua sebagai satu rantai utuh,
bukan pecahan yang menyisakan induk tanpa outcome. Legal hold di satu mata
rantai memblokir SELURUH rantai (fail-closed) — bukti yang di-purge separuh
tidak bisa ditafsirkan. Lihat payment-gateway/application/retention-purge.ts.
Legal hold — precedence dan default-deny (kritis, jangan dilonggarkan)
- Hold aktif (tenant-wide
descriptorKey: null, atau menyasar descriptor
spesifik) SELALU override retensi/purge biasa — dicek di
planLifecycleDryRun SEBELUM cabang apa pun yang bisa melaporkan baris
purgeable. retentionDaysOverride seagresif apa pun tidak bisa
membuka jalan purge saat hold aktif.
legalHold.applicable pada descriptor adalah metadata dokumentasi
murni — JANGAN PERNAH membuat mesin mengecek field ini untuk
memutuskan apakah hold berlaku. Hold record NYATA selalu berlaku
terlepas dari nilai field ini (mencegah modul pemilik mendeklarasikan
tabelnya sendiri "kebal hold").
data_lifecycle.legal_hold.create dan .release WAJIB tetap
permission KODE TERPISAH — jangan pernah menggabungkannya jadi satu
permission manage. security:readiness's
checkDataLifecycleLegalHoldReleaseSeparate (critical) akan gagal bila
ini dilanggar.
- Release WAJIB reason (≥10 karakter,
validateReleaseLegalHoldInput),
Idempotency-Key, dan audit critical — sama seperti create.
endsAt pada hold tidak otomatis melepas hold — murni metadata
"perkiraan tanggal review". Hanya aksi release eksplisit yang mengubah
status.
Jangan ulangi bug presisi cursor (microsecond vs millisecond)
timestamptz PostgreSQL presisi mikrodetik; Date JavaScript hanya
milidetik. Setiap perbandingan batas cursor yang membaca sebuah nilai
dari Postgres (via SELECT, otomatis jadi JS Date) lalu memakainya
sebagai bound <=/>/>= di query BERIKUTNYA kehilangan presisi —
baris yang MENDEFINISIKAN batas itu bisa gagal memenuhi perbandingan
terhadap dirinya sendiri.
- Sudah diperbaiki di
archive-purge-job.ts via
CURSOR_BOUNDARY_SAFETY_MARGIN_MS (1ms) — pola: pad batas ke ARAH
YANG BENAR (upper bound <= → tambah 1ms; lower bound resume > →
ubah jadi >= dengan bound +1ms) sebelum dipakai sebagai parameter
query berikutnya.
- Bila menambah perbandingan cursor BARU (fitur baru, refactor):
reuse
CURSOR_BOUNDARY_SAFETY_MARGIN_MS yang sudah ada, JANGAN
bandingkan nilai Date yang dibaca-lalu-ditulis-ulang secara langsung
tanpa padding. Uji dengan
tests/integration/data-lifecycle-archive-purge-job.integration.test.ts's
test volume besar (bug ini SELALU muncul di baris terakhir setiap
batch, bukan kasus langka — tanpa fix, backlog kecil bisa terjebak
loop sampai DEFAULT_MAX_PASSES).
- Detail investigasi lengkap (bagaimana bug ditemukan, dampak sebelum
fix):
src/modules/data-lifecycle/README.md §Timestamp precision.
Identifier dinamis (tableName/tenantColumn/cursorColumn) di SQL
Selalu lewat assertSafeIdentifier (regex allowlist) SEBELUM
diinterpolasi ke teks SQL via tx.unsafe(sql, params) — nilai
sebenarnya (tenantId, cutoff, dst.) tetap SELALU lewat parameter
$1/$2/... terikat, tidak pernah string-concat. Identifier HANYA
boleh berasal dari HighVolumeTableDescriptor yang sudah divalidasi
registry gate — tidak pernah dari request/user input. Pola sama
visitor-analytics/application/analytics-queries.ts's
topJsonFieldCounts.
Jangan bikin mekanisme baru untuk yang sudah ada
- Locking/batching/retry — reuse
src/lib/jobs/* (shared worker
runner, PR #713/Issue #697) lewat runBoundedBatches. JANGAN tambah
advisory lock/batching sendiri.
- Audit — reuse
recordAuditEvent yang sudah ada
(logging/application/audit-log.ts). JANGAN bikin tabel audit
terpisah untuk aksi data-lifecycle.
- Redaksi/masking — tidak ada mekanisme baru; dry-run/run history
hanya menyimpan count teragregasi, tidak pernah row content, jadi
tidak ada nilai sensitif untuk diredaksi di sana sejak awal.
- ABAC/RLS — pola
authorizeInTransaction + withTenant standar
(skill awcms-mini-abac-guard), tidak ada mekanisme otorisasi baru.
Verifikasi
bun run data-lifecycle:registry:check — registry valid.
bun run security:readiness — dua check baru
(checkDataLifecycleRegistryValid, checkDataLifecycleLegalHoldReleaseSeparate)
pass.
- Dry-run terhadap Postgres nyata: descriptor manapun, memanggil dua kali
berturut-turut dengan input sama menghasilkan hasil identik (tidak ada
mutasi).
- Legal hold aktif: dry-run melaporkan SEMUA baris eligible sebagai
held, purgeableCount: 0, bahkan dengan retentionDaysOverride
paling agresif.
executionMode: "generic" descriptor: test volume besar (>batchLimit)
membuktikan multi-pass benar tanpa duplikasi/lompatan baris, dan
manifest arsip yang dihasilkan lolos ArchivePort.verify().