Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .specs/kiloclaw-billing-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ downstream enforcement can proceed.
9. Processing MUST preserve the period-scoped idempotency behavior
defined in `.specs/kiloclaw-billing.md` so duplicate delivery cannot
double-charge or double-advance a subscription.
10. Every successful renewal or duplicate-boundary reconciliation that
leaves or transitions the subscription to active MUST clear any prior
destruction deadline in the successful renewal transaction. This
invalidation MUST NOT wait for instance start, readiness, or auto-resume
completion, and MUST NOT clear the suspension timestamp.

### Shared Credit Balance Safety

Expand Down Expand Up @@ -240,6 +245,11 @@ downstream enforcement can proceed.
6. If credit-renewal backlog or retry age creates credible risk of
false suspension or destruction, the system SHOULD add a stronger
protection mechanism before continuing rollout.
7. Immediately before requesting personal instance destruction, downstream
enforcement MUST re-read and revalidate the current subscription and
instance. Destruction MUST be skipped unless the row remains current,
suspended, destruction-eligible, past its deadline, attached to the same
live personal instance, and the instance is not already destroyed.

### Observability and Operator Control

Expand Down
57 changes: 40 additions & 17 deletions .specs/kiloclaw-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -933,8 +933,11 @@ rows renew.
operations so that a retry can re-attempt the deduction without
the idempotency key blocking it.
4. If the deduction insert returns zero affected rows (duplicate key
from a prior committed transaction), the subscription update
within the same transaction is a no-op (same values). The system
from a prior committed transaction), the system MUST reconcile the
subscription advancement when the same renewal boundary remains
current. Reconciliation MUST apply the same active-renewal destruction-
deadline invalidation and suspended-recovery side effects as a newly
inserted deduction. If the boundary has already advanced, the system
MUST skip further processing for that row.
5. If the subscription has cancel-at-period-end set, the sweep MUST
skip the deduction, set the subscription status to canceled, and
Expand All @@ -945,10 +948,12 @@ rows renew.
6. When the effective balance (as defined in Credit Enrollment rule 4)
is sufficient for the subscription's price-version renewal amount
and the deduction succeeds (one affected row), the system MUST
atomically record the deduction as credit spend (see Definitions)
and advance the subscription's billing period
(current-period-start, current-period-end, credit-renewal-timestamp)
within the same transaction. After the transaction commits, the
atomically record the deduction as credit spend (see Definitions),
advance the subscription's billing period
(current-period-start, current-period-end, credit-renewal-timestamp),
and clear any prior destruction deadline within the same transaction.
Clearing the destruction deadline MUST NOT clear the suspension
timestamp or auto-resume retry state. After the transaction commits, the
system MUST trigger a bonus credit evaluation as described in Credit
Enrollment rule 6. The user's credit balance MAY be temporarily
negative between the deduction and the bonus award. If the bonus
Expand All @@ -967,9 +972,11 @@ rows renew.
the user so that future failures can re-trigger the notification.
10. When the deduction succeeds, the subscription was past-due, and
the suspension timestamp is non-null (suspended recovery), the
system MUST call the auto-resume procedure to restart the instance,
clear the suspension-cycle email log entries (including the
credit-renewal-failed entry), and clear the suspension columns.
system MUST call the auto-resume procedure to restart the instance.
The renewal transaction has already invalidated the prior destruction
deadline; suspension-cycle email log entries (including the credit-
renewal-failed entry), the suspension timestamp, and auto-resume retry
state MUST remain until readiness completion.
11. When the effective balance (as defined in Credit Enrollment
rule 4) is insufficient, the system MUST first check whether
the user has auto top-up enabled and whether a top-up has
Expand Down Expand Up @@ -1127,6 +1134,11 @@ rows renew.
hard-expiry enforcement, the system MUST re-evaluate parent
organization entitlement. If entitlement has returned, the system MUST
run Organization Entitlement Recovery instead of sending the warning.
3. Immediately before sending a personal destruction warning, the system
MUST re-read the current subscription and instance state and skip the
warning if the row is no longer current and destruction-eligible, the
deadline no longer falls within the warning window, or the instance is
no longer the same live personal instance.

### Instance Destruction

Expand All @@ -1153,6 +1165,12 @@ rows renew.
MUST emit telemetry identifying pending provider resources and the
latest provider error, if any. A provider 404 for a resource counts as
confirmed deletion of that resource.
9. Immediately before requesting destruction of a personal instance, the
system MUST re-read the current subscription and instance state. It MUST
skip destruction unless the same current subscription still has a
destruction-eligible status, remains suspended, has an elapsed deadline,
still references the same live personal instance, and that instance is
not already destroyed.

