The chain writer re-read the whole late-detail lookback every 30 s and probed the GIN array index once per detail (18.4 s, 1.78M buffer hits per tick in production), and the coverage trigger's EXISTS overlap check ran as a cached generic plan that sequentially scanned worm_audit_chain for every inserted link. - worm-audit:13: worm_audit_chain_detail (detail_id PK, chain_id), one append-only row per covered detail, written by the chain trigger in the same statement as the link and backfilled under the chain advisory lock. WORM guards and grants like the other tables; no foreign keys. - The trigger inserts coverage rows and lets the primary key reject a detail covered twice. The purge deletes coverage before its links. - lateDetails probes the coverage primary key; both sweeps read only ids above per-JVM sweep floors, with a full sweep after restart and hourly. - Verification aggregates the chain's own detail lists once and breaks if the coverage table disagrees. Link hash inputs are unchanged. - Memory and H2 backends: the writer records coverage rows itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
29 KiB
QBit WORM Audit: design
Status: approved. Owner decisions (2026-10-12): all recommendations accepted. Every open question in section 12 is decided as recommended.
1. Owner request
"we also need to ensure that our auditing qbit supports WORM audits as well. and the phi/secure data qbit needs to ensure that we don't leak phi into audits"
Context: HIPAA §164.312(b) audit controls, §164.312(c)(1) integrity, §164.316(b)(2)(i) six-year retention of required documentation (audit records are kept to the same horizon), and a HITRUST-style review that asks three things: (1) can anyone change or delete an audit record, (2) would we know if they did, and (3) is there a copy nobody can alter?
2. Goals and non-goals
Goals:
- Append-only at the database level. No application path, and no ordinary database login, can UPDATE, DELETE or TRUNCATE audit rows. The only removal is an audited retention purge, after the retention date, through a separate database role.
- Tamper evidence. Every audit row is linked into a hash chain. Signed checkpoints anchor the chain. A verification action and a daily job report the exact first broken row.
- Sealed copies. New audit rows and checkpoints are exported on a schedule to an S3 bucket with Object Lock in COMPLIANCE mode (default six years), and can be verified against the live chain.
- No PHI in the WORM layer. The chain and the exports seal exactly what core wrote. With qbit-secured-data that means HMACs for classified values, never plaintext.
Non-goals: changing what core audits (that is core's and qbit-secured-data's job); protecting against a Postgres superuser preventing a change (a superuser can disable triggers; the chain, the signatures and the Object Lock copies make that change detectable, see 4.5).
3. Scope: which tables
| Table (QQQ name / kof22 physical) | Treatment |
|---|---|
audit / audit |
Append-only (UPDATE, DELETE, TRUNCATE rejected). Chained. |
auditDetail / audit_detail |
Append-only. Chained with its header. |
auditTable / audit_table |
Lookup. DELETE and TRUNCATE rejected; UPDATE of id or name rejected (label and dates may change). Names are resolved into the chained content, so a later rename is detected. |
auditUser / audit_user |
As audit_table. |
worm_audit_chain, worm_audit_checkpoint, worm_audit_export, worm_audit_verification, worm_audit_purge |
The qbit's own tables: all append-only. |
Physical names come from the host's backend (kof22 maps camelCase to snake_case). The DDL is a template rendered for the host's names (section 5.4).
4. Append-only enforcement (Postgres first)
4.1 Triggers
One function, worm_audit_reject(), attached as:
BEFORE UPDATE OR DELETE ... FOR EACH ROWon every append-only table;BEFORE TRUNCATE ... FOR EACH STATEMENTon every append-only table;- on the two lookups, a row trigger that rejects DELETE and any UPDATE that changes
idorname.
The error is explicit and greppable:
ERROR: worm_audit: audit rows are write-once; UPDATE of "audit" is not allowed (id=4211). Expired rows are removed only by worm_audit_purge().
SQLSTATE: P0001 (HINT: see qbit-worm-audit docs/DESIGN.md section 4)
In the application the qbit also removes the update and delete capabilities from these tables'
QQQ metadata, so the admin UI and both APIs never offer edit or delete, and a direct call gets
QQQ's standard "table does not support update/delete" error before reaching the database. The
qbit maps a P0001 with the worm_audit: prefix to a QUserFacingException: "Audit records are
write-once and cannot be changed or deleted."
4.2 The retention purge (the only removal path)
worm_audit_purge(p_cutoff timestamptz) is a SECURITY DEFINER function owned by the schema
owner. EXECUTE is revoked from PUBLIC and granted only to the role worm_audit_purger, which
the app role is not a member of.
- It refuses (and records a
REFUSEDrow inworm_audit_purge, then returns; no exception, so the record survives) whenp_cutoff > now() - retention. The retention interval is a constant compiled into the function by the changeset (default6 years), so shortening it is a schema migration, reviewed and itself audited. - It purges only a chain prefix: the audits whose chain links all lie at or before the last link whose audit timestamp is older than the cutoff, together with their details and links.
- It sets a transaction-local flag (
set_config('worm_audit.purging', 'on', true)). The reject trigger allows DELETE only when that flag is on andcurrent_useris the function owner and the row is older than the retention interval. Setting the flag from another session does nothing, becausecurrent_useris not the owner. - It writes a
PURGEDrow: who (session_user), cutoff, retention, counts, and the anchor (the last purged link's id and hash). Verification continues from the anchor.
The purge runs from an ops runbook (psql as worm_audit_purger). Version 0.1 has no UI action;
the app home lists purge rows, including refusals.
4.3 GRANT model
| Role | Owns | Grants on append-only tables |
|---|---|---|
<app>_owner (Liquibase) |
tables, functions, triggers | all (owner) |
<app> (runtime) |
nothing | SELECT, INSERT; USAGE on their sequences. No UPDATE, DELETE or TRUNCATE. |
worm_audit_purger (ops) |
nothing | EXECUTE worm_audit_purge; nothing else |
When a host uses one role for both migrations and runtime (kof22 today), the triggers still stop
every UPDATE, DELETE and TRUNCATE from the application, but the role can ALTER TABLE ... DISABLE TRIGGER. The docs recommend splitting the roles, and the app home shows a warning when the
runtime role owns the audit tables (checked through pg_tables.tableowner = current_user).
4.4 Changesets
The qbit ships src/main/resources/com/kingsrook/qbits/wormaudit/ddl/postgres/worm-audit.sql,
a Liquibase formatted-SQL template with placeholders for the four core audit table names, the
app role and the retention interval, plus a renderer (WormAuditDdl.postgres(config)) and the
rendered default (docs/ddl/worm-audit-postgres.sql, snake_case names). Core audit physical
columns must use snake_case (for example audit_table_id, audit_id, new_value); hosts map
QQQ field names to those columns. Hosts copy the rendered
file into their changelog (kof22: changesets/features/YYYY-MM-DD-worm-audit.sql). It creates the
qbit tables, the functions, the triggers, the purger role (CREATE ROLE ... NOLOGIN when it is
missing) and the grants. It is idempotent (CREATE OR REPLACE, DROP TRIGGER IF EXISTS).
Foreign keys (worm-audit:11-12) are ON DELETE RESTRICT only, so nothing cascades into or out
of evidence: worm_audit_chain.audit_id -> the core audit header (the purge deletes details, then
links, then headers, in that order), worm_audit_export.from/to_checkpoint_id -> checkpoints and
worm_audit_verification.export_id -> exports (neither is ever deleted). References to chain links
(checkpoint, export and verification ranges, first_break_link_id, the purge anchor) are not
foreign keys: the purge deletes the sealed prefix those rows outlive. On upgrade, rows whose parent
is already missing are evidence and are never changed: they are copied to qqq_fk_quarantine
(action KEPT) and that constraint stays NOT VALID (new rows are still checked). With the
foreign key, a plain TRUNCATE of audit (or of checkpoints or exports) is refused by PostgreSQL
before the guard trigger; TRUNCATE ... CASCADE reaches the guards, which refuse it.
4.5 Threat model summary
| Actor | Can modify audit rows? | Detected by |
|---|---|---|
| App user, admin UI, QQQ APIs, assistant | No (capabilities removed; triggers) | n/a |
| SQL injection or a bug as the runtime role | No (triggers; no grant) | n/a |
| Owner role (migrations) | Only by disabling triggers | chain verification (daily), alert |
| Postgres superuser rewriting rows and recomputing the chain | Yes | checkpoint signatures (no signing key in the DB); sealed exports in Object Lock |
| Anyone deleting the sealed copies | No (COMPLIANCE mode: not even the bucket root, until expiry) | n/a |
4.6 Other databases (documented, not shipped in 0.1)
- MySQL/MariaDB:
BEFORE UPDATE/BEFORE DELETEtriggers withSIGNAL SQLSTATE '45000'; no TRUNCATE trigger, so revokeDROPfrom the app user; purge through aSQL SECURITY DEFINERprocedure. - SQL Server:
INSTEAD OF UPDATE, DELETEtriggers plusDENY UPDATE, DELETEto the app user. - H2 (tests only): no enforcement; the chain and the exports work on any QQQ RDBMS or memory backend, so tamper evidence is database-independent.
5. Tamper evidence
5.1 Canonical content (agreed with qbit-secured-data)
One canonical document per audit header: the header plus all of its detail rows, as RFC 8785 JCS JSON (sorted keys, no insignificant whitespace, UTF-8):
{"audit":{"clientChannel":null,"id":4211,"message":"Record was Edited","recordId":"17","table":"cms_page","timestamp":"2026-10-13T21:04:05.123456Z","user":"james"},
"details":[{"fieldName":"title","id":9001,"message":"Changed Title from ...","newValue":"...","oldValue":"..."}],
"v":1}
- Values are exactly as stored: no formatting, no re-masking, no lookups other than resolving
auditTableId/auditUserIdto their names.detailsare sorted by id. - With qbit-secured-data's core hook (QRun-IO/qqq#1161,
docs/SECURED-DATA.md§14.2), PII and PHI values are tokenised insideDMLAuditActionbeforeAuditActioninserts, in the same transaction as the data change, so the token is the only form ever written. Stored forms (ASCII, at most 200 characters) inoldValue/newValuearehmac:v1:<keyId>:<b64url>,shred:v1:<auditKeyId>:<b64url>,changed:v1,wiped:v1:<ruleId>andshredded:v1:<ruleId>(the last is its own detail row, written when keys are destroyed, and is chained like any other row). Detail messages for those fields are replaced with value-free ones ("Changed "); header messages carry no field values. The canonical form, the chain and the exports therefore contain tokens only, and verifying the chain never needs a key. The WORM layer never reads, joins or exportssecured_data_audit_keyor any othersecured_data_*table. Crypto-shredding (destroying a record's key) leaves the HMACs and the chain intact; they simply become unlinkable. - The canonical JSON is never stored in the database. The chain stores ids and hashes only, so the qbit adds no new copy of audit content to the database.
5.2 The chain: a single asynchronous chainer (recommended)
link_hash = SHA-256(prev_link_hash || 0x00 || canonical_bytes), hex, with the genesis
prev_link_hash = 64 zeros.
Core inserts audits from many threads, and headers and details may be inserted in separate autocommit statements (core passes the caller's transaction, which may be null). Computing the chain in core's write path would need a global lock on every audited write, and a trigger-based chain would need the canonical form in SQL. The design instead uses one chainer:
- A scheduled job (every 30 s by default) takes
pg_try_advisory_xact_lock(any other instance skips that tick; on non-Postgres backends, a single-row lock table withSELECT ... FOR UPDATE). It selects audit headers that are older than a settle horizon (default 60 s, so their details have been committed) and not yet chained (an anti-join on the uniqueworm_audit_chain.audit_id), in(timestamp, id)order. Then it appends links in batches. - Link ids are assigned by the chainer as
max(id) + 1inside the lock, not from a sequence, so the chain is contiguous (a sequence gaps on rollback). - Late arrivals (a header whose id sorts before already-chained ones because its transaction
committed late) are chained when they appear. The chain order is chaining order, not audit id
order, and each link records its
audit_id. - Late details (a detail inserted after its header was chained) get their own link,
kind = LATE_DETAIL, covering exactly those detail ids. Verification flags any detail not covered by exactly one link. - Unique constraints back the lock:
UNIQUE (audit_id) WHERE kind = 'AUDIT', and the detail coverage:worm_audit_chain_detail (detail_id PRIMARY KEY, chain_id), one append-only row per covered detail, written by the chain insert trigger in the same statement as the link. The writer finds uncovered details with a primary-key probe of it; verification still counts coverage from the links' owndetail_idsand breaks if the table disagrees. - Each tick reads only rows above per-JVM sweep floors: the highest audit and detail ids seen at least (settle horizon + 5 minutes) before the last complete sweep. After a restart and once an hour a tick ignores the floors, so a row whose transaction commits even later is still chained.
Why not per-partition chains: they make each partition's chain shorter but multiply heads, checkpoints and verification state, and they still need a writer per partition. One chainer handles thousands of rows a second in batches, which is well above audit volume, and keeps a single head to sign. The table carries no partition column (YAGNI). A future partitioned mode would add one.
Window: a row is unchained for at most horizon plus interval (about 90 s). During that window the triggers still protect it. The chain defends against privileged tampering, which the window does not change materially.
5.3 Signed checkpoints (worm_audit_checkpoint)
Every hour, and every 10,000 links, the chainer writes a checkpoint for the links since the last
one: from_link_id, to_link_id, head_hash (the last link hash), merkle_root (a binary
SHA-256 Merkle tree over the range's link hashes, so a single exported range can be proven
without the whole chain), key_id, public_key, algorithm = Ed25519 and signature over the
JCS form of those fields. The signing key comes from config (WormAuditQBitConfig.signingKey, a
supplier; kof22: env WORM_AUDIT_SIGNING_KEY from Infisical) and never touches the database. The
public key is stored on each checkpoint and pinned in config (trustedPublicKeys), so a forged
checkpoint signed with another key fails verification. Rotation adds a new key id. Old
checkpoints verify against their own pinned key.
5.4 Verification
"Verify audit chain", a table action on worm_audit_chain (and on selected links), is a
standard streamed ETL:
- Extract: the links in scope (all since the purge anchor, or the selection), in id order.
- Transform: recompute each link from the live audit rows (
prevcontinuity, canonical content,link_hash); check checkpoint signatures and Merkle roots; for a full run, check completeness (every settled audit and detail is covered exactly once). - Load: one
worm_audit_verificationrow. - Review: "1,204,331 chain links will be verified (links 1–1,204,331, 121 checkpoints)."
- Result: "1,204,331 links verified" or, on failure, one line per break, each with the exact row: "Break at link 88,412 (audit 90,117, table cms_page, record 17): content differs from the chained hash" / "audit_detail 9,002 is not covered by the chain" / "checkpoint 41: signature does not verify".
Daily verification: a scheduled job runs the same verifier at 03:00. A failure raises
QAlerts.raise(new AlertSignal("wormaudit.chain.break").withDedupKey("chain")...) with the first
break in the title and the verification record as the record ref; a later pass resolves it. Every
run sends QAlerts.heartbeat("wormaudit.verify.daily"). QAlerts is the core producer SPI
(QQQ 4.1.0-RC.5), so the qbit needs no dependency on qbit-alerts. With no alerts implementation
installed the alerts go to the log.
6. Sealed exports
6.1 Export job
Default superseded by Owner decisions (2026-10-14) below: lock mode and export retention
now come from Settings, defaulting to GOVERNANCE and 30 days. The original decision text is
retained for history.
Daily at 02:00 (configurable), and by the table action "Export now" on worm_audit_export:
- Range: links after the last export's
to_link_id, up to the latest checkpoint. Exports are contiguous by construction. The app home flags a gap (a range not covered) and lag. - Objects under
<prefix>/<yyyy>/<mm>/<dd>/<from>-<to>/:links.jsonl: one line per link:{"link":{id,kind,auditId,detailIds,prevHash,linkHash},"content":<canonical document>};checkpoints.jsonl: the checkpoints in range, with signatures;manifest.json: format version, range, counts, the SHA-256 of each file,head_hash, the previous export's manifest SHA-256 (the exports form their own chain), qbit version.
- Each object is put through qbit-object-storage (
SystemStorage, bucket alias from config, defaultworm_audit) withLockMode.COMPLIANCEandretainUntil = now + retentionYears. The export refuses to run, and alertswormaudit.export.failed, when the bucket does not report theOBJECT_LOCKcapability or the put does not echo the lock. - On success it inserts one
worm_audit_exportrow (insert-only; a failed export leaves no row, and its alert carries the reason). Every scheduled run sendswormaudit.export.daily.
6.2 Restore and verify a sealed export
"Verify sealed export", a record action on worm_audit_export (standard ETL), downloads the
exact object versions pinned in the catalog's object_versions map. A new version under an
existing key is an overwrite even when bytes and retention match: verification checks the latest
version against the pin and rejects replacements, then reads and checks the pinned version.
It checks the manifest hashes, the lock mode and retain-until of each object, the link
hashes recomputed from the exported content, the checkpoint signatures, and the export's link to
the previous manifest. Then it compares each exported link and content document with the live
chain and the live audit rows. Result lines name each difference ("link 88,412: live content
differs from the sealed copy (sealed 2026-10-14)"). This is also the restore tool: the exported
content is the canonical audit content, so a verified export is evidence of the original rows
after a live tamper. A purged range stays verifiable from the sealed copy alone. Writing restored
rows back into a live table is out of scope (it would need the purge role and break append-only).
7. Tables
All tables use the host's RDBMS backend, are append-only (section 4), are audited by nothing
(auditing the audit tables would recurse), have full record screens, and are searchable in Quick
Search. ESB table events are on for worm_audit_verification and worm_audit_export (the
events integrations care about); chain links are high-volume and publish none.
worm_audit_chain[Audit Chain Link]:id(contiguous),kind(AUDIT, LATE_DETAIL),audit_id,detail_ids(comma list, at most 250 per link),prev_hash,link_hash,chained_at. Unique(audit_id) where kind = 'AUDIT'.worm_audit_checkpoint[Checkpoint]:id,from_link_id,to_link_id,link_count,head_hash,merkle_root,algorithm,key_id,public_key,signature,created_at.worm_audit_export[Sealed Export]:id,from_link_id,to_link_id,from_checkpoint_id,to_checkpoint_id,bucket_alias,object_prefix,object_versions,manifest_key,manifest_sha256,prev_manifest_sha256,object_count,byte_count,lock_mode,retain_until,sealed_at.worm_audit_verification[Verification]:id,kind(CHAIN, SEALED_EXPORT),trigger(SCHEDULE, USER),started_at,finished_at,from_link_id,to_link_id,links_checked,outcome(PASS, FAIL),break_count,first_break_link_id,first_break_audit_id,first_break_reason,export_id,run_by.worm_audit_purge[Purge]:id,requested_at,requested_by,cutoff,retention_interval,outcome(PURGED, REFUSED),reason,audits_deleted,details_deleted,links_deleted,anchor_link_id,anchor_hash.
Nav (the app "WORM Audit"): Home, Verifications, Sealed Exports, Checkpoints, Audit Chain, Purges. Checkpoints list their links as a child list. Exports and verifications link their checkpoints and links.
8. Permissions and the assistant wall
- Table permissions use the full name (
worm_audit_chain.read, ...). There are no insert, edit or delete permissions to grant: the capabilities are absent. - Process permissions:
worm_audit.verify,worm_audit.export. The home is visible with read on anyworm_audit_*table. - The assistant channel is denied
worm_audit_*. The qbit registersClientChannelsMetaData.ofOrWithNew(qInstance).withDeniedPattern("assistant", "worm_audit_*")(core, QQQ 4.1.0-RC.6, #1141). On RC.5 it does so reflectively when the class is present. A one-line core PR also addsworm_audit_*toASSISTANT_DEFAULT_DENIED_PATTERNS, so the wall holds even if the qbit is configured without its producer. Core'saudit*pattern already covers the core audit tables.
9. App home
A real home that owns the data (no duplicate dashboard), built from standard widgets, served
through /qqq/v1/widget like every QQQ widget:
- Chain status: links chained, last link time, unchained backlog (with the settle horizon), last verification (time, outcome, links), open breaks with the first break's exact row and a link to the verification record.
- Export status: last sealed export (range, time, retain-until), lag in links and hours, gaps, and bucket lock state (COMPLIANCE, retention).
- Retention: configured years, the purge anchor, the oldest live audit, the next eligible purge date.
- Recent purges: the last 10 purge rows, refusals included.
- Setup warnings: triggers missing on any table (checked via
pg_trigger), runtime role owns the audit tables, signing key or trusted keys missing, bucket without Object Lock.
The home must be readable at every width (390 to 1920 px) in light and dark mode, with no inner
horizontal scroll. Screenshots go in docs/screenshots/. If a custom endpoint is ever needed, it
registers as a MiddlewareSpecExtension under /qqq/v1/qbit/worm-audit/...
(docs/UI-API-EXTENSIONS.md). The design needs none.
10. Configuration (WormAuditQBitConfig)
retentionYears (6), signingKey (supplier of an Ed25519 private key), trustedPublicKeys
(key id to public key), chainIntervalSeconds (30), settleHorizonSeconds (60),
checkpointEveryLinks (10,000), checkpointEveryMinutes (60), exportBucketAlias (worm_audit),
exportPrefix (worm-audit), exportCron (02:00 daily), verifyCron (03:00 daily),
coreAuditTableNames (physical names, defaulting to QQQ's), tableMetaDataCustomizer.
11. Tests (acceptance)
- Postgres Testcontainers (
postgres:16): the rendered DDL applies twice (idempotent); UPDATE, DELETE and TRUNCATE onaudit,audit_detailand eachworm_audit_*table fail with theworm_audit:message; lookup renames fail and label changes succeed; the runtime role has no UPDATE/DELETE grant;worm_audit_purgerefuses a recent cutoff (and records it), purges an expired prefix as the purger role, records the anchor, and verification passes from the anchor; settingworm_audit.purgingfrom the app session does not allow DELETE. - Chain: chain, verify (PASS); a forced tamper (triggers disabled as superuser, one
audit_detail.new_valuechanged) is detected, naming that exact link, audit and detail; a deleted row, an inserted row, a reordered link and a forged checkpoint are each detected. - Concurrency: 8 threads inserting audits (with late commits and details in separate statements) while 3 chainers run. The chain is contiguous, every audit and detail is covered exactly once, and verification passes.
- Exports (MinIO Testcontainers, bucket with Object Lock): export, then verify the round trip; every object has COMPLIANCE retention; deleting or overwriting an object version is refused by MinIO; a tampered live row is reported by "Verify sealed export"; a bucket without Object Lock makes the export refuse and alert.
- PHI: a field classified PHI (
AiFieldMetaData.withClassification(DataClassification.PHI), read throughAiDataClassifications.ofField) is written with the valueCANARY-PHI-<random>. With secured-data's audit token hook (a stand-in producing the samehmac:v1:form in the qbit's tests until the core hook ships), the audit header, the details (old, new, message), the canonical content and the export JSONL contain neither the canary nor any substring of it longer than 4 characters. - Alerts: a break raises
wormaudit.chain.breakthroughQAlerts(captured by a test sink); a later pass resolves it. - Coverage gates: 70% instructions, 90% classes.
12. Decisions (all accepted as recommended, 2026-10-12)
| # | Question | Decision |
|---|---|---|
| 1 | Chain construction | A single asynchronous chainer with a settle horizon, contiguous link ids and late-arrival links (5.2), not synchronous or per-partition chains. |
| 2 | Chain storage | A sidecar table (worm_audit_chain), not columns on core tables, so core's schema is untouched and no UPDATE of audit rows is ever needed. |
| 3 | Checkpoints | Hourly or every 10,000 links; Ed25519; Merkle root per range. |
| 4 | Lookup tables | DELETE and id/name changes rejected; names resolved into the content. |
| 5 | Purge | SQL function plus a purger role only, chain-prefix only, no UI action in 0.1. |
| 6 | Export | Daily JSONL plus manifest, COMPLIANCE, six years, contiguous ranges, the exports form their own chain. |
| 7 | Databases | Postgres enforced; MySQL and SQL Server documented; evidence on any backend. |
13. Phases
- (a) DDL and renderer, capabilities guard, chain and chainer, checkpoints, verification action and daily job with alerts, the assistant deny pattern; tests 1–3, 5 and 6.
- (b) Sealed exports and the export verifier through qbit-object-storage; test 4.
- (c) App home and setup warnings, screenshots; kof22 adoption (draft Website-Backend PR, a feature changeset) and the infra proposal for an Object Lock bucket (section 14).
14. Adoption infrastructure (kof22, proposal only)
- Bucket:
kof22-worm-auditon the kof22 MinIO, created byminio-bucket-initwithmc mb --with-lock --ignore-existingandmc retention set --default COMPLIANCE 6y. Object Lock can only be enabled at bucket creation. - Policy:
kof22-appgetss3:PutObject,s3:GetObject,s3:GetObjectVersion,s3:ListBucket,s3:GetObjectRetention,s3:PutObjectRetention(needed to set the lock on each put),s3:GetBucketObjectLockConfigurationon that bucket. No delete, and nos3:BypassGovernanceRetention(irrelevant under COMPLIANCE anyway). - Off-site: COMPLIANCE on a single homelab MinIO stops every API deletion, but not someone
destroying the volume. Recommend extending
media-offsite-copyto replicate the bucket to a B2 bucket with Object Lock (COMPLIANCE, 6 years). - Secrets:
WORM_AUDIT_SIGNING_KEY(Ed25519 PKCS#8, base64) generated in Praetorterraform/infisical-configand delivered by ESO. The public key goes in the backend config. - Database: split the kof22 owner and runtime roles when convenient (4.3). Until then the home shows the owner warning.
These are owner-run (Praetor and cluster-gitops are applied by the owner). The qbit only documents them.
Owner decisions (2026-10-14)
The 2026-10-12 decisions above remain the historical design. These decisions supersede the COMPLIANCE/six-year export default in §6.1 and the corresponding default assumptions in §§2, 4.5, 9, 11, 12 and the §14 infrastructure proposal.
- Settings live in the application as the standard QQQ
worm_audit_settingstable, record ID 1, labelled WORM Audit Settings and navigated as Settings after Purges. It allows edits, has no append-only triggers, and uses FIELD-level core audit so every change is chained. Reading needsworm_audit_settings.read; editing additionally needsworm_audit_settings.editandworm_audit.settings. New IDs and deletion are refused. - Defaults are seeded insert-only in new changeset
worm-audit:10; existing changesets are unchanged. An administrator's edits survive subsequent seeding. Config supplies defaults when a row/field/table is absent. Runtime reads cache at most 30 seconds, scoped to the QInstance. Chain interval and UTC Quartz export/verify schedules take effect after restart. - kof22 starts with GOVERNANCE, 30 days for sealed exports. An administrator with
s3:BypassGovernanceRetentioncan remove those copies. This is deliberately reversible; it does not provide COMPLIANCE's irreversible retention. Runtime credentials still receive no delete or bypass permission. Bucket defaults should match the selected settings. - Changing Settings to COMPLIANCE affects new exports. Existing GOVERNANCE versions retain their original mode unless an administrator extends/upgrades them. Verification checks the catalog's recorded mode and date, so external mode changes are reported as mismatches. COMPLIANCE cannot be undone. The settings change itself is audited and chained.
- Audit retention defaults to six years independently of export retention. The home warns about a shorter export horizon and a mismatch with the purge function's compiled interval. Updating the setting does not modify the purge function; that still requires a migration.
The README Settings table specifies all fields, defaults, bounds and timing. Signing keys, public-key pins, bucket alias, prefix and backend remain host configuration, never settings.