Repository navigation
Bound and meter workflow task completions (#930, phase A) - #934
Conversation
Security review of Server #766 found two availability boundaries in the package-owned record_local_activity contract: unbounded timing fields reach timestamp arithmetic, and the per-command limits compose into a completion that writes over 100,000 history events, each repeating the activity's arguments. Timing: duration_ms and heartbeat elapsed_ms are capped at seven days and attempt backoff_seconds at one day, each with its own 422 field error (a given out-of-range backoff is no longer replaced by "requires backoff"). The bridge sums the walk back from completion with checked arithmetic and refuses a report reaching before the earliest storable timestamp before any write. Meter: every completion and checkpointed-command transaction attempt runs under a fresh HistoryTransactionMeter. WorkflowHistoryEvent::record charges each event, its bytes as HistoryBudget measures them, and its rows; local activity rows are charged before each write. Crossing a hard structural limit (history_transaction_size, and the new history_transaction_bytes, history_transaction_rows and external_payload_transaction_bytes; 0 disables) trips the meter, rolls the transaction back and keeps the task's lease. An unmeasurable event fails closed. A run's deadline close is the server's work on stored state and is never metered, so an expired run still closes. Early check: after any deadline close and before the worker's first write, complete() counts the local activities' exact events and rows (other commands at their lower bound of one event) and refuses with completion_budget_exceeded. External payloads: inside a metered completion, externalization charges the per-payload and per-transaction raw byte limits, then stages the object under the URI a PredictableExternalPayloadStorageDriver predicts from its content; rows and history carry that final reference. Staged objects are stored only after the body passes every limit, before commit, and each put must return its predicted URI. A refusal stores nothing; an unpredictable driver is refused. Both built-in drivers predict their URIs and now verify content hashes and store atomically and idempotently. Phase B (exact pre-write row accounting for state-driven cascades and the other command paths) remains open under #930; see docs/architecture/completion-budget.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Security review of exact head 287bfe7 requests changes before merge.
Hosted evidence is also red at this head: Public Boundary rejects the cross-tracker references in docs/architecture/completion-budget.md, and Validate repository contracts reports replay implementation changes without corpus growth (73 to 73). The downstream build matrix was skipped. Please rerun the full required matrix on the corrected exact head. I did not run application tests locally. This review is based on the exact source tree, clean diff checks, and the hosted results above. |
- Hold lifecycle events back until the completion commits. Each metered transaction attempt collects the events its body dispatches and releases them after DB::transaction() returns, so a refused attempt, a failed staged put, a failed commit or a retried attempt dispatches none. - Key the payload stage by driver and predicted URI, and refuse one URI for different content, hash or codec (external_payload_staged_uri_collision). - Keep drivers that implement only ExternalPayloadStorageDriver working: they store payloads as before, after the raw bytes are charged to the same limits. PredictableExternalPayloadStorageDriver is an opt-in capability. - Make the predictable contract true: the local driver keeps an existing object only if its length and hash match, and otherwise moves a copy into place only after checking its exact length and hash. The filesystem driver no longer claims the interface. - Publish history_transaction_bytes, history_transaction_rows and external_payload_transaction_bytes in StructuralLimits::snapshot(), so the health check, operator metrics and backend capabilities show them. - Describe history_transaction_rows as what Phase A meters (history and local activity rows), and drop issue numbers from the architecture doc. - Add replay evidence at the timing caps: the longest admitted timings record exactly the replayed walk, and one past each cap is refused by that field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Answer to the exact-head review of
Hosted gates.
Local verification at this head.
The full hosted matrix runs on this head. |
|
Security review of exact head
This is not only a hypothetical private call shape: the new API and Please make each The hosted matrix is still in progress at this review point. Security acceptance remains pending this correction and a terminal green exact-head matrix. |
…hase A) LifecycleEventDispatcher::collect() now keeps a stack of collectors, one per attempt. A nested attempt collects into its own list: if its body throws, the list is discarded, even when an enclosing attempt catches the failure and commits. release() hands a committed nested attempt's events to the enclosing attempt, which dispatches or discards them with its own. The two new controls fail on the previous collector: a nested attempt that rolls back under an outer attempt that commits, and a nested transaction retried under an outer collector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Answer to #934 (comment). The new exact head is
Controls, in
Both of your controls (1 and 2) were run against |
|
|
SonarCloud decision at
Decision: accepted for Phase A rather than splitting the closure now. Each return is one of PHP's ordered refusal answers, and their order is the contract. Moving them into helpers would reorder or obscure the answer sequence in the same change that introduces the budget. Phase B restructures these same write paths for pre-write row accounting, and that is where the closure should be split into the ordered steps (claim checks, deadline close, early check, command application). No reliability or security finding is open. |
|
Security accepts exact head The six findings on Hosted evidence is terminal green for this exact head, including No Security blocker remains for this exact Phase A head. This acceptance does not cover a later head, does not close or pre-approve Phase B in #930, and does not authorize deployment. I did not run application tests locally; the acceptance is based on exact-source review plus the terminal hosted evidence above. |
|
Merged as
This is Phase A only, and #930 stays open for Phase B. No release is implied. The native Server port of this rule is going into Server #766. |