### Past-Due Payment Enforcement

Expand Down Expand Up @@ -1213,16 +1231,21 @@ rows renew.
status-change events MUST NOT trigger auto-resume for hybrid rows
(see Hybrid Subscription Ownership rule 2).
2. If the instance start attempt fails, the system MUST log the failure
and MUST NOT clear the suspension timestamp or destruction deadline.
and MUST NOT clear the suspension timestamp or auto-resume retry state.
Leaving these fields intact allows the background job (Billing
Lifecycle Background Job rule 5) to detect the incomplete
auto-resume and retry on the next sweep.
3. The system MUST clear the suspension timestamp and destruction
deadline only after a successful instance start (or when no instance
exists to restart).
4. The system MUST clear email log entries for suspension, destruction,
Lifecycle Background Job rule 5) to detect the incomplete auto-resume
and retry on the next sweep. A successful credit renewal MUST already
have invalidated the prior destruction deadline before the start attempt,
and start failure MUST NOT restore it.
3. The system MUST clear the suspension timestamp and auto-resume retry
state only after a successful instance start (or when no instance
exists to restart). Credit-renewal destruction-deadline invalidation
MUST NOT wait for instance readiness or auto-resume completion.
4. After successful instance start (or when no instance exists to restart),
the system MUST clear email log entries for suspension, destruction,
and credit-renewal-failed notifications so they can fire again in a
future suspension cycle.
future suspension cycle. Deadline invalidation alone MUST NOT reset
those entries while the suspension timestamp remains non-null.
5. The system MUST NOT clear email log entries for trial or earlybird
warning notifications, as those are one-time events.

Expand Down
45 changes: 44 additions & 1 deletion apps/web/src/lib/kiloclaw/instance-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,28 @@ const txInsertValues: Array<Record<string, unknown>> = [];
const deleteWhereCalls: unknown[] = [];
const startAsyncMock = jest.fn();

function sqlParamValues(condition: unknown): unknown[] {
const values: unknown[] = [];
const visited = new WeakSet<object>();

const visit = (value: unknown) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);

if (value.constructor.name === 'Param' && 'value' in value) {
values.push(value.value);
return;
}

for (const child of Object.values(value)) {
visit(child);
}
};

visit(condition);
return values;
}

function createSelectResult<T>(rows: T[]) {
const promise = Promise.resolve(rows);
const result: {
Expand Down Expand Up @@ -208,7 +230,7 @@ describe('instance lifecycle async resume', () => {
);
});

it('completes async auto-resume for an active subscription and clears retry state', async () => {
it('completes async auto-resume and resets warning dedupe for a later suspension lifecycle', async () => {
const instanceId = '11111111-1111-4111-8111-111111111111';
const sandboxId = 'ki_11111111111141118111111111111111';
selectResultsQueue.push(
Expand Down Expand Up @@ -243,6 +265,16 @@ describe('instance lifecycle async resume', () => {
expect(result).toEqual({ instanceId, resumeCompleted: true });
expect(mockDb.transaction).toHaveBeenCalledTimes(1);
expect(deleteWhereCalls).toHaveLength(1);
expect(sqlParamValues(deleteWhereCalls[0])).toEqual(
expect.arrayContaining([
'claw_suspended_trial',
'claw_suspended_subscription',
'claw_suspended_payment',
'claw_destruction_warning',
'claw_instance_destroyed',
'claw_credit_renewal_failed',
])
);
expect(txUpdateSetCalls).toHaveLength(1);
expect(txUpdateSetCalls[0]).toEqual({
suspended_at: null,
Expand All @@ -257,6 +289,17 @@ describe('instance lifecycle async resume', () => {
actor_id: 'web-instance-lifecycle',
action: 'reactivated',
reason: 'auto_resume_completed',
before_state: expect.objectContaining({
suspended_at: '2026-04-07T20:00:00.000Z',
destruction_deadline: '2026-04-14T20:00:00.000Z',
}),
after_state: expect.objectContaining({
suspended_at: null,
destruction_deadline: null,
auto_resume_requested_at: null,
auto_resume_retry_after: null,
auto_resume_attempt_count: 0,
}),
})
);
});
Expand Down
Loading
Loading