Skip to content

Provably pre-effect ConnectorUnavailable rejection permanently retains the scoped standing-rule reservation #174

Description

@George-RD

What to build

ConnectorUnavailable rejections are classified as ConfirmedFailure but they are pre-effect: no attempt was made and the service was not reached. They should be NotAttempted to permit retry. Document the connector boundary contract and fix the classification.

Acceptance criteria

  1. Classify ConnectorUnavailable as NotAttempted, not ConfirmedFailure. 2. Add regression test: a ConnectorUnavailable leaves the pending row pending and permits retry on the next attempt. 3. Document the provider contract boundary.

Blocked by

None (T1 characterization; sub-issue of epic #198)

Original report

Found by an adversarial correctness/security review of #130 (ship-recurring-gmail-draft-proof) before merge. Labelled FOLLOW-UP-ISSUE: the direction is conservative (over-consumes the owner's budget rather than over-granting), so it is a delegation-accounting defect rather than an unsafe one.

Defect

crates/openspine-kernel/src/pipeline/approval_draft.rs:202:

Err(DispatchError::ConnectorUnavailable(err)) => return Err(err),

This leaves the typed EffectOutcome channel and surfaces as a bare anyhow::Error. crates/openspine-kernel/src/api/scoped_admission.rs:537 maps every executor Err to DispatchError::Resource(err), and crates/openspine-kernel/src/api/actions.rs:1200-1201 classifies Resource as retain_reservation = true.

Why it is wrong

admit_connector_write's own contract says the opposite (connector_breaker.rs:169-172): "An admission rejection is provably pre-effect — no connector future has been polled — so a caller that separates the steps can report it as a refusal rather than as a failed write." The effect ordering here is known. Yet the scoped lane retains a reserved quota row and a reserved rate row against the owner's reviewed rule for an effect that provably never happened, and refreshes the lapse clock via note_standing_rule_use (actions.rs:1211-1215). The change's own contract table at scoped_admission.rs:453-459 promises RefusedPreEffect -> Connector -> cancel, and actions.rs:1170-1180 records that no reconciler exists, so a retained row can never be released.

Reachable whenever the breaker transitions to Open/HalfOpen between the read-only thread fetch and the write admission. crate::spend::guard_connector_for at approval_draft.rs:188 takes the same route.

Corroborating: the post-write Err(DispatchError::ConnectorUnavailable(err)) arm at approval_draft.rs:306-309 is dead — call_with_admitted_connector_write can only return DeliveryUnknown or the output of map_write_error.

Suggested fix

Return EffectOutcome::RefusedPreEffect (after the connector_unavailable audit map_admission_error already writes) instead of Err(err) at approval_draft.rs:202, so the scoped lane cancels rather than retains. Remove the dead arm at :306-309. Add a regression test asserting an unavailable connector cancels the reservation and restores full budget.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentFully specified, ready for an AFK agent

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions