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
- 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.
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
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:This leaves the typed
EffectOutcomechannel and surfaces as a bareanyhow::Error.crates/openspine-kernel/src/api/scoped_admission.rs:537maps every executorErrtoDispatchError::Resource(err), andcrates/openspine-kernel/src/api/actions.rs:1200-1201classifiesResourceasretain_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 areservedquota row and areservedrate row against the owner's reviewed rule for an effect that provably never happened, and refreshes the lapse clock vianote_standing_rule_use(actions.rs:1211-1215). The change's own contract table atscoped_admission.rs:453-459promisesRefusedPreEffect -> Connector -> cancel, andactions.rs:1170-1180records 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_foratapproval_draft.rs:188takes the same route.Corroborating: the post-write
Err(DispatchError::ConnectorUnavailable(err))arm atapproval_draft.rs:306-309is dead —call_with_admitted_connector_writecan only returnDeliveryUnknownor the output ofmap_write_error.Suggested fix
Return
EffectOutcome::RefusedPreEffect(after theconnector_unavailableauditmap_admission_erroralready writes) instead ofErr(err)atapproval_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.