Skip to content

InvalidPatchLifecycleState

graph.apply_patch(patch_id) was called on a patch that isn't in the 'proposed' state. Patches are one-shot: a proposed patch becomes 'applied' (success) or 'rejected' (refusal) exactly once. Re-applying an already-applied patch — or applying one that's been rejected — would either emit a duplicate patch.applied event (breaking replay) or contradict an explicit refusal.

This is an exception, not an event, because the caller has made a mistake the caller can fix at the call site — see failure-model for the events-not-exceptions principle and why patch-lifecycle violations land on the exception side of the line.

Quick fix

Check the patch's status before applying:

patch = graph.get_patch(patch_id)
if patch.status == "proposed":
    graph.apply_patch(patch_id)
else:
    # Already applied, rejected, or in another terminal state.
    # Whatever you were going to do, the patch already did it (or
    # explicitly didn't). Don't re-apply.
    pass

If you genuinely need to apply a new mutation, propose a new patch — patches aren't re-used:

new_patch = graph.propose_patch(target, op="patch", diff={...})
graph.apply_patch(new_patch.id)

The patch lifecycle (three sentences)

A patch is created in 'proposed' state by graph.propose_patch. graph.apply_patch transitions it to 'applied' and emits a patch.applied event; graph.reject_patch transitions it to 'rejected' and emits patch.rejected. Both transitions are terminal — a patch leaves 'proposed' exactly once.

For the full lifecycle including optimistic concurrency on object versions and the policy-gating semantics, see concepts/patches.

How to diagnose

The error names the patch id and its current status:

InvalidPatchLifecycleState: patch patch_017 is 'applied', not 'proposed'

From code:

try:
    graph.apply_patch(patch_id)
except InvalidPatchLifecycleState as e:
    print(e.patch_id)        # 'patch_017'
    print(e.current_status)  # 'applied' | 'rejected'

To see what happened to the patch:

activegraph inspect <store> --event <patch.applied or patch.rejected event id>

The status transition is in the event log — every patch.applied and patch.rejected event names the patch it transitioned, so the audit trail shows when and why the patch left 'proposed'.

When does this fire

At graph.apply_patch(patch_id), after the patch is fetched and its current status is read. The check is the second thing apply_patch does (after the patch-exists check that raises KeyError if the id is unknown), so misuse is caught early.

The error never fires from propose_patch, reject_patch, or get_patch — those are read-only or transition-initiating, not transition-completing.

Why the framework refuses to continue

Patches are one-shot. A 'proposed' patch becomes 'applied' (success) or 'rejected' (refusal) exactly once. Re-applying an already-applied patch would emit a duplicate patch.applied event, which would break the replay contract — replay would produce a different event stream than the original run. The framework refuses re-application rather than emit the duplicate.

This is why the error is an exception and not an event: the caller can fix it (check status, propose a new patch). It's not a non-fatal stop the runtime should record and continue past. See failure-model for the broader principle.

  • concepts/patches — the canonical patch lifecycle reference. Optimistic concurrency, policy gating, the apply/reject transitions.
  • runtime-context-required-error — the sibling ExecutionError for "the caller is using the primitive outside its intended context."
  • failure-model — why patch lifecycle violations are exceptions, not events.