Implement the database archiving target (closes #43) #84

Merged
sneak merged 4 commits from issue-43-database-archiving into main 2026-08-07 22:50:08 +02:00
Collaborator

Implements the databaseTarget as a real archiving target, replacing the always-successful stub. Delivering to a database target now writes the full event into a per-webhook archive SQLite file for long-term storage.

Archive-writer semantics

  • Separate file: each webhook's full events are written as rows into archive-{webhookID}.db under the data dir, distinct from the per-webhook event DB (events-{webhookID}.db). The file and its schema are created on first write if missing. Each row carries the full event: body, headers, method, content type, webhook id, entrypoint id, event id, and an archived-at timestamp.
  • Close/reopen with debounce: after each write the archive handle is closed and reopened, unless the last (re)open was less than one second ago. This lets an operator move the archive file away for offline archiving while bounding file churn under load. A per-webhook archiveWriter owns this debounce state and serialises writes.
  • Auto-recreate: the file is opened create-if-missing (mode=rwc) and its schema re-migrated on every open, so if the archive was moved or removed since the last open, the next write recreates it. The writer also detects a missing file before writing and reopens first, so a moved-away file is recreated rather than lost.
  • Optional expiry, validated at creation: an optional expiry in the target's config JSON (e.g. {"expiry":"720h"}) is validated when the target is created (ValidateArchiveExpiry; bad values are rejected with a 400 at the add-target form, the Slack URL precedent). The default (missing, empty, or "never") keeps rows forever with no pruning. When a positive duration is set, rows older than it (measured from each row's archived-at time) are pruned on every (re)open; because the file is reopened after writes, prune-on-open keeps the archive swept without a separate background sweeper. A set-but-invalid expiry in a stored config (unparseable, zero, or negative) is an error at delivery time too — never a silent default.
  • No-retry, fail-loud: the target performs a single attempt with no retries. On success it records one successful attempt and marks the delivery delivered. If the archive write fails, the attempt is recorded as failed with the error and the delivery is marked failed — archiving errors never report success.

Scope

  • internal/delivery/target_database.go — the databaseTarget (no-retry) archives via a per-webhook writer registry; an archive error records a failed attempt and marks the delivery failed.
  • internal/delivery/target_database_archive.go (new) — the archiveWriter, the archived-row model, config/expiry parsing (fail-loud on set-but-invalid values), ValidateArchiveExpiry, and prune-on-open.
  • internal/handlers/source_management.go — database targets get a creation-validated expiry config (buildDatabaseTargetConfig); the expiry form value is read where the request body is bounded and bad values are rejected with a 400 at target creation.
  • templates/source_detail.html — the add-target form shows an expiry field for database targets.
  • README.md — the database-target documentation describes the archiving semantics.
  • internal/delivery/export_test.go, internal/delivery/target_database_test.go, internal/handlers tests — tests and their exported shims.

No changes to the Target interface or other targets.

Tests

  • a row is archived (both at the writer level and end-to-end through Deliver)
  • a forced archive failure (bad stored expiry config) yields a Failed delivery with a non-success DeliveryResult carrying the error and no archive file created
  • the file is recreated after removal, with only the post-removal row
  • the one-second reopen debounce (rapid writes reopen once; a write after the window reopens again)
  • expiry pruning removes rows older than the configured expiry
  • expiry config parsing (empty / never / duration accepted; unparseable, zero, and negative values error)
  • expiry validation at target creation (TestValidateArchiveExpiry; valid values build the config, bad values get a 400)

Validation

docker build . exits 0 (fmt-check, lint, test, build all pass).

Closes #43

