Blob: reference/anvil-spec-resolutions.md
Anvil Spec Resolutions
Date: 2026-03-18
This file records binding resolutions for ambiguities in anvil-spec.md without editing the base spec in place yet. Until the main spec is reconciled, this file is authoritative for the topics below.
1. Authority Boundary For Queueing, D1 Sync, Lock Release, And Queue Advancement
ProjectDO is the sole authoritative owner of project-level concurrency, queue mutation, queue dispatch state, D1 reconciliation state, lock release, and queue advancement.
The Worker and queue consumer may only:
- call
ProjectDORPCs to accept work, claim work, report terminal results, request cancellation, record heartbeats, or kick reconciliation - act as delivery hints or execution agents around
ProjectDO
They must not independently mutate durable queue state or D1 run-summary state as an authoritative source of truth.
2. Public Status Mapping For Accepted Runs
pending and executable are internal ProjectDO queue states only.
Public APIs, RunDO, and D1 run_index use only the canonical public run status enum:
queuedstartingrunningcancel_requestedcancelingpassedfailedcanceled
Both internal pending and internal executable map to public queued.
Queue-local ordering is exposed separately through project queue summaries rather than through a separate public run status.
3. Source Revision A Run Executes
Webhook-triggered runs
Webhook-triggered runs execute the exact commit SHA from the verified provider event.
- the canonical accepted snapshot for webhook runs includes
commit_sha - queue delay must not change the revision that executes
Manual runs
Manual runs execute the branch head as resolved when the queue consumer starts execution.
- manual acceptance does not pin a commit SHA up front
commit_shamay benullat acceptance time- after checkout resolves
HEAD, the queue consumer backfills the actual checked-outcommit_shaimmediately - this backfilled SHA must be propagated to
ProjectDO,RunDO, and D1run_index - this backfilled SHA is metadata describing what ran; it is not an acceptance-time execution pin
4. position Semantics In The Per-Project FIFO Queue
project_runs.position is a queued-only stable ordering token.
- it is assigned when a run is accepted into the per-project queue
- it is monotonically increasing within a project while rows remain queued
- it is not renumbered after cancellation, promotion, or terminalization
- gaps are allowed
- it is
NULLonce a run is no longer in queued state - FIFO promotion uses ascending non-null
position
This means position is not a mutable "live index" that is continuously compacted.
5. Proof Required Before Promoting The Next Run
ProjectDO may promote the next queued run only after one of these conditions is true:
- the queue consumer has completed teardown for the active run and reported terminalization back to
ProjectDO ProjectDOhas declared the runner lost during stale-heartbeat recovery and has taken over terminalization itself
Operationally:
- the normal path is queue-consumer cleanup, sandbox/session teardown, terminalization, then promotion
- the recovery path is watchdog takeover after stale heartbeat, terminalization as
runner_lostor the recovered terminal state, then promotion
The system does not require a positive sandbox acknowledgment in the watchdog recovery path before advancing the queue.
6. Idempotent D1 Reconciliation Algorithm
D1 reconciliation is upsert current truth by run_id.
d1_sync_status values in v1
needs_createcurrentneeds_updateneeds_terminal_updatedone
Allowed d1_sync_status transitions
needs_create -> currentneeds_create -> needs_updateneeds_create -> needs_terminal_updatecurrent -> needs_updatecurrent -> needs_terminal_updateneeds_update -> currentneeds_update -> needs_terminal_updateneeds_terminal_update -> done
done is a terminal D1 sync state. No transition out of done is permitted.
Create sync
When d1_sync_status = needs_create, ProjectDO upserts a queued row into D1 using the best current run truth.
Metadata sync
When d1_sync_status = needs_update, ProjectDO upserts the current non-terminal run truth into D1.
This status exists because a run's metadata may change after initial D1 creation but before terminal completion. The primary case is commit SHA backfill for manual runs (see Resolution 3): the queue consumer resolves the checked-out HEAD and records it back to ProjectDO, which must propagate the updated commit_sha to D1.
Terminal sync
When d1_sync_status = needs_terminal_update, ProjectDO upserts the terminal row into D1 using the latest terminal truth from ProjectDO and RunDO.
Required properties
- retries replay the same upsert behavior and are safe
needs_create -> needs_terminal_updateis valid and means a run became terminal before the initial D1 row was confirmedneeds_create -> needs_updateis valid and means metadata changed before the initial D1 row was confirmed; the metadata sync path uses the same upsert and will create the row- a terminal run may therefore be reconciled by an initial queued upsert followed by a terminal upsert
- unique-key conflicts on
run_idare resolved by the upsert, not by ad hoc insert-then-update branching - a non-terminal D1 upsert must not regress a row that is already terminal in D1; the upsert must guard
status,started_at,finished_at, andexit_codewith a conditional that preserves existing terminal values commit_shamust useCOALESCEsemantics: a non-null value already in D1 is never overwritten with null
7. Queue Capacity Limit And Overflow Behavior
The per-project cap remains 20 accepted queued runs.
Manual triggers
- if the project already has 20 accepted queued runs, the private API returns
409 project_queue_full
Verified webhook triggers
- if the project already has 20 accepted queued runs, the public webhook route returns
429 - the verified delivery is recorded with outcome
queue_full - later duplicate deliveries with the same
(project_id, provider, delivery_id)within the 72-hour dedupe window do not re-evaluate capacity, do not create a run, and return2xx - the stored delivery outcome remains
queue_fullfor audit and UI purposes
Addendum: provider conflict resolution for queue_full webhook deliveries (2026-03-22)
This addendum supersedes the four bullets above and the generic duplicate-replay rule below for the specific case where a verified delivery reaches project queue capacity.
queue_fullis treated as a temporary receiver failure, not a terminal governance result.- the public webhook route returns
503 Service Unavailable - the response includes
Retry-After: 60 429is reserved for edge or rate-limit style throttling, not per-project queue exhaustion after webhook verification succeeds- the verified delivery is still recorded durably with stored outcome
queue_full - a later delivery with the same
(project_id, provider, delivery_id)may be re-evaluated while the stored outcome isqueue_full - if a later retry is accepted, anvil updates the stored row to
acceptedand attaches the resultingrunId - all other stored outcomes remain terminal within the 72-hour dedupe window and must replay without reprocessing
Rationale:
- GitLab documents that webhook receivers should use
4xxonly for misconfigured webhooks and5xxfor temporary receiver failures; GitLab may permanently disable4xxreceivers but only temporarily back off5xxfailures - GitHub documents that failed webhook deliveries are not redelivered automatically, but failures can be redelivered manually or through the API; using
503still expresses temporary capacity exhaustion more accurately than a permanent-looking4xx - for v1, anvil prefers one consistent cross-provider contract for temporary queue exhaustion: return
503, keep the delivery id stable, and allow a later resend to settle the durable result
References:
- GitLab webhook receiver requirements and delivery headers: https://docs.gitlab.com/user/project/integrations/webhooks/
- GitHub failed delivery handling and webhook best practices: https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries and https://docs.github.com/webhooks/using-webhooks/best-practices-for-using-webhooks
8. Canonical Webhook Normalization Contract
Backend Phase 5 must use one canonical normalized payload before invoking ProjectDO acceptance logic.
The canonical payload must include at least:
providerdeliveryId- normalized event kind
- raw provider event name
repositoryUrl- raw
ref - normalized
branch commitShabeforeShawhen the provider supplies it
This payload is the cross-provider contract for GitHub, GitLab, and Gitea.
9. Webhook Dedupe Semantics
Only verified webhook deliveries are written to the dedupe ledger.
All verified deliveries must be recorded in project_webhook_deliveries, not only deliveries that create runs.
The dedupe ledger must support explicit reason-specific stored outcomes, with a small normalized enum such as:
acceptedignored_pingignored_eventignored_branchqueue_full
The implementation may additionally classify an inbound request as a duplicate at handling time, but it must preserve the originally recorded stored outcome for that delivery ID rather than overwriting it with duplicate.
Required behavior:
- dedupe is keyed by
(project_id, provider, delivery_id) - retention remains 72 hours
- ping/test events, filtered non-default-branch pushes, duplicates, and queue-full no-ops are recorded
- if a delivery was already recorded, duplicate handling replays the previously decided durable result rather than reprocessing the event
10. Webhook CRUD And Rotation Contract
The webhook configuration API uses a simple secure upsert model.
PUT /api/private/projects/:projectId/webhooks/:provider
- create the provider row when it does not exist, or update
enabled/ providerconfigwhen it already exists - on create:
- request body provides
enabled - request body may provide a plaintext secret
- if no secret is provided, Worker generates one and returns it exactly once in the create response
- request body provides
- on update:
- request body may change
enabled - request body may change provider
configwhen applicable - request body must not include
secret - inline secret replacement is rejected; secret changes use
rotate-secret
- request body may change
Disabled rows
- disabling a webhook does not delete its stored encrypted secret
- disabled rows retain secret material until rotated or deleted
DELETE /api/private/projects/:projectId/webhooks/:provider
- hard-delete the webhook row and encrypted secret material
POST /api/private/projects/:projectId/webhooks/:provider/rotate-secret
- generate and persist a new secret
- return the new plaintext secret exactly once in the response
- there is no overlap window where both old and new secrets verify
11. Webhook Secret Custody And Verification Boundary
ProjectDO owns durable custody of webhook verification material.
This includes:
- encrypted-at-rest storage
- enablement state
- rotation state
- dedupe and post-verification governance decisions
The Worker owns ephemeral cryptographic use of webhook secrets at public ingress.
Required behavior:
ProjectDOstores webhook secrets only in encrypted form- Worker loads the minimal encrypted verification material from
ProjectDO - Worker decrypts the secret in memory
- Worker verifies the raw provider request using the provider signature scheme
- Worker does not persist plaintext webhook secret material
- after verification succeeds, Worker calls
ProjectDOwith only verified normalized payload
Rationale:
- this keeps durable secret custody with the project-local actor
- it keeps raw-request authentication at the Worker edge boundary where headers, body, WAF protection, and provider-specific ingress handling already live
- it avoids serializing decrypt-and-verify work inside
ProjectDOfor every webhook delivery
Default Implementation Notes
Until the main spec is updated:
- treat this file as authoritative for queue, run, and webhook contract decisions covered above
- prefer the current
ProjectDO/RunDOimplementation model where it already matches these resolutions