Skip to content

Commit 48649be

Browse files
MajorTalclaude
andcommitted
feat(sdk,cli,mcp): v1.57 lifecycle-state-on-billing-account client cascade (#402)
Gateway v1.57 moved the lifecycle state machine from `internal.projects` to `internal.billing_accounts` and dropped the per-project pin endpoints. This is the client-side cascade — runs as a breaking minor. SDK (`@run402/sdk`): - `ProjectSummary` / `UsageReport`: drop `status` (and `pinned` on list), gain `effective_status`, `account_lifecycle_state`, `lease_perpetual`, `deleted_at`, `archived_at`. New `EffectiveProjectStatus` / `BillingAccountLifecycleState` union types. - `TierStatusResult`: optional top-level `account_lifecycle_state` + `lease_perpetual`. - `r.admin`: new `setLeasePerpetual(ba, bool)`, `archiveProject(id, {reason?})`, `reactivateProject(id)` with typed result envelopes (including no-op `note: "already archived" | "not archived"` variants). - Remove `r.projects.pin()` and the scoped wrapper; remove `PinResult`. CLI (`run402`): - New `run402 admin` group: `lease-perpetual`, `archive`, `reactivate`. - `run402 projects pin` returns a `REMOVED_COMMAND` envelope pointing at `admin lease-perpetual` (no network call). - `run402 status` exposes top-level `account_lifecycle_state` + `lease_perpetual`. MCP (`run402-mcp`): - New tools: `admin_set_lease_perpetual`, `admin_archive_project`, `admin_reactivate_project`. Drop `pin_project`. - `list_projects` / `get_usage` render the new lifecycle fields. Docs + drift: SKILL.md, openclaw/SKILL.md, llms-mcp.txt, cli/llms-cli.txt, sdk/llms-sdk.txt rewritten; SURFACE + SDK_BY_CAPABILITY updated; banned- regression patterns added for `pin_project` (SKILL) and `run402 projects pin` (openclaw SKILL). Closes #402. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b1f897a commit 48649be

29 files changed

Lines changed: 946 additions & 265 deletions

SKILL.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -564,8 +564,9 @@ For agents that need to sign Ethereum transactions. Private keys never leave AWS
564564
- **`allowance_status`** / **`allowance_create`** / **`allowance_export`** — local allowance management.
565565
- **`request_faucet`** — testnet USDC.
566566
- **`check_balance`** — USDC for an allowance address.
567-
- **`list_projects`** — active projects for a wallet.
568-
- **`pin_project`** — pin a project (admin only — uses the configured admin allowance wallet).
567+
- **`list_projects`** — active projects for a wallet. Each row carries v1.57 lifecycle fields: `effective_status`, `account_lifecycle_state` (same value across every project on the billing account), `lease_perpetual`, `deleted_at`, `archived_at`.
568+
- **`admin_set_lease_perpetual`** — operator escape hatch (v1.57+). Toggles the billing account's `lease_perpetual` flag so the account never advances past `active` regardless of lease expiry. Replaces the v1.56 per-project pin tool (gateway endpoint was removed). Enabling on a grace-state account reactivates inline.
569+
- **`admin_archive_project`** / **`admin_reactivate_project`** — operator moderation actions on a single project (`projects.archived_at`). Independent of account-level lifecycle.
569570
- **`project_info`** / **`project_keys`** / **`project_use`** — inspect / set the active project.
570571
- **`send_message`** — send feedback to the Run402 team.
571572
- **`set_agent_contact`** / **`get_agent_contact_status`** / **`verify_agent_contact_email`** — register agent contact info, read assurance status, and start the operator email reply challenge.
@@ -618,17 +619,23 @@ Project rate limit: **100 req/sec**. Exceeding returns 429 with `retry_after`. E
618619

619620
## Project lifecycle (~104-day soft delete)
620621

621-
After lease expires, projects go through a state machine. The live data plane keeps serving the whole time only the owner's control plane gets gated:
622+
Gateway v1.57 moved the lifecycle state machine from `internal.projects` to `internal.billing_accounts`. The grace clock now ticks per **billing account** — every project on the same account inherits the same `account_lifecycle_state`. The live data plane keeps serving the whole time; only the owner's control plane gets gated:
622623

623624
| State | When | What happens |
624625
|-------|------|--------------|
625626
| `active` || Full read/write |
626627
| `past_due` | day 0 | Site, REST, email keep serving. Owner gets first email. |
627628
| `frozen` | +14d | Control plane (deploys, secrets, subdomain claims, function upload) returns 402 with `lifecycle_state` / `entered_state_at` / `next_transition_at`. Site still serves. Subdomain reserved so the brand can't be claimed by another wallet. |
628629
| `dormant` | +44d | Scheduled functions pause. |
629-
| `purged` | +104d | Cascade: schema dropped, Lambdas deleted, mailbox tombstoned. Subdomain becomes claimable 14 days later. |
630+
| `purged` | +104d | Cascade: schemas dropped, Lambdas deleted, mailboxes tombstoned. Subdomains become claimable 14 days later. |
630631

631-
Calling **`set_tier`** during grace reactivates the project and clears all timers in one transaction. Pinned projects bypass the state machine entirely.
632+
Calling **`set_tier`** during grace reactivates the **account** inline and clears every project's timers in one transaction. Per-project fields on each `list_projects` row:
633+
634+
- `effective_status` — derived for serving / UX. Equals `account_lifecycle_state` unless the project is individually archived (`archived_at` set → `archived`) or deleted (`deleted_at` set → `deleted`).
635+
- `account_lifecycle_state` — the raw per-account state. Identical across every project on the same account.
636+
- `lease_perpetual` — operator escape hatch flag on the owning account. When `true`, the account never advances past `active`. Replaces the v1.56 per-project `pinned`. Toggle via **`admin_set_lease_perpetual`** (platform-admin only).
637+
638+
Operator moderation actions (independent of lifecycle, scoped to a single project): **`admin_archive_project`** and **`admin_reactivate_project`**.
632639

633640
## Standard Workflow
634641

@@ -693,7 +700,7 @@ Other allowance options:
693700
|---|---|
694701
| `402 payment_required` on `set_tier` | Allowance is empty. Call `request_faucet` (testnet) or fund with real USDC. |
695702
| `402` with `lifecycle_state: frozen` | Project past lease + 14 days. `set_tier` reactivates instantly. |
696-
| `403 admin_required` | Tool is admin-only (e.g., `pin_project`). Use a platform admin allowance wallet; project owners can't pin their own projects. |
703+
| `403 admin_required` | Tool is platform-admin only (e.g., `admin_set_lease_perpetual`, `admin_archive_project`, `admin_reactivate_project`). Use a platform admin allowance wallet; project owners can't toggle these on their own. |
697704
| Empty `[]` from `rest_query` for anon | Table not in manifest with `expose: true`. Call `apply_expose`. |
698705
| `403 forbidden_function` calling an RPC | Function not in the manifest's `rpcs[]`. Add `{ name, signature, grant_to: ["authenticated"] }` and re-apply. |
699706
| `409 reserved` from `claim_subdomain` | Original owner's grace period — subdomain held until +118 days from lease expiry. |

SKILL.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ describe("SKILL.md (root, MCP-based)", () => {
111111
{ pattern: /\bvalue_hash\b/, reason: "secret value hashes are no longer public; list only keys/timestamps" },
112112
{ pattern: /"secrets"\s*:\s*\{\s*"set"\s*:/, reason: "deploy specs must not carry secret values; use secrets.require" },
113113
{ pattern: /\breplace_all\b/, reason: "secrets.replace_all is not representable in value-free deploy specs" },
114+
{ pattern: /\bpin_project\b/, reason: "pin_project was removed in v1.57; use admin_set_lease_perpetual" },
114115
];
115116
for (const { pattern, reason } of banned) {
116117
it(`does not contain: ${pattern.source}`, () => {
@@ -222,6 +223,7 @@ describe("openclaw/SKILL.md (CLI-based)", () => {
222223
{ pattern: /"secrets"\s*:\s*\[/, reason: "CLI deploy manifests must not carry legacy secret value arrays" },
223224
{ pattern: /"secrets"\s*:\s*\{\s*"set"\s*:/, reason: "deploy specs must not carry secret values; use secrets.require" },
224225
{ pattern: /\breplace_all\b/, reason: "secrets.replace_all is not representable in value-free deploy specs" },
226+
{ pattern: /\brun402 projects pin\b/, reason: "`run402 projects pin` was removed in v1.57; use `run402 admin lease-perpetual`" },
225227
];
226228
for (const { pattern, reason } of banned) {
227229
it(`does not contain: ${pattern.source}`, () => {

cli-e2e.test.mjs

Lines changed: 81 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,28 @@ async function mockFetch(input, init) {
190190
if (path.match(/^\/projects\/v1\/[^/]+$/) && method === "DELETE") {
191191
return Promise.resolve(noContent());
192192
}
193-
if (path.match(/^\/projects\/v1\/admin\/[^/]+\/pin$/) && method === "POST") {
193+
if (path.match(/^\/billing-accounts\/v1\/admin\/[^/]+\/lease-perpetual$/) && method === "POST") {
194+
const accountId = path.split("/").at(-2);
195+
const desired = Boolean(body?.lease_perpetual);
196+
return Promise.resolve(json({
197+
status: "ok",
198+
billing_account_id: accountId,
199+
lease_perpetual: desired,
200+
reactivated: desired,
201+
}));
202+
}
203+
if (path.match(/^\/projects\/v1\/admin\/[^/]+\/archive$/) && method === "POST") {
204+
const projectId = path.split("/").at(-2);
205+
return Promise.resolve(json({
206+
status: "ok",
207+
project_id: projectId,
208+
archived_at: "2026-05-06T12:00:00.000Z",
209+
reason: body?.reason ?? "(no reason given)",
210+
}));
211+
}
212+
if (path.match(/^\/projects\/v1\/admin\/[^/]+\/reactivate$/) && method === "POST") {
194213
const projectId = path.split("/").at(-2);
195-
return Promise.resolve(json({ status: "pinned", project_id: projectId }));
214+
return Promise.resolve(json({ status: "ok", project_id: projectId, reactivated: true }));
196215
}
197216
if (
198217
(path === "/projects/v1/expose/validate" ||
@@ -1838,8 +1857,13 @@ describe("CLI e2e happy path", () => {
18381857
assert.equal(seenCookie, "run402_admin=test-session");
18391858
});
18401859

1841-
it("projects pin uses allowance admin auth, not project service key auth", async () => {
1842-
const { run } = await import("./cli/lib/projects.mjs");
1860+
// v1.57: projects pin was removed in favor of admin lease-perpetual.
1861+
// Same auth shape — allowance SIWX, X-Admin-Mode: 1, no Bearer service_key.
1862+
// The mockFetch lease-perpetual route echoes the request body back as the
1863+
// `lease_perpetual` field of the response, so verifying the response shape
1864+
// is equivalent to verifying the body went out correctly.
1865+
it("admin lease-perpetual uses allowance admin auth, not project service key auth", async () => {
1866+
const { run } = await import("./cli/lib/admin.mjs");
18431867
const { saveAllowance } = await import("./cli/lib/config.mjs");
18441868
saveAllowance({
18451869
address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
@@ -1849,19 +1873,22 @@ describe("CLI e2e happy path", () => {
18491873
rail: "x402",
18501874
});
18511875
let seenUrl = null;
1876+
let seenMethod = null;
18521877
let seenSiwx = null;
18531878
let seenAdminMode = null;
18541879
let seenAuthorization = null;
18551880
const prevFetch = globalThis.fetch;
18561881
globalThis.fetch = (input, init) => {
18571882
const url = typeof input === "string" ? input : (input instanceof Request ? input.url : String(input));
1858-
if (url.includes("/projects/v1/admin/prj_external/pin")) {
1883+
if (url.includes("/billing-accounts/v1/admin/ba_external/lease-perpetual")) {
18591884
seenUrl = url;
18601885
if (input instanceof Request) {
1886+
seenMethod = input.method;
18611887
seenSiwx = input.headers.get("sign-in-with-x");
18621888
seenAdminMode = input.headers.get("x-admin-mode");
18631889
seenAuthorization = input.headers.get("authorization");
18641890
} else {
1891+
seenMethod = init?.method ?? "GET";
18651892
const headers = init?.headers ?? {};
18661893
seenSiwx = headers["SIGN-IN-WITH-X"] ?? headers["sign-in-with-x"] ?? null;
18671894
seenAdminMode = headers["X-Admin-Mode"] ?? headers["x-admin-mode"] ?? null;
@@ -1872,19 +1899,23 @@ describe("CLI e2e happy path", () => {
18721899
};
18731900
captureStart();
18741901
try {
1875-
await run("pin", ["prj_external"]);
1902+
await run("lease-perpetual", ["ba_external", "--enable"]);
18761903
} finally {
18771904
captureStop();
18781905
globalThis.fetch = prevFetch;
18791906
}
1880-
assert.ok(seenUrl && seenUrl.includes("/projects/v1/admin/prj_external/pin"),
1881-
`pin should hit the admin pin endpoint; got: ${seenUrl}`);
1882-
assert.equal(typeof seenSiwx, "string", "pin should send allowance SIWX admin-wallet auth");
1883-
assert.equal(seenAdminMode, "1", "pin should explicitly request admin mode");
1884-
assert.equal(seenAuthorization, null, "pin must not use the project's service_key as Bearer auth");
1907+
assert.ok(
1908+
seenUrl && seenUrl.includes("/billing-accounts/v1/admin/ba_external/lease-perpetual"),
1909+
`lease-perpetual should hit the admin endpoint; got: ${seenUrl}`,
1910+
);
1911+
assert.equal(seenMethod, "POST");
1912+
assert.equal(typeof seenSiwx, "string", "lease-perpetual should send allowance SIWX admin-wallet auth");
1913+
assert.equal(seenAdminMode, "1", "lease-perpetual should explicitly request admin mode");
1914+
assert.equal(seenAuthorization, null, "lease-perpetual must not use a service_key as Bearer auth");
18851915
const parsed = JSON.parse(capturedStdout());
1886-
assert.equal(parsed.status, "pinned");
1887-
assert.equal(parsed.project_id, "prj_external");
1916+
assert.equal(parsed.billing_account_id, "ba_external");
1917+
assert.equal(parsed.lease_perpetual, true,
1918+
"mockFetch echoes the request body; if this is false, the body didn't carry lease_perpetual:true");
18881919
});
18891920

18901921
it("projects schema defaults to active project (GH-102)", async () => {
@@ -4281,35 +4312,57 @@ describe("CLI e2e happy path", () => {
42814312
bannerRegex: /^run402 projects/,
42824313
});
42834314

4284-
// GH-103: `projects pin` must be clearly marked as admin-only in help text.
4285-
// The server-side /projects/v1/admin/:id/pin endpoint rejects project-owner
4286-
// auth (service_key / SIWX) with 403 admin_required. Help text that omits
4287-
// the admin caveat leads owners into an error they cannot resolve.
4288-
it("projects pin --help marks pin as admin-only (GH-103)", async () => {
4315+
// v1.57: `projects pin` was removed. The CLI now surfaces a structured
4316+
// REMOVED_COMMAND error pointing users at `admin lease-perpetual` — without
4317+
// making a network call — so owners who follow stale docs get redirected
4318+
// instead of hitting a 404. The replacement help (lease-perpetual / archive
4319+
// / reactivate) must clearly mark itself as admin-only.
4320+
it("projects pin returns REMOVED_COMMAND with a hint at admin lease-perpetual (v1.57)", async () => {
42894321
const { run } = await import("./cli/lib/projects.mjs");
42904322
let fetchCalled = false;
42914323
const prevFetch = globalThis.fetch;
42924324
globalThis.fetch = (...args) => { fetchCalled = true; return prevFetch(...args); };
42934325
let threw = null;
42944326
captureStart();
42954327
try {
4296-
await run("pin", ["--help"]);
4328+
await run("pin", ["prj_anything"]);
42974329
} catch (e) {
42984330
threw = e;
42994331
} finally {
43004332
captureStop();
43014333
globalThis.fetch = prevFetch;
43024334
}
4303-
assert.equal(threw?.message, "process.exit(0)", "pin --help should exit 0");
4304-
assert.equal(fetchCalled, false, "pin --help must not make any fetch/API call");
4335+
assert.equal(threw?.message, "process.exit(1)", "projects pin should exit non-zero");
4336+
assert.equal(fetchCalled, false, "removed command must not make any fetch/API call");
4337+
const stderr = capturedStderr();
4338+
const envelope = JSON.parse(stderr.trim());
4339+
assert.equal(envelope.code, "REMOVED_COMMAND");
4340+
assert.match(envelope.hint, /admin lease-perpetual/i,
4341+
`hint should point at admin lease-perpetual; got: ${envelope.hint}`);
4342+
});
4343+
4344+
it("admin --help marks subcommands as admin-only (v1.57 replacements)", async () => {
4345+
const { run } = await import("./cli/lib/admin.mjs");
4346+
let fetchCalled = false;
4347+
const prevFetch = globalThis.fetch;
4348+
globalThis.fetch = (...args) => { fetchCalled = true; return prevFetch(...args); };
4349+
let threw = null;
4350+
captureStart();
4351+
try {
4352+
await run("--help", []);
4353+
} catch (e) {
4354+
threw = e;
4355+
} finally {
4356+
captureStop();
4357+
globalThis.fetch = prevFetch;
4358+
}
4359+
assert.equal(threw?.message, "process.exit(0)", "admin --help should exit 0");
4360+
assert.equal(fetchCalled, false, "admin --help must not make any fetch/API call");
43054361
const stdout = capturedStdout();
4306-
// Find the pin line in the subcommand list and assert it mentions admin.
4307-
const pinLine = stdout.split("\n").find(l => /^\s*pin\s/.test(l)) ?? "";
4308-
assert.match(
4309-
pinLine,
4310-
/admin/i,
4311-
`pin subcommand line should mention admin-only; got: ${pinLine}`,
4312-
);
4362+
assert.match(stdout, /admin/i, "admin help should mention admin gating");
4363+
assert.match(stdout, /lease-perpetual/);
4364+
assert.match(stdout, /archive/);
4365+
assert.match(stdout, /reactivate/);
43134366
});
43144367

43154368
// Also spot-check the short flag alias -h works.

cli-help.test.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,14 @@ const MATRIX = {
5757
projects: {
5858
shared: [
5959
"quote", "use", "list", "info", "keys", "rest",
60-
"usage", "schema", "rls", "delete", "pin", "promote-user", "demote-user",
60+
"usage", "schema", "rls", "delete", "promote-user", "demote-user",
6161
],
6262
specific: ["provision", "sql", "costs", "validate-expose"],
6363
},
64+
admin: {
65+
shared: [],
66+
specific: ["lease-perpetual", "archive", "reactivate"],
67+
},
6468
deploy: { shared: [], specific: ["apply", "resume", "list", "events", "diagnose", "resolve", "release"] },
6569
ci: { shared: [], specific: ["link", "list", "revoke"] },
6670
functions: {

cli/cli.mjs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Commands:
2525
allowance Manage your agent allowance (create, fund, balance, status)
2626
tier Manage tier subscription (status, set)
2727
projects Manage projects (provision, list, query, inspect, delete)
28+
admin Platform-admin operations (lease-perpetual, archive, reactivate)
2829
deploy Unified deploy operations (requires active tier)
2930
ci Link GitHub Actions OIDC deploy bindings
3031
jobs Submit and inspect fixed platform-managed jobs
@@ -125,6 +126,11 @@ switch (cmd) {
125126
await run(sub, rest);
126127
break;
127128
}
129+
case "admin": {
130+
const { run } = await import("./lib/admin.mjs");
131+
await run(sub, rest);
132+
break;
133+
}
128134
case "deploy": {
129135
const { run } = await import("./lib/deploy.mjs");
130136
await run([sub, ...rest].filter(Boolean));

0 commit comments

Comments
 (0)