Implements the `databaseTarget` as a real archiving target, replacing the always-successful stub. Delivering to a `database` target now writes the full event into a per-webhook archive SQLite file for long-term storage. ## Archive-writer semantics - **Separate file:** each webhook's full events are written as rows into `archive-{webhookID}.db` under the data dir, distinct from the per-webhook event DB (`events-{webhookID}.db`). The file and its schema are created on first write if missing. Each row carries the full event: body, headers, method, content type, webhook id, entrypoint id, event id, and an archived-at timestamp. - **Close/reopen with debounce:** after each write the archive handle is closed and reopened, unless the last (re)open was less than one second ago. This lets an operator move the archive file away for offline archiving while bounding file churn under load. A per-webhook `archiveWriter` owns this debounce state and serialises writes. - **Auto-recreate:** the file is opened create-if-missing (`mode=rwc`) and its schema re-migrated on every open, so if the archive was moved or removed since the last open, the next write recreates it. The writer also detects a missing file before writing and reopens first, so a moved-away file is recreated rather than lost. - **Optional expiry, validated at creation:** an optional `expiry` in the target's config JSON (e.g. `{"expiry":"720h"}`) is validated when the target is created (`ValidateArchiveExpiry`; bad values are rejected with a 400 at the add-target form, the Slack URL precedent). The default (missing, empty, or `"never"`) keeps rows forever with no pruning. When a positive duration is set, rows older than it (measured from each row's archived-at time) are pruned on every (re)open; because the file is reopened after writes, prune-on-open keeps the archive swept without a separate background sweeper. A set-but-invalid expiry in a stored config (unparseable, zero, or negative) is an error at delivery time too — never a silent default. - **No-retry, fail-loud:** the target performs a single attempt with no retries. On success it records one successful attempt and marks the delivery delivered. If the archive write fails, the attempt is recorded as failed with the error and the delivery is marked failed — archiving errors never report success. ## Scope - `internal/delivery/target_database.go` — the `databaseTarget` (no-retry) archives via a per-webhook writer registry; an archive error records a failed attempt and marks the delivery failed. - `internal/delivery/target_database_archive.go` (new) — the `archiveWriter`, the archived-row model, config/expiry parsing (fail-loud on set-but-invalid values), `ValidateArchiveExpiry`, and prune-on-open. - `internal/handlers/source_management.go` — database targets get a creation-validated `expiry` config (`buildDatabaseTargetConfig`); the expiry form value is read where the request body is bounded and bad values are rejected with a 400 at target creation. - `templates/source_detail.html` — the add-target form shows an expiry field for database targets. - `README.md` — the database-target documentation describes the archiving semantics. - `internal/delivery/export_test.go`, `internal/delivery/target_database_test.go`, `internal/handlers` tests — tests and their exported shims. No changes to the `Target` interface or other targets. ## Tests - a row is archived (both at the writer level and end-to-end through `Deliver`) - a forced archive failure (bad stored expiry config) yields a `Failed` delivery with a non-success `DeliveryResult` carrying the error and no archive file created - the file is recreated after removal, with only the post-removal row - the one-second reopen debounce (rapid writes reopen once; a write after the window reopens again) - expiry pruning removes rows older than the configured expiry - expiry config parsing (empty / `never` / duration accepted; unparseable, zero, and negative values error) - expiry validation at target creation (`TestValidateArchiveExpiry`; valid values build the config, bad values get a 400) ## Validation `docker build .` exits 0 (fmt-check, lint, test, build all pass). Closes #43
clawbot added 1 commit 2026-08-07 17:59:47 +02:00
Implement the database archiving target (closes #43)
All checks were successful
check / check (push) Successful in 5s
38cfe76d49
Author
Collaborator

File-by-file summary

  • internal/delivery/target_database.godatabaseTarget now holds a per-webhook archiveWriter registry (guarded by a mutex, lazily populated). Deliver archives the full event, then records one successful attempt and marks the delivery delivered; archiving errors are logged, not fatal. archive parses the optional expiry from d.Target.Config and hands the row to the webhook's writer. writerFor derives the archive path (archive-{webhookID}.db) next to the per-webhook event DB via the DB manager and caches one writer per webhook.
  • internal/delivery/target_database_archive.go (new) — the archiveWriter (per-file: serialised writes, create-if-missing open with schema migration, close/reopen with a 1s debounce, missing-file auto-recreate, prune-on-open); the archivedEvent row model; parseArchiveExpiry (empty / never / duration / invalid, default keep-forever); and the databaseTargetConfig JSON shape.
  • internal/delivery/export_test.go — exported test shims (ExportArchiveWriter, ExportArchivedEvent, ExportParseArchiveExpiry) so the writer mechanics can be driven from the black-box test package. (The internal package rejects new non-export_test.go white-box test files under testpackage.)
  • internal/delivery/target_database_test.go (new) — tests: end-to-end archive via Deliver, writer row-write, recreate-after-removal, the reopen debounce, expiry pruning, and expiry parsing.

No changes to the Target interface, other targets, or internal/config.

docker build

docker build . -t webhooker-issue43 exited 0 — make fmt-check, make lint, make test, and the build stages all passed. (The only output during the static link stage was benign glibc dlopen/getaddrinfo linker warnings, not errors.)

## File-by-file summary - `internal/delivery/target_database.go` — `databaseTarget` now holds a per-webhook `archiveWriter` registry (guarded by a mutex, lazily populated). `Deliver` archives the full event, then records one successful attempt and marks the delivery delivered; archiving errors are logged, not fatal. `archive` parses the optional expiry from `d.Target.Config` and hands the row to the webhook's writer. `writerFor` derives the archive path (`archive-{webhookID}.db`) next to the per-webhook event DB via the DB manager and caches one writer per webhook. - `internal/delivery/target_database_archive.go` (new) — the `archiveWriter` (per-file: serialised writes, create-if-missing open with schema migration, close/reopen with a 1s debounce, missing-file auto-recreate, prune-on-open); the `archivedEvent` row model; `parseArchiveExpiry` (empty / `never` / duration / invalid, default keep-forever); and the `databaseTargetConfig` JSON shape. - `internal/delivery/export_test.go` — exported test shims (`ExportArchiveWriter`, `ExportArchivedEvent`, `ExportParseArchiveExpiry`) so the writer mechanics can be driven from the black-box test package. (The internal package rejects new non-`export_test.go` white-box test files under `testpackage`.) - `internal/delivery/target_database_test.go` (new) — tests: end-to-end archive via `Deliver`, writer row-write, recreate-after-removal, the reopen debounce, expiry pruning, and expiry parsing. No changes to the `Target` interface, other targets, or `internal/config`. ## docker build `docker build . -t webhooker-issue43` exited 0 — `make fmt-check`, `make lint`, `make test`, and the build stages all passed. (The only output during the static link stage was benign glibc `dlopen`/`getaddrinfo` linker warnings, not errors.)
Author
Collaborator

Independent review — one required change before merge-ready

The archive mechanics are correct and well-tested; one behaviour needs fixing before this goes to @sneak.

Verified solid:

  • Per-webhook archiveWriter (a keyed map, each writer with its own mutex) serialises writes; the target's writers map is guarded — concurrency-safe across worker goroutines.
  • The full event is archived (body, headers, method, content type, webhook/entrypoint ids, archived-at) into a separate archive-{webhookID}.db beside the event DB.
  • Close and reopen after each write, debounced to once per second (TestArchiveWriter_ReopenDebounce); auto-recreate if the file is moved away (fileExists check plus mode=rwc open and AutoMigrate on every open — TestArchiveWriter_RecreatesAfterRemoval).
  • Expiry parsed from the per-target config ({"expiry":"720h"}; empty / "never" / non-positive means keep forever), and parseArchiveExpiry correctly FAILS on an unparseable expiry rather than silently defaulting — matches the config standard. Prune-on-open hard-deletes rows past the cutoff (archivedEvent has no soft-delete field), TestArchiveWriter_ExpiryPrune.

Required change (fail-loud):

  • In databaseTarget.Deliver, an archive error (including a bad expiry config) is logged but the delivery is still recorded as a SUCCESS and marked Delivered. That silently reports success when the target's actual job — archiving — failed, which is exactly the pattern we avoid. On an archive error, record the attempt as a FAILURE (success = false, with the error string) and set the delivery status to Failed, so the failure is visible in the delivery record, not only in the logs. This corrects my own instruction on the issue, which said "record success + mark delivered" without accounting for the failure case. Add a test asserting a forced archive failure yields a Failed delivery with the error recorded.

Reassigning to clawbot for the fix.

## Independent review — one required change before merge-ready The archive mechanics are correct and well-tested; one behaviour needs fixing before this goes to @sneak. Verified solid: - Per-webhook `archiveWriter` (a keyed map, each writer with its own mutex) serialises writes; the target's `writers` map is guarded — concurrency-safe across worker goroutines. - The full event is archived (body, headers, method, content type, webhook/entrypoint ids, archived-at) into a separate `archive-{webhookID}.db` beside the event DB. - Close and reopen after each write, debounced to once per second (`TestArchiveWriter_ReopenDebounce`); auto-recreate if the file is moved away (`fileExists` check plus `mode=rwc` open and AutoMigrate on every open — `TestArchiveWriter_RecreatesAfterRemoval`). - Expiry parsed from the per-target config (`{"expiry":"720h"}`; empty / `"never"` / non-positive means keep forever), and `parseArchiveExpiry` correctly FAILS on an unparseable expiry rather than silently defaulting — matches the config standard. Prune-on-open hard-deletes rows past the cutoff (`archivedEvent` has no soft-delete field), `TestArchiveWriter_ExpiryPrune`. Required change (fail-loud): - In `databaseTarget.Deliver`, an archive error (including a bad `expiry` config) is logged but the delivery is still recorded as a SUCCESS and marked `Delivered`. That silently reports success when the target's actual job — archiving — failed, which is exactly the pattern we avoid. On an archive error, record the attempt as a FAILURE (success = false, with the error string) and set the delivery status to `Failed`, so the failure is visible in the delivery record, not only in the logs. This corrects my own instruction on the issue, which said "record success + mark delivered" without accounting for the failure case. Add a test asserting a forced archive failure yields a `Failed` delivery with the error recorded. Reassigning to `clawbot` for the fix.
clawbot self-assigned this 2026-08-07 18:02:32 +02:00
Author
Collaborator

Handoff status: the required change from the review above is NOT yet applied — the rework was interrupted before it started. Everything else in this PR passed review (the archive mechanics are solid).

Next agent, to finish this PR:

  • In databaseTarget.Deliver (internal/delivery/target_database.go), when archive(d) returns an error, record the attempt as a FAILURE (call recordResult with success = false and the error string, matching the http/slack targets' failed-attempt convention) and set the delivery status to database.DeliveryStatusFailed, then return — instead of the current behaviour of logging and still marking Delivered.
  • Add a delivery-package test that forces an archive failure (e.g. a database target whose config is {"expiry":"nonsense"}, which makes archive return the parse error before writing) and asserts the delivery ends Failed with a non-success DeliveryResult.
  • Work on branch issue-43-database-archiving; validate with docker build . (the authoritative gate; host Go is 1.25 but go.mod needs 1.26); push to the same branch to update this PR; then re-review before assigning @sneak.
Handoff status: the required change from the review above is NOT yet applied — the rework was interrupted before it started. Everything else in this PR passed review (the archive mechanics are solid). Next agent, to finish this PR: - In `databaseTarget.Deliver` (`internal/delivery/target_database.go`), when `archive(d)` returns an error, record the attempt as a FAILURE (call `recordResult` with success = false and the error string, matching the http/slack targets' failed-attempt convention) and set the delivery status to `database.DeliveryStatusFailed`, then return — instead of the current behaviour of logging and still marking `Delivered`. - Add a delivery-package test that forces an archive failure (e.g. a database target whose config is `{"expiry":"nonsense"}`, which makes `archive` return the parse error before writing) and asserts the delivery ends `Failed` with a non-success `DeliveryResult`. - Work on branch `issue-43-database-archiving`; validate with `docker build .` (the authoritative gate; host Go is 1.25 but go.mod needs 1.26); push to the same branch to update this PR; then re-review before assigning @sneak.
Author
Collaborator

Independent adversarial review (second, independent pass)

Reviewed the full diff at head 38cfe76 against issue #43 (sneak's authoritative spec and the implementation comment), REPO_POLICIES.md, and repo conventions. Independent of the earlier review on this PR; I re-derived every conclusion from the code.

Verified correct:

  • Mergeable against current main (base 81413c5 is main HEAD); CI green on head 38cfe76.
  • Archive mechanics match the spec: full event (body, headers, method, content type, webhook/entrypoint/event ids, archived-at) written to archive-{webhookID}.db, distinct from the event DB, created with schema on first use (mode=rwc open + AutoMigrate on every open).
  • Close/reopen after each write with the 1-second debounce, and auto-recreate when the file is moved away (both the fileExists pre-write check and create-if-missing open). Covered by TestArchiveWriter_ReopenDebounce and TestArchiveWriter_RecreatesAfterRemoval.
  • Concurrency: one archiveWriter per webhook (mutex-guarded registry, per-writer mutex), safe across worker goroutines.
  • Expiry pruning on (re)open with hard deletes (archivedEvent has no soft-delete column, so gorm deletes rows for real); parseArchiveExpiry returns an error on an unparseable expiry rather than silently defaulting.
  • I verified Target.Config actually reaches Deliver on all paths: the normal path loads the target row, and buildTargetFromTask/buildRecoveryTask carry TargetConfig through the task structs.
  • Tests are meaningful and cover every DoD bullet on the issue; commit hygiene fine (single commit, subject ends with (closes #43), no AI/tooling references anywhere).

Required changes:

  1. Archive failure must fail the delivery (concurring with the earlier review on this PR; still unapplied). Deliver currently logs an archive error and then unconditionally records a successful attempt and marks the delivery Delivered. The delivery record then claims the target did its job when it did not — the silent-success pattern this repo explicitly rejects. On error from archive(d): record the attempt with success=false and the error string (the http/slack failed-attempt convention) and set DeliveryStatusFailed, then return. Failed is terminal in the engine (only retrying rows are swept), so the failure stays visible in the UI/delivery history. Needs a test forcing an archive failure (e.g. config {"expiry":"nonsense"}) asserting a Failed delivery and a non-success DeliveryResult.

  2. The expiry is not actually configurable, and a bad expiry is only discovered at delivery time. The issue says "configurable expiry (default: never)", but buildTargetConfig (internal/handlers/source_management.go) returns an empty config for database targets and the add-target form in templates/source_detail.html has no expiry field — there is no way to set the expiry this PR implements, short of hand-editing the DB. The config must be settable at target creation and validated there, mirroring the Slack precedent (Slack target URLs are validated at creation, #68/#73): an expiry field for database targets, rejected with a 400 at creation when unparseable, so a bad value fails loudly at the only place a human can fix it instead of failing every subsequent delivery.

Non-blocking notes (follow-up material, not gating):

  • The per-target writers map is never evicted when a webhook is deleted, so a long-lived process keeps one open SQLite handle per deleted webhook. Worth a follow-up issue.
  • In archiveWriter.write, a reopen error after a successfully written row fails the write; with change 1 applied that marks the delivery Failed even though the row was archived. Conservative and acceptable — a reopen failure means the next write is in danger anyway.
  • Prune runs only on (re)open, so an archive that stops receiving writes is not pruned until the next write. The issue explicitly allows this mechanism and the code documents it.

Verdict: needs-rework for the two required changes. I will apply them on this branch myself; the PR then goes back to needs-review for a fresh independent pass rather than merge-ready.

## Independent adversarial review (second, independent pass) Reviewed the full diff at head 38cfe76 against issue #43 (sneak's authoritative spec and the implementation comment), REPO_POLICIES.md, and repo conventions. Independent of the earlier review on this PR; I re-derived every conclusion from the code. Verified correct: - Mergeable against current `main` (base 81413c5 is `main` HEAD); CI green on head 38cfe76. - Archive mechanics match the spec: full event (body, headers, method, content type, webhook/entrypoint/event ids, archived-at) written to `archive-{webhookID}.db`, distinct from the event DB, created with schema on first use (`mode=rwc` open + AutoMigrate on every open). - Close/reopen after each write with the 1-second debounce, and auto-recreate when the file is moved away (both the `fileExists` pre-write check and create-if-missing open). Covered by `TestArchiveWriter_ReopenDebounce` and `TestArchiveWriter_RecreatesAfterRemoval`. - Concurrency: one `archiveWriter` per webhook (mutex-guarded registry, per-writer mutex), safe across worker goroutines. - Expiry pruning on (re)open with hard deletes (`archivedEvent` has no soft-delete column, so gorm deletes rows for real); `parseArchiveExpiry` returns an error on an unparseable expiry rather than silently defaulting. - I verified `Target.Config` actually reaches `Deliver` on all paths: the normal path loads the target row, and `buildTargetFromTask`/`buildRecoveryTask` carry `TargetConfig` through the task structs. - Tests are meaningful and cover every DoD bullet on the issue; commit hygiene fine (single commit, subject ends with `(closes #43)`, no AI/tooling references anywhere). Required changes: 1. Archive failure must fail the delivery (concurring with the earlier review on this PR; still unapplied). `Deliver` currently logs an archive error and then unconditionally records a successful attempt and marks the delivery `Delivered`. The delivery record then claims the target did its job when it did not — the silent-success pattern this repo explicitly rejects. On error from `archive(d)`: record the attempt with success=false and the error string (the http/slack failed-attempt convention) and set `DeliveryStatusFailed`, then return. `Failed` is terminal in the engine (only `retrying` rows are swept), so the failure stays visible in the UI/delivery history. Needs a test forcing an archive failure (e.g. config `{"expiry":"nonsense"}`) asserting a `Failed` delivery and a non-success `DeliveryResult`. 2. The expiry is not actually configurable, and a bad expiry is only discovered at delivery time. The issue says "configurable expiry (default: never)", but `buildTargetConfig` (internal/handlers/source_management.go) returns an empty config for database targets and the add-target form in `templates/source_detail.html` has no expiry field — there is no way to set the expiry this PR implements, short of hand-editing the DB. The config must be settable at target creation and validated there, mirroring the Slack precedent (Slack target URLs are validated at creation, #68/#73): an expiry field for database targets, rejected with a 400 at creation when unparseable, so a bad value fails loudly at the only place a human can fix it instead of failing every subsequent delivery. Non-blocking notes (follow-up material, not gating): - The per-target `writers` map is never evicted when a webhook is deleted, so a long-lived process keeps one open SQLite handle per deleted webhook. Worth a follow-up issue. - In `archiveWriter.write`, a reopen error after a successfully written row fails the write; with change 1 applied that marks the delivery `Failed` even though the row was archived. Conservative and acceptable — a reopen failure means the next write is in danger anyway. - Prune runs only on (re)open, so an archive that stops receiving writes is not pruned until the next write. The issue explicitly allows this mechanism and the code documents it. Verdict: needs-rework for the two required changes. I will apply them on this branch myself; the PR then goes back to needs-review for a fresh independent pass rather than merge-ready.
clawbot added the needs-rework label 2026-08-07 18:30:55 +02:00
clawbot added 1 commit 2026-08-07 18:36:44 +02:00
Fail deliveries on archive errors; validate expiry at creation (#43)
Some checks failed
check / check (push) Failing after 57s
d35ad0c49e
Two review findings on the database archiving target:

- An archive error now records the attempt as failed with the
  error string and marks the delivery failed, instead of logging
  the error and reporting success. A target that could not do its
  one job must not claim it did.
- The archive expiry is now actually configurable: the add-target
  form gains an expiry field for database targets, and the value
  is validated at creation time via the new
  delivery.ValidateArchiveExpiry (empty, "never", or a positive
  Go duration), rejecting bad values with a 400 at the only place
  a human can fix them, mirroring how Slack target URLs are
  validated at creation.

Test updates: a forced archive failure asserts a failed delivery
with a recorded error and no archive file; config builder tests
cover empty/never/duration and rejection paths; the two engine
tests that exercise the database target now build engines with a
real webhook DB manager since archiving is no longer a no-op; the
reopen-debounce test uses a wider window so parallel test load
cannot make two rapid writes straddle it.
Author
Collaborator

Rework applied

Pushed d35ad0c addressing both required changes from the review above:

  1. Fail-loud delivery failure (internal/delivery/target_database.go): Deliver now records the attempt with success=false and the error string, marks the delivery Failed, and returns when archive(d) errors — matching the http/slack failed-attempt convention. Success is only reported when the archive write actually happened. New test TestDeliverDatabase_ArchiveFailureFailsDelivery forces a failure via config {"expiry":"nonsense"} and asserts the Failed status, the non-success DeliveryResult carrying the error, and that no archive file was created.

  2. Configurable, creation-validated expiry:

    • internal/delivery/target_database_archive.go: new exported ValidateArchiveExpiry — accepts empty, never, or a positive Go duration; rejects unparseable, zero, and negative values.
    • internal/handlers/source_management.go: buildTargetConfig routes database targets to a new buildDatabaseTargetConfig, which validates the expiry form value and rejects bad values with a 400 at creation (the Slack URL precedent); empty means the keep-forever default with an empty config.
    • templates/source_detail.html: the add-target form shows an expiry field when the database type is selected, with a hint (never default, or a duration like 720h).
    • Tests: TestValidateArchiveExpiry (valid/invalid table), TestBuildDatabaseTargetConfig_Valid, and TestBuildDatabaseTargetConfig_RejectsBadExpiry (asserts the 400).

Collateral test maintenance: TestDeliverDatabase_ImmediateSuccess and TestProcessDelivery_RoutesToCorrectHandler previously passed only because the archive failure (no DB manager in their test engines) was silently swallowed; they now build engines with a real temp-dir webhook DB manager. The shared delivery-seeding block was extracted into seedDatabaseTargetDelivery. The reopen-debounce test's 20 ms window was flaky under parallel test load (observed a real failure locally); it now uses a 2 s window.

Validation: make fmt applied; make test and make fmt-check pass locally. Host linter shows only the pre-existing goconst skew findings (host linter is newer than the pinned CI image); none in the rework. CI on the new head is the authoritative gate.

Setting needs-review: this rework was authored by me, so a fresh independent review must gate merge-ready. The two non-blocking notes from the review (writer eviction on webhook deletion; prune-on-open idle behaviour) remain follow-up material.

## Rework applied Pushed d35ad0c addressing both required changes from the review above: 1. Fail-loud delivery failure (`internal/delivery/target_database.go`): `Deliver` now records the attempt with success=false and the error string, marks the delivery `Failed`, and returns when `archive(d)` errors — matching the http/slack failed-attempt convention. Success is only reported when the archive write actually happened. New test `TestDeliverDatabase_ArchiveFailureFailsDelivery` forces a failure via config `{"expiry":"nonsense"}` and asserts the `Failed` status, the non-success `DeliveryResult` carrying the error, and that no archive file was created. 2. Configurable, creation-validated expiry: - `internal/delivery/target_database_archive.go`: new exported `ValidateArchiveExpiry` — accepts empty, `never`, or a positive Go duration; rejects unparseable, zero, and negative values. - `internal/handlers/source_management.go`: `buildTargetConfig` routes database targets to a new `buildDatabaseTargetConfig`, which validates the `expiry` form value and rejects bad values with a 400 at creation (the Slack URL precedent); empty means the keep-forever default with an empty config. - `templates/source_detail.html`: the add-target form shows an expiry field when the database type is selected, with a hint (`never` default, or a duration like `720h`). - Tests: `TestValidateArchiveExpiry` (valid/invalid table), `TestBuildDatabaseTargetConfig_Valid`, and `TestBuildDatabaseTargetConfig_RejectsBadExpiry` (asserts the 400). Collateral test maintenance: `TestDeliverDatabase_ImmediateSuccess` and `TestProcessDelivery_RoutesToCorrectHandler` previously passed only because the archive failure (no DB manager in their test engines) was silently swallowed; they now build engines with a real temp-dir webhook DB manager. The shared delivery-seeding block was extracted into `seedDatabaseTargetDelivery`. The reopen-debounce test's 20 ms window was flaky under parallel test load (observed a real failure locally); it now uses a 2 s window. Validation: `make fmt` applied; `make test` and `make fmt-check` pass locally. Host linter shows only the pre-existing `goconst` skew findings (host linter is newer than the pinned CI image); none in the rework. CI on the new head is the authoritative gate. Setting needs-review: this rework was authored by me, so a fresh independent review must gate merge-ready. The two non-blocking notes from the review (writer eviction on webhook deletion; prune-on-open idle behaviour) remain follow-up material.
clawbot added needs-review and removed needs-rework labels 2026-08-07 18:37:01 +02:00
clawbot added 1 commit 2026-08-07 18:59:29 +02:00
Read the expiry form value where the body is bounded (#43)
All checks were successful
check / check (push) Successful in 2m36s
7ca62664d0
The pinned CI linter's gosec G120 flagged r.FormValue in
buildDatabaseTargetConfig because the MaxBytesReader guard lives
one function up in processTargetCreate, out of static-analysis
sight. Read the expiry alongside the other form values in
processTargetCreate and pass it down as a string, matching how
the http and slack config builders receive their URL.
Author
Collaborator

CI on d35ad0c failed in the Docker lint stage: the pinned linter's gosec G120 flagged r.FormValue("expiry") inside buildDatabaseTargetConfig (it cannot see the http.MaxBytesReader guard applied one function up in processTargetCreate; the host linter did not flag this, which is why it slipped past local validation).

Pushed 7ca6266: the expiry form value is now read in processTargetCreate alongside the other form values — inside the function where the body is bounded — and passed down to buildDatabaseTargetConfig as a string, matching how the http and slack builders receive their URL. The test helper that built a form request was removed accordingly; the config-builder tests now pass the expiry string directly.

Re-validated: make test and make fmt-check green locally, and docker build . (the exact CI gate, including the pinned-linter stage that failed) exits 0 on 7ca6266. Label stays needs-review.

CI on d35ad0c failed in the Docker lint stage: the pinned linter's `gosec` G120 flagged `r.FormValue("expiry")` inside `buildDatabaseTargetConfig` (it cannot see the `http.MaxBytesReader` guard applied one function up in `processTargetCreate`; the host linter did not flag this, which is why it slipped past local validation). Pushed 7ca6266: the expiry form value is now read in `processTargetCreate` alongside the other form values — inside the function where the body is bounded — and passed down to `buildDatabaseTargetConfig` as a string, matching how the http and slack builders receive their URL. The test helper that built a form request was removed accordingly; the config-builder tests now pass the expiry string directly. Re-validated: `make test` and `make fmt-check` green locally, and `docker build .` (the exact CI gate, including the pinned-linter stage that failed) exits 0 on 7ca6266. Label stays needs-review.
Author
Collaborator

Independent adversarial review (third pass) — head 7ca6266

Verdict: FAIL — needs-rework. The code is sound and both previously required changes are genuinely applied and tested; the remaining defects are documentation that contradicts the shipped behaviour.

Verified on this head (re-derived from the code, not taken from the rework comments):

  • Prior required change 1 (fail-loud) is applied. databaseTarget.Deliver on an archive(d) error records the attempt with success=false and the error string via recordResult and marks the delivery DeliveryStatusFailed, then returns — matching the http/slack failed-attempt convention. TestDeliverDatabase_ArchiveFailureFailsDelivery forces the failure via {"expiry":"nonsense"} and asserts the Failed status, the non-success result carrying the error, and no archive file.
  • Prior required change 2 (configurable, creation-validated expiry) is applied. ValidateArchiveExpiry accepts empty/never/positive Go duration and rejects unparseable, zero, and negative values; buildDatabaseTargetConfig rejects bad values with a 400 at creation; the add-target form has the expiry field for database targets. Covered by TestValidateArchiveExpiry, TestBuildDatabaseTargetConfig_Valid, and TestBuildDatabaseTargetConfig_RejectsBadExpiry. Creation is the only config-writing path (no target-edit handler exists), so creation-time validation covers the UI surface.
  • The gosec G120 workaround (7ca6266) is sound. The expiry form value is read in processTargetCreate, where http.MaxBytesReader bounds the body, and passed down as a plain string — the same shape as the http/slack URL parameter.
  • Archive mechanics re-verified: separate archive-{webhookID}.db beside the event DB; full event row; create-if-missing open (mode=rwc) plus AutoMigrate on every open; close/reopen after write debounced to 1s; missing-file pre-write check; prune-on-open with hard deletes. One databaseTarget instance per engine (internal/delivery/target.go line 98), so the mutex-guarded writer registry and per-writer debounce state are genuinely shared across concurrent deliveries. Event.WebhookID and Target.Config reach Deliver on the new-task, retry, and recovery paths.
  • CI green on head 7ca6266 (check / check, 2m36s); mergeable against main (base 81413c5 is main HEAD); make test and make fmt-check pass locally on the head; commit subjects follow convention with (closes #43) on the landing commit; no AI/tooling references anywhere in the diff or commit messages.

Required changes:

  1. README.md (lines ~515–519) still documents the rejected stub semantics. It reads: "the database target simply marks the delivery as immediately successful. The per-webhook DB IS the dedicated event database — that's the whole point of the database target type." That is verbatim the claim issue #43 was opened to reject ("no."), and after this PR it affirmatively contradicts the shipped behaviour. README is the repo's primary documentation; rewrite the paragraph to describe the actual semantics: separate per-webhook archive-{webhookID}.db, close/reopen after each write with the 1-second debounce, auto-recreate when the file is moved away for offline archiving, optional creation-validated expiry (default never), and archive failure failing the delivery.

  2. The PR description is stale from before the d35ad0c rework, and this repo squash-merges. The "Fire-and-forget" bullet still says "Archiving errors are logged but do not fail the delivery" — the exact behaviour d35ad0c removed — and the Scope and Tests sections omit the internal/handlers/templates expiry-validation changes and the failure-path tests. Update the PR body to match the code as it stands so the squash-merge record is not wrong on day one.

Minor (fix while in there, or explicitly defer with a note):

  1. parseArchiveExpiry (internal/delivery/target_database_archive.go) silently maps a set-but-non-positive duration ("0s", "-5h") to keep-forever (dur <= 0 returns 0, nil), while ValidateArchiveExpiry rejects exactly those values at creation. Unreachable via the UI today, but a hand-edited Target.Config would silently default — the pattern this repo rejects. Return errArchiveExpiryNotPositive there too (the delivery path already fails loud on parse errors); adjust the "zero duration" case in TestParseArchiveExpiry accordingly.

  2. The databaseTarget type comment (internal/delivery/target_database.go) still opens with "fire-and-forget", which now misdescribes a target whose failures fail the delivery. The Deliver method comment already gets it right; call the type "no-retry" instead.

Non-blocking, carried from the previous review (still valid, still untracked):

  • The per-target writers map is never evicted when a webhook is deleted, so a long-lived process keeps one cached writer (and possibly an open handle) per deleted webhook. Worth opening the follow-up issue now so it does not get lost.
  • Prune runs only on (re)open, so an archive that stops receiving writes is not pruned until its next write. Documented in the code; the issue allows this mechanism.
## Independent adversarial review (third pass) — head 7ca6266 **Verdict: FAIL — needs-rework.** The code is sound and both previously required changes are genuinely applied and tested; the remaining defects are documentation that contradicts the shipped behaviour. Verified on this head (re-derived from the code, not taken from the rework comments): - **Prior required change 1 (fail-loud) is applied.** `databaseTarget.Deliver` on an `archive(d)` error records the attempt with success=false and the error string via `recordResult` and marks the delivery `DeliveryStatusFailed`, then returns — matching the http/slack failed-attempt convention. `TestDeliverDatabase_ArchiveFailureFailsDelivery` forces the failure via `{"expiry":"nonsense"}` and asserts the `Failed` status, the non-success result carrying the error, and no archive file. - **Prior required change 2 (configurable, creation-validated expiry) is applied.** `ValidateArchiveExpiry` accepts empty/`never`/positive Go duration and rejects unparseable, zero, and negative values; `buildDatabaseTargetConfig` rejects bad values with a 400 at creation; the add-target form has the expiry field for database targets. Covered by `TestValidateArchiveExpiry`, `TestBuildDatabaseTargetConfig_Valid`, and `TestBuildDatabaseTargetConfig_RejectsBadExpiry`. Creation is the only config-writing path (no target-edit handler exists), so creation-time validation covers the UI surface. - **The gosec G120 workaround (7ca6266) is sound.** The expiry form value is read in `processTargetCreate`, where `http.MaxBytesReader` bounds the body, and passed down as a plain string — the same shape as the http/slack URL parameter. - Archive mechanics re-verified: separate `archive-{webhookID}.db` beside the event DB; full event row; create-if-missing open (`mode=rwc`) plus AutoMigrate on every open; close/reopen after write debounced to 1s; missing-file pre-write check; prune-on-open with hard deletes. One `databaseTarget` instance per engine (`internal/delivery/target.go` line 98), so the mutex-guarded writer registry and per-writer debounce state are genuinely shared across concurrent deliveries. `Event.WebhookID` and `Target.Config` reach `Deliver` on the new-task, retry, and recovery paths. - CI green on head 7ca6266 (`check / check`, 2m36s); mergeable against `main` (base 81413c5 is `main` HEAD); `make test` and `make fmt-check` pass locally on the head; commit subjects follow convention with `(closes #43)` on the landing commit; no AI/tooling references anywhere in the diff or commit messages. Required changes: 1. **`README.md` (lines ~515–519) still documents the rejected stub semantics.** It reads: "the database target simply marks the delivery as immediately successful. The per-webhook DB IS the dedicated event database — that's the whole point of the database target type." That is verbatim the claim issue #43 was opened to reject ("no."), and after this PR it affirmatively contradicts the shipped behaviour. README is the repo's primary documentation; rewrite the paragraph to describe the actual semantics: separate per-webhook `archive-{webhookID}.db`, close/reopen after each write with the 1-second debounce, auto-recreate when the file is moved away for offline archiving, optional creation-validated expiry (default never), and archive failure failing the delivery. 2. **The PR description is stale from before the d35ad0c rework, and this repo squash-merges.** The "Fire-and-forget" bullet still says "Archiving errors are logged but do not fail the delivery" — the exact behaviour d35ad0c removed — and the Scope and Tests sections omit the `internal/handlers`/`templates` expiry-validation changes and the failure-path tests. Update the PR body to match the code as it stands so the squash-merge record is not wrong on day one. Minor (fix while in there, or explicitly defer with a note): 3. `parseArchiveExpiry` (`internal/delivery/target_database_archive.go`) silently maps a set-but-non-positive duration (`"0s"`, `"-5h"`) to keep-forever (`dur <= 0` returns `0, nil`), while `ValidateArchiveExpiry` rejects exactly those values at creation. Unreachable via the UI today, but a hand-edited `Target.Config` would silently default — the pattern this repo rejects. Return `errArchiveExpiryNotPositive` there too (the delivery path already fails loud on parse errors); adjust the `"zero duration"` case in `TestParseArchiveExpiry` accordingly. 4. The `databaseTarget` type comment (`internal/delivery/target_database.go`) still opens with "fire-and-forget", which now misdescribes a target whose failures fail the delivery. The `Deliver` method comment already gets it right; call the type "no-retry" instead. Non-blocking, carried from the previous review (still valid, still untracked): - The per-target `writers` map is never evicted when a webhook is deleted, so a long-lived process keeps one cached writer (and possibly an open handle) per deleted webhook. Worth opening the follow-up issue now so it does not get lost. - Prune runs only on (re)open, so an archive that stops receiving writes is not pruned until its next write. Documented in the code; the issue allows this mechanism.
clawbot added needs-rework and removed needs-review labels 2026-08-07 19:11:29 +02:00
Author
Collaborator

Manager note: the fresh full review above (comment 43909) confirms both prior required changes are genuinely in the code at 7ca6266 (fail-loud archive failures; creation-validated expiry) but FAILS the PR on documentation contradicting the shipped behavior. Label set to needs-rework; dispatching rework for:

  1. README archiving section (~lines 515-519) still describes the old always-successful stub semantics — rewrite to match the shipped archive behavior.
  2. Stale PR description: the "Fire-and-forget" bullet describes removed behavior; Scope/Tests omit the expiry-validation work in internal/handlers/templates and the failure-path tests. Must be corrected since squash-merge makes the body the merge record.
  3. parseArchiveExpiry silently maps set-but-non-positive durations to keep-forever at read time while creation validation rejects them — a hand-edited config silently defaults, which this repo treats as a defect. Fail loud instead.
  4. databaseTarget type comment still says "fire-and-forget" — now inaccurate.

The carried-forward non-blocking findings (writer eviction on webhook deletion; idle archives never pruned) are now tracked as #89 and stay out of this PR's scope. A fresh reviewer will re-review after the rework lands.

Manager note: the fresh full review above (comment 43909) confirms both prior required changes are genuinely in the code at 7ca6266 (fail-loud archive failures; creation-validated expiry) but FAILS the PR on documentation contradicting the shipped behavior. Label set to `needs-rework`; dispatching rework for: 1. README archiving section (~lines 515-519) still describes the old always-successful stub semantics — rewrite to match the shipped archive behavior. 2. Stale PR description: the "Fire-and-forget" bullet describes removed behavior; Scope/Tests omit the expiry-validation work in `internal/handlers`/`templates` and the failure-path tests. Must be corrected since squash-merge makes the body the merge record. 3. `parseArchiveExpiry` silently maps set-but-non-positive durations to keep-forever at read time while creation validation rejects them — a hand-edited config silently defaults, which this repo treats as a defect. Fail loud instead. 4. `databaseTarget` type comment still says "fire-and-forget" — now inaccurate. The carried-forward non-blocking findings (writer eviction on webhook deletion; idle archives never pruned) are now tracked as #89 and stay out of this PR's scope. A fresh reviewer will re-review after the rework lands.
clawbot added 1 commit 2026-08-07 19:27:05 +02:00
Align docs with archive semantics; fail loud on non-positive expiry (#43)
All checks were successful
check / check (push) Successful in 2m39s
f5b4aec0bd
- README: rewrite the database-target documentation (target-types
  bullet and the per-webhook databases section) to describe the
  shipped archiving semantics -- separate archive-{webhookID}.db,
  debounced close/reopen for offline archiving, auto-recreate,
  creation-validated optional expiry with prune-on-open, and
  fail-loud delivery on archive write errors -- replacing the
  stale always-successful stub description.
- parseArchiveExpiry now returns an error for set-but-non-positive
  durations ("0s", "-5h") instead of silently defaulting to
  keep-forever, matching ValidateArchiveExpiry at creation time;
  the delivery then fails loudly like any other archive error.
  TestParseArchiveExpiry extended with zero and negative cases.
- databaseTarget type comment: "fire-and-forget" -> "no-retry",
  matching the fail-loud behaviour.
Author
Collaborator

Rework applied — head f5b4aec

Pushed f5b4aec addressing all four findings from the review (comment 43909):

  1. README archiving docs (required change 1): the stale stub paragraph in the per-webhook databases section ("simply marks the delivery as immediately successful ... the per-webhook DB IS the dedicated event database") is rewritten to describe the shipped semantics: full events archived as rows into a separate archive-{webhookID}.db under the data dir; handle closed/reopened after writes with a one-second debounce so an operator can move the file away for offline archiving; auto-recreated if moved or removed; optional expiry in the target config JSON validated at target creation (default or never keeps rows forever), with rows older than the expiry pruned on each (re)open; and an archive write failure recording a failed attempt and marking the delivery failed — never silent success. The database bullet in the Target types list, which made the same stale claim, is updated to match and points at the full section.

  2. Stale PR description (required change 2): the PR body is updated — the "Fire-and-forget" bullet (which claimed "archiving errors are logged but do not fail the delivery", behaviour removed in d35ad0c) is replaced by a "No-retry, fail-loud" bullet describing the actual behaviour, and the Scope and Tests sections now include the internal/handlers/templates/source_detail.html expiry-validation work and the failure-path tests. The squash-merge record now matches the code.

  3. parseArchiveExpiry silent default (minor 3): set-but-non-positive durations ("0s", "-5h") now return errArchiveExpiryNotPositive instead of silently mapping to keep-forever, consistent with ValidateArchiveExpiry at creation time; the resulting archive error fails the delivery loudly like any other. TestParseArchiveExpiry restructured with a wantErr column: zero and negative durations moved to the error cases and the unparseable case folded into the table.

  4. databaseTarget type comment (minor 4): now opens with "no-retry" instead of "fire-and-forget", matching the Deliver comment and the fail-loud behaviour.

The two carried-forward non-blocking notes (writer eviction on webhook deletion; idle archives not pruned until the next write) are tracked as #89 and stay out of this PR per the manager note.

Validation on f5b4aec: make fmt applied; make test green; script/cibuild (the docker-based CI gate, including the pinned-linter stage) exits 0. The host linter shows only the pre-existing goconst version-skew findings, none introduced by this rework.

Not touching labels or assignees per the dispatch instructions; a fresh reviewer re-reviews from here.

## Rework applied — head f5b4aec Pushed f5b4aec addressing all four findings from the review (comment 43909): 1. **README archiving docs (required change 1):** the stale stub paragraph in the per-webhook databases section ("simply marks the delivery as immediately successful ... the per-webhook DB IS the dedicated event database") is rewritten to describe the shipped semantics: full events archived as rows into a separate `archive-{webhookID}.db` under the data dir; handle closed/reopened after writes with a one-second debounce so an operator can move the file away for offline archiving; auto-recreated if moved or removed; optional `expiry` in the target config JSON validated at target creation (default or `never` keeps rows forever), with rows older than the expiry pruned on each (re)open; and an archive write failure recording a failed attempt and marking the delivery failed — never silent success. The `database` bullet in the Target types list, which made the same stale claim, is updated to match and points at the full section. 2. **Stale PR description (required change 2):** the PR body is updated — the "Fire-and-forget" bullet (which claimed "archiving errors are logged but do not fail the delivery", behaviour removed in d35ad0c) is replaced by a "No-retry, fail-loud" bullet describing the actual behaviour, and the Scope and Tests sections now include the `internal/handlers`/`templates/source_detail.html` expiry-validation work and the failure-path tests. The squash-merge record now matches the code. 3. **`parseArchiveExpiry` silent default (minor 3):** set-but-non-positive durations (`"0s"`, `"-5h"`) now return `errArchiveExpiryNotPositive` instead of silently mapping to keep-forever, consistent with `ValidateArchiveExpiry` at creation time; the resulting archive error fails the delivery loudly like any other. `TestParseArchiveExpiry` restructured with a `wantErr` column: zero and negative durations moved to the error cases and the unparseable case folded into the table. 4. **`databaseTarget` type comment (minor 4):** now opens with "no-retry" instead of "fire-and-forget", matching the `Deliver` comment and the fail-loud behaviour. The two carried-forward non-blocking notes (writer eviction on webhook deletion; idle archives not pruned until the next write) are tracked as #89 and stay out of this PR per the manager note. Validation on f5b4aec: `make fmt` applied; `make test` green; `script/cibuild` (the docker-based CI gate, including the pinned-linter stage) exits 0. The host linter shows only the pre-existing `goconst` version-skew findings, none introduced by this rework. Not touching labels or assignees per the dispatch instructions; a fresh reviewer re-reviews from here.
clawbot added needs-review and removed needs-rework labels 2026-08-07 19:27:56 +02:00
Author
Collaborator

Independent adversarial review (fourth pass) — head f5b4aec

Verdict: PASS.

Every finding from all prior review rounds was re-verified in the code at f5b4aec, not taken from the rework summaries:

  • Fail-loud archive failures (rounds 1-2): databaseTarget.Deliver on an archive(d) error records the attempt via recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0) — argument order checked against the recordResult signature in internal/delivery/engine.go — and marks the delivery DeliveryStatusFailed, then returns. TestDeliverDatabase_ArchiveFailureFailsDelivery asserts the Failed status, the non-success result carrying the error, and no archive file.
  • Creation-validated configurable expiry (round 2): ValidateArchiveExpiry accepts empty/never/positive duration and rejects unparseable, zero, and negative; buildDatabaseTargetConfig returns a 400 on bad values; the add-target form in templates/source_detail.html exposes the field for database targets. Covered by TestValidateArchiveExpiry, TestBuildDatabaseTargetConfig_Valid, TestBuildDatabaseTargetConfig_RejectsBadExpiry.
  • gosec G120 workaround (7ca6266): the expiry form value is read in processTargetCreate, where http.MaxBytesReader bounds the body, and passed down as a plain string.
  • README (round 3, required 1): the stale stub paragraphs are gone. Both the target-types bullet and the per-webhook databases section now describe the shipped semantics — separate archive-{webhookID}.db, debounced close/reopen, auto-recreate, creation-validated optional expiry with prune-on-open, archive failure failing the delivery. I checked each README claim against the code; all accurate.
  • PR body (round 3, required 2): the fire-and-forget bullet is gone; the body now describes no-retry/fail-loud behaviour, and Scope and Tests include the internal/handlers/templates expiry work and the failure-path tests. Verified line by line against the diff — the squash-merge record will be accurate.
  • parseArchiveExpiry (round 3, minor 3): set-but-non-positive durations now return errArchiveExpiryNotPositive instead of silently defaulting to keep-forever; TestParseArchiveExpiry has wantErr cases for "0s" and "-5h". Read-path and creation-path validation now agree; a hand-edited stored config fails the delivery loudly.
  • Type comment (round 3, minor 4): databaseTarget now opens with "no-retry"; no "fire-and-forget" left on this target.

Fresh adversarial pass on the full diff vs main (81413c5):

  • Archive mechanics: full event row (body, headers, method, content type, webhook/entrypoint/event ids, archived-at) matches the Event model fields; mode=rwc open plus AutoMigrate on every open covers create-if-missing and recreate-after-move; the pre-write fileExists check catches a file moved away while the handle is open. Debounce logic re-derived: first write opens once; writes inside the window skip the reopen; a write after the window closes/reopens exactly once — TestArchiveWriter_ReopenDebounce matches (and its 2s window is robust under parallel load).
  • Concurrency: one databaseTarget per engine (internal/delivery/target.go line 98), mutex-guarded writer registry, per-writer mutex serialising writes — safe across concurrent deliveries.
  • Driver registration: sql.Open("sqlite", ...) in the archive writer relies on the modernc driver, which the production binary registers via internal/database/database.go (imported by the delivery engine) — not only via the test file's blank import.
  • Prune-on-open: hard deletes (no soft-delete field on archivedEvent), failures logged but non-fatal (documented, spec allows the mechanism); TestArchiveWriter_ExpiryPrune is meaningful.
  • Plumbing: Event.WebhookID and Target.Config reach Deliver on new-task, retry, and recovery paths (TargetConfig carried through the task structs at engine.go lines 80/818/935).
  • Definition of done (issue #43): every bullet satisfied — separate per-webhook archive file created if absent; 1s-debounced close/reopen; recreate after removal; optional per-target expiry (default never) pruning older rows; tests cover row archived, recreate, debounce, and pruning.

Gates and hygiene:

  • CI green on head f5b4aec (check / check, 2m39s); script/cibuild (docker gate incl. pinned linter) exits 0 locally on the head.
  • Mergeable against main (base 81413c5 is main HEAD).
  • Commit subjects follow convention; the PR title (squash-merge subject) ends with (closes #43); no AI/tooling references or attribution trailers anywhere in the tree, diff, commit messages, or PR body.
  • No scope creep: no changes to the Target interface, other targets, or internal/config.

Non-blocking, already tracked: writer eviction on webhook deletion and idle archives not pruned until the next write are tracked as #89 and correctly out of this PR's scope.

No findings. This PR is merge-ready; leaving labels and assignees to the coordinating agent per dispatch instructions.

## Independent adversarial review (fourth pass) — head f5b4aec **Verdict: PASS.** Every finding from all prior review rounds was re-verified in the code at f5b4aec, not taken from the rework summaries: - **Fail-loud archive failures (rounds 1-2):** `databaseTarget.Deliver` on an `archive(d)` error records the attempt via `recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)` — argument order checked against the `recordResult` signature in `internal/delivery/engine.go` — and marks the delivery `DeliveryStatusFailed`, then returns. `TestDeliverDatabase_ArchiveFailureFailsDelivery` asserts the `Failed` status, the non-success result carrying the error, and no archive file. - **Creation-validated configurable expiry (round 2):** `ValidateArchiveExpiry` accepts empty/`never`/positive duration and rejects unparseable, zero, and negative; `buildDatabaseTargetConfig` returns a 400 on bad values; the add-target form in `templates/source_detail.html` exposes the field for database targets. Covered by `TestValidateArchiveExpiry`, `TestBuildDatabaseTargetConfig_Valid`, `TestBuildDatabaseTargetConfig_RejectsBadExpiry`. - **gosec G120 workaround (7ca6266):** the expiry form value is read in `processTargetCreate`, where `http.MaxBytesReader` bounds the body, and passed down as a plain string. - **README (round 3, required 1):** the stale stub paragraphs are gone. Both the target-types bullet and the per-webhook databases section now describe the shipped semantics — separate `archive-{webhookID}.db`, debounced close/reopen, auto-recreate, creation-validated optional expiry with prune-on-open, archive failure failing the delivery. I checked each README claim against the code; all accurate. - **PR body (round 3, required 2):** the fire-and-forget bullet is gone; the body now describes no-retry/fail-loud behaviour, and Scope and Tests include the `internal/handlers`/`templates` expiry work and the failure-path tests. Verified line by line against the diff — the squash-merge record will be accurate. - **`parseArchiveExpiry` (round 3, minor 3):** set-but-non-positive durations now return `errArchiveExpiryNotPositive` instead of silently defaulting to keep-forever; `TestParseArchiveExpiry` has `wantErr` cases for `"0s"` and `"-5h"`. Read-path and creation-path validation now agree; a hand-edited stored config fails the delivery loudly. - **Type comment (round 3, minor 4):** `databaseTarget` now opens with "no-retry"; no "fire-and-forget" left on this target. Fresh adversarial pass on the full diff vs `main` (81413c5): - **Archive mechanics:** full event row (body, headers, method, content type, webhook/entrypoint/event ids, archived-at) matches the `Event` model fields; `mode=rwc` open plus AutoMigrate on every open covers create-if-missing and recreate-after-move; the pre-write `fileExists` check catches a file moved away while the handle is open. Debounce logic re-derived: first write opens once; writes inside the window skip the reopen; a write after the window closes/reopens exactly once — `TestArchiveWriter_ReopenDebounce` matches (and its 2s window is robust under parallel load). - **Concurrency:** one `databaseTarget` per engine (`internal/delivery/target.go` line 98), mutex-guarded writer registry, per-writer mutex serialising writes — safe across concurrent deliveries. - **Driver registration:** `sql.Open("sqlite", ...)` in the archive writer relies on the modernc driver, which the production binary registers via `internal/database/database.go` (imported by the delivery engine) — not only via the test file's blank import. - **Prune-on-open:** hard deletes (no soft-delete field on `archivedEvent`), failures logged but non-fatal (documented, spec allows the mechanism); `TestArchiveWriter_ExpiryPrune` is meaningful. - **Plumbing:** `Event.WebhookID` and `Target.Config` reach `Deliver` on new-task, retry, and recovery paths (`TargetConfig` carried through the task structs at engine.go lines 80/818/935). - **Definition of done (issue #43):** every bullet satisfied — separate per-webhook archive file created if absent; 1s-debounced close/reopen; recreate after removal; optional per-target expiry (default never) pruning older rows; tests cover row archived, recreate, debounce, and pruning. Gates and hygiene: - CI green on head f5b4aec (`check / check`, 2m39s); `script/cibuild` (docker gate incl. pinned linter) exits 0 locally on the head. - Mergeable against `main` (base 81413c5 is `main` HEAD). - Commit subjects follow convention; the PR title (squash-merge subject) ends with `(closes #43)`; no AI/tooling references or attribution trailers anywhere in the tree, diff, commit messages, or PR body. - No scope creep: no changes to the `Target` interface, other targets, or `internal/config`. Non-blocking, already tracked: writer eviction on webhook deletion and idle archives not pruned until the next write are tracked as #89 and correctly out of this PR's scope. No findings. This PR is merge-ready; leaving labels and assignees to the coordinating agent per dispatch instructions.
clawbot removed their assignment 2026-08-07 19:31:02 +02:00
sneak was assigned by clawbot 2026-08-07 19:31:02 +02:00
clawbot added merge-ready and removed needs-review labels 2026-08-07 19:31:02 +02:00
Author
Collaborator

Manager note: fresh independent re-review above (comment 43954) PASSED the PR at head f5b4aec with zero findings — every prior-round requirement (fail-loud archive failures, creation-validated expiry, gosec G120 handling, README/PR-body accuracy, fail-loud parseArchiveExpiry) was verified in the code, CI is green on the head, and script/cibuild exited 0 for the reviewer independently. Setting merge-ready and assigning to sneak for merge (protected main).

Remaining non-blocking items are tracked as #89 (writer eviction on webhook deletion; idle-archive pruning) and stay out of this PR's scope.

Manager note: fresh independent re-review above (comment 43954) PASSED the PR at head f5b4aec with zero findings — every prior-round requirement (fail-loud archive failures, creation-validated expiry, gosec G120 handling, README/PR-body accuracy, fail-loud `parseArchiveExpiry`) was verified in the code, CI is green on the head, and `script/cibuild` exited 0 for the reviewer independently. Setting `merge-ready` and assigning to sneak for merge (protected `main`). Remaining non-blocking items are tracked as #89 (writer eviction on webhook deletion; idle-archive pruning) and stay out of this PR's scope.
sneak merged commit ee7c626071 into main 2026-08-07 22:50:08 +02:00
sneak deleted branch issue-43-database-archiving 2026-08-07 22:50:08 +02:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/webhooker#84