Phase A of #930. It resolves the two availability blockers Security found on Server #766 (durable-workflow/server#766 (comment)), as the owning package contract the Rust port will consume. It does not complete #930. Phase B adds exact pre-write row accounting for the state-driven cascades and the other command paths, and #930 isn't done until it lands. The accepted design is in #930 (v3
6402911532, with the additions in6402936400).docs/architecture/completion-budget.mddescribes what this change does.The second commit answers Security's exact-head review of
287bfe7d(#934 (comment)), point by point below.What it does
duration_msand heartbeatelapsed_msare capped at 7 days and attemptbackoff_secondsat 1 day, each with its own 422 field error. The walk back from completion is summed with checked arithmetic and refused (local_activity_timestamp_out_of_range) if it would reach before the earliest storable timestamp.HistoryTransactionMetergives everycomplete()attempt, and each ofcheckpointCommands' five attempts, a fresh meter:WorkflowHistoryEvent::record()charges each event, its bytes in theHistoryBudgetmeasure, and two rows. Local activity rows are charged before each write.StructuralLimits(0 disables):history_transaction_size(existing, 5,000),history_transaction_bytes(16 MiB),history_transaction_rows(20,000) andexternal_payload_transaction_bytes(16 MiB). All four appear inStructuralLimits::snapshot(), so the health check, operator metrics and backend capabilities publish them.history_transaction_rowscounts history events with their run positions, and local activity rows. Other command writes are not counted until Phase B. The doc, changelog, config comment and enum say so.DB::transaction()returns, or after the caller's outer transaction commits. A refused attempt, a failed staged put, a failed commit or a retried attempt dispatches none. Collection is scoped to each attempt: a nested attempt that fails is discarded even if the enclosing attempt commits.completion_budget_exceeded.payload_size_bytesand charged toexternal_payload_transaction_bytesbefore it is staged or stored.PredictableExternalPayloadStorageDriverhas its payloads staged under the predicted URI and stored only after every limit has passed, before commit. A refusal stores nothing.external_payload_staged_uri_collision.ExternalPayloadStorageDriverkeeps working as before, storing each payload when it is externalized, after the charge. A refused completion can leave those objects unreferenced, at mostexternal_payload_transaction_bytesper attempt.Security review of
287bfe7dEvent::faketests: zero events after a later byte refusal and after a failed staged put, then exactly one each after acceptance. A listener check shows the transaction level is 0 when the event is dispatched. A retried transaction dispatches only the committed attempt's events.StructuralLimits::snapshot(),HealthCheck,OperatorMetrics, andBackendCapabilitiesconfigured and effective.Hosted gates:
portable-local-activity-timing-caps-cold-replay. It cold-replays a local activity at the caps (7-day attempts, a 1-day backoff). The embedded consumer checks that the bridge records exactly that walk at the caps. It also checks that one past each cap is refused by that field alone, with nothing written.Tests
HistoryTransactionMeterTest, 14 tests: the exact measure; exact and one-over limits; disabled limits; a swallowed refusal; overflow-checked aggregates; fail-closed measure; staging with zero puts on refusal, dedup with per-occurrence charging and a fresh stage per attempt; raw per-payload and per-transaction limits; a mispredicted URI and a failed put; URI collisions; two drivers; unstaged charges; meters don't nest.DefaultWorkflowTaskBridgeLocalActivityTest, 12 tests, adding: an old-interface driver within the byte limit; lifecycle events only after commit; listeners at transaction level 0; a failed staged put dispatches nothing.LifecycleEventDispatcherCollectTest, 6 tests: a retried transaction, a failed attempt, release inside an outer transaction (commit and rollback), a nested attempt that rolls back under an outer attempt that commits, a nested transaction retried under an outer collector, and an enclosing attempt that fails.ExternalPayloadStorageTest: incomplete and corrupt local writes, replacing damaged objects, hash mismatch, and which drivers claim the contract.StructuralLimitsTest: the three limits on every operator surface.LocalActivityCommandNormalizationTest: each timing field at its maximum, one over andPHP_INT_MAX; the exact event check.Related
record_local_activity) consumes these constants and formulas exactly.🤖 Generated with Claude Code