KoniKodeKnowledge BaseMy AccountOpen website

KNOWLEDGE BASE

Documentation

DOCUMENTATION
CURRENT APPLICATIONClean Framework
DocumentationClean Framework

Clean Framework · DOCUMENTATION

Optional atomic webhook adoption

Optional atomic webhook adoption

Bounded candidate reference — 2026-10-20: 0.2.0-rc.1 is committed locally through 84f415b3098af5653577b2c65eea31fca21df79c, unpublished and not production-approved; stable PHP >=8.5.11 <8.6.0 is required. This is documentation, not adoption: this knowledge base retains its installed 0.1.0-rc.1 dependency and PHP 8.2.11 local profile. Dated PHP 8.2 evidence is historical; earlier dirty-source reports retain their original test provenance, not retroactive clean-commit proof. Candidate examples are illustrative: API signatures were reviewed, but candidate code was not executed by this editorial update. See current checkpoint, open criteria and adoption boundary.

Status: local stabilization candidate, not a deployment approval. PHP >=8.5.11 <8.6.0; qualified fixture MySQL 8.4.11/InnoDB/REPEATABLE-READ. No provider/account, route, schema or worker is enabled by framework boot. Keep consequential processing disabled until the selected deployment, recovery, operator ownership and independent security gates are qualified.

Compatibility and schema

WebhookReceiver, WebhookLifecycle, WebhookEventStore and QueueWebhookEventDispatcher retain their old signatures/decisions for nonconsequential fixtures. Their split receipt/dispatch flow is NOT atomic and is not safe for consequential writes. The explicit red characterization remains opt-in, outside the green verification suite. Do not interpret its deliberate failures as a passing legacy capability.

Adopt resources/migrations/webhooks/20261017_001_atomic_receipts.php explicitly through the application's normal MigrationDiscovery/MigrationRunner and reviewed advisory lock/ledger. It is a new immutable, forward-only optional migration; it is not automatically included in the skeleton. MySQL DDL is nontransactional: if interrupted, inspect both schema and ledger before any retry. Do not edit an applied migration, drop committed receipts, or pretend a source rollback undoes DDL. Review a forward corrective migration if needed.

The selected ledger is cf_webhook_receipts; existing documented cf_outbox_messages and cf_jobs schemas/unique keys must also be provisioned and retained. All writes must use InnoDB. Custom table names are trusted constructor configuration, consistently supplied to every adapter, never HTTP parameters. Receipt identity is the exact binary tuple [environment, provider, opaque_account_reference, event_id], hashed from JSON for deterministic outbox/job keys. A changed body digest or event type is a conflict, not another delivery. Account reference comes from trusted application composition, never from the posted body or an unverified account header.

Composition example

Application factories supply the objects below; this is not a bootstrap script or authority to install a route. Each adapter owns its own short transactions. Do not call intake inside an existing transaction or share its Connection with unrelated concurrent work. After an uncertain commit discard that connection and reconcile using a fresh one.

use Clean\Framework\Integration\Webhook\DatabaseWebhookHandoff;
use Clean\Framework\Integration\Webhook\DatabaseWebhookQueueTransport;
use Clean\Framework\Integration\Webhook\DatabaseWebhookRecovery;
use Clean\Framework\Integration\Webhook\VerifiedWebhookPayloadStore;
use Clean\Framework\Integration\Webhook\WebhookHandoffPolicy;
use Clean\Framework\Storage\LocalPrivateStorage;
use Clean\Framework\Outbox\DatabaseOutboxStore;
use Clean\Framework\Outbox\OutboxDispatcher;
use Clean\Framework\Outbox\OutboxReconciliationService;

// The application validates qualification independently for DEV/STAGE/PROD.
// The default policy is disabled; never infer approval from a handler map.
$policy = new WebhookHandoffPolicy(
    $environment, $providerNamespace, $opaqueAccountReference,
    ['record.updated' => 'application.record.updated'],
    enabled: $qualifiedConsequentialIntakeEnabled
);
// Provision outside public/, private owner-only permissions, no script execution.
// One dedicated root belongs to ONE SQL receipt ledger. All participants must
// share this root and the local flock protocol; do not use unqualified NFS or
// split-host roots. Constructors do not create a route or contact a provider.
$payloads = new VerifiedWebhookPayloadStore(new LocalPrivateStorage($privateRoot));
$intake = new DatabaseWebhookHandoff(
    $connection, $clock, $policy, $signatureVerifier, $eventExtractor, $payloads
);
// Invoke after HTTP body bounds and trusted provider/account route selection.
$receipt = $intake->receive($exactRawBodyRequest);

// In separately supervised post-commit worker composition:
$outbox = new DatabaseOutboxStore($workerConnection, $clock);
$bridge = new DatabaseWebhookQueueTransport($workerConnection, $clock, $policy, $workerPayloads);
$dispatcher = new OutboxDispatcher($outbox, ['webhook.queue' => $bridge], $workerLabel);
$summary = $dispatcher->dispatch(50);
$reconciler = new OutboxReconciliationService($outbox, ['webhook.queue' => $bridge]);
$resolved = $reconciler->run(50); // UNKNOWN is checked, never blindly sent.

$recovery = new DatabaseWebhookRecovery($recoveryConnection, $clock, $policy, $recoveryPayloads);
$repairs = $recovery->repair(100);
$orphans = $recovery->cleanOrphans(100, 3600);

Each named Connection is independently owned, but each adapter constructs its receipt/outbox/queue operations on the SAME connection internally. Do not mix SQL targets that happen to have similarly named tables. Shared private-root ownership is an application deployment contract, not database discovery.

HTTP decisions and payloads

Return a small provider-specific successful acknowledgement only after receive() returns ACCEPTED/DUPLICATE, or durably retained unsupported UNKNOWN according to the explicit provider retention policy. Map WebhookVerificationException to the provider's documented non-success status. Map retryable WebhookIntakeException to sanitized non-2xx (normally 503); nonretryable configuration failures also must not return 2xx. Expose neither exception details/stack/SQL nor event identities, raw bodies, signatures, tokens, private paths or lease tokens. Use production zend.exception_ignore_args=1.

Signatures, extraction and immutable private staging happen before SQL. Intake holds a shared local storage guard through transaction outcome. Both receipt and outbox commit together. A thrown/lost commit acknowledgement is not success; retain candidate files and reconcile canonical identity on fresh-connection redelivery. Conflicts never replace canonical bytes. Missing/corrupt payloads deny acknowledgement and processing. Files are fsynced where available, but these tests do not qualify host loss, directory-fsync, NFS or power-loss durability.

Stages have finite body/object/total limits (defaults 1 MiB/1024/64 MiB); hitting capacity denies intake and must alert. Canonical objects count against capacity until the application's reviewed retention/archive procedure handles them. Do not simply increase limits without ownership/disk monitoring. Cleanup takes the exclusive guard, waits on SQL identity locks, and unlinks only after a confirmed unreferenced SQL check. It never deletes canonical data, including UNKNOWN/dead-letter/processed objects. Restarted recovery begins a safe sweep; reuse its instance across ticks until full keyset sweeps complete. Results are safe aggregate counts, not public object inventories. Off-host retention/restore is unqualified and must preserve matched schema, rows, tombstones and objects.

Processing, UNKNOWN and replay

The bridge creates one deterministic account-aware job; enqueue and receipt QUEUED status share its transaction. Re-delivery checks the existing immutable job rather than swallowing a unique-key error. Outbox SENT means durable queue handoff, not domain success. Sent intent plus missing job without completion proof is stranded: intake/recovery hold it and report a safe incident. Do not prune jobs/outbox before their receipt completion/retention contract permits it.

The optional DatabaseWebhookProcessor is a JobHandler for SQL-only use cases. Its callback receives (Connection, WebhookEvent) and must use only that connection and transactional domain tables for bounded writes. It may use safe event metadata; it must not read payload files inside the transaction. No provider/file/AI/network effects, nested commits or separate DB writes are allowed. Its completion tombstone and those local SQL effects commit together. This proves one local SQL effect across lease retries, not universal exactly-once effects or arbitrary external payment/fulfillment behavior. Applications needing body-derived business data must validate/extract it outside SQL in a separately reviewed handler and retain equivalent idempotency/transaction invariants.

Expired outbox reservations become UNKNOWN. A crash before enqueue can be reconciled against the locked receipt/job ledger to prove absence and permit a retry. A crash after enqueue can prove the existing job and mark SENT without another job. Arbitrary external effect uncertainty cannot use this absence proof. Confirmed MySQL deadlock aborts of a claim transaction only may be supervised with bounded backoff; do not wrap delivery/effects/commit uncertainty in blind retry loops. See the test-only claim supervisor for that exact distinction.

Queue UNKNOWN does not support blind replay. Dead-letter job replay preserves identity; the SQL-only completion tombstone prevents reapplying a committed effect. Outbox dead letters remain held (no automatic replay API). An operator must authenticate/authorize/audit the exact account/message, reconcile queue and effect state, and propose a reviewed recovery step; do not reset rows via a public endpoint or manufacture proof. No universal manual replay tool is supplied.

Valid unsupported types retain bounded UNKNOWN metadata/private bytes without an invented intent. Registering a new handler does not auto-promote historical rows: redelivery returns a retryable hold. After authorization/audit, call promoteUnsupported($eventId) using that exact composed account; promotion and intent commit together, and a repeated promotion does not enqueue another job. This method is mechanics, not its own HTTP authorization boundary.

Legacy cutover and evidence

There is no automatic import from legacy file receipts: account scope, historical effects and safe queue state cannot be guessed. Disable legacy consequential intake, inventory/drain/reconcile under the compatible old code, and require an application-owned reviewed mapping before adoption. Provable missing intents in the new ledger are repaired transactionally; ambiguous/corrupt records are held with stranded/held counts. Preserve originals; do not erase them to make a migration look successful. Rollback disables intake FIRST, retains all durable state and reconciles/drains with code compatible with that schema.

metrics() returns account-scoped current-state gauges (received, queued, processed, failed, unknown, stranded) plus process-local confirmed duplicates, conflicts, recovered, rejected counters. Recovery returns bounded scanned/recovered/held/protected/deleted counts. These are not a deployed exporter, alert route, distributed telemetry or on-call acknowledgement. Alert on stranded > 0, held > 0, UNKNOWN/dead letters, capacity denial, stale workers and incomplete sweeps. Never attach raw event/account/payload labels.

The exact reproducible isolated command, source/output hashes, expected-red failures and actual fault matrix belong to the Urgent evidence/checkpoints. Live providers, consumer migrations, multi-node/power-loss behavior, target-host drills, independent security review and release approval remain unqualified.

Editorial provenance

Source: docs/stabilization/ATOMIC_WEBHOOK_ADOPTION.md. Source SHA-256: 037bba4eee61af60b60eacf462894e84bb2978db9867755108d7796c408ec75c.

Framework candidate reference; editorial checkpoint 2026-10-20. See candidate status and adoption boundary.

EXPLORE FURTHER

Drag a divider or use Left and Right Arrow keys to resize. Hold Shift for larger steps. Enter hides or restores the sidebar; Home and End select its minimum or maximum width. Double-click resets the width.