From 0a24becfe13ec3ab27c1a2bbea385c17ee9bd9a0 Mon Sep 17 00:00:00 2001 From: Manas Srivastava Date: Tue, 12 May 2026 23:52:49 +0530 Subject: [PATCH] handlers: audit + sharpen every agent_action string per uniform contract (U3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establishes a single source of truth for every agent_action string the API returns (internal/handlers/agent_action.go) and enforces a four-requirement contract in tests (TestAgentActionContract). The U3 contract every agent_action MUST satisfy: 1. Open with "Tell the user" — the LLM agent re-articulates verbatim. 2. Name the specific reason rejected (tier, limit, policy, resource). 3. Name the exact next action (Upgrade, Claim, Provision twin, Contact support) — never "try again later" without context. 4. Include a full https://instanode.dev/ URL — no relative paths. 5. Under 280 chars so LLMs reproduce verbatim instead of summarizing. Audited 25 strings across the handlers + middleware packages. Every one now meets all four requirements: builders (newAgentAction*) for tier/env/limit interpolation, static constants for fixed walls, and the existing codeToAgentAction registry sharpened in lockstep. Call sites refactored to consume the named constants/builders so reviewers audit prose in one file (agent_action.go) rather than scattered handlers. Tests: - TestAgentActionContract covers every string (static + builder + registry) - TestAgentActionContract_RegistryCoverage guards expected codes present - Existing TestRespondError_* assertions updated to match sharpened copy - All handlers + middleware unit tests green (pre-existing internal/plans failure unrelated to this PR) Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/handlers/agent_action.go | 198 ++++++++++++++++++ .../handlers/agent_action_contract_test.go | 159 ++++++++++++++ internal/handlers/agent_action_test.go | 6 +- internal/handlers/deploy.go | 5 +- internal/handlers/env_policy.go | 2 +- internal/handlers/family_bindings.go | 31 +-- internal/handlers/helpers.go | 45 ++-- internal/handlers/provision_helper.go | 9 +- internal/handlers/stack.go | 4 +- internal/handlers/storage.go | 2 +- internal/handlers/vault.go | 2 +- internal/middleware/env_policy.go | 8 +- 12 files changed, 418 insertions(+), 53 deletions(-) create mode 100644 internal/handlers/agent_action.go create mode 100644 internal/handlers/agent_action_contract_test.go diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go new file mode 100644 index 0000000..381b2bd --- /dev/null +++ b/internal/handlers/agent_action.go @@ -0,0 +1,198 @@ +package handlers + +// agent_action.go — single source of truth for every `agent_action` string +// returned to the calling LLM agent on a 402/403/409/410/4xx wall. +// +// ───────────────────────────────────────────────────────────────────────────── +// THE U3 CONTRACT +// ───────────────────────────────────────────────────────────────────────────── +// +// Every `agent_action` string returned by this service MUST satisfy these four +// requirements. They are enforced by TestAgentActionContract in +// agent_action_test.go and re-asserted at the handler level by the touch-points +// listed below. +// +// 1. IMPERATIVE OPENING. +// Every string MUST begin with "Tell the user" — the LLM agent's job is +// to re-articulate the sentence to the human in front of it. Starting +// every string with the same imperative makes the contract trivial for +// a downstream LLM to recognize as "verbatim copy I should reproduce." +// +// 2. SPECIFIC REJECTION REASON. +// Every string MUST name the concrete reason the request was rejected: +// the tier ("hobby"), the limit ("5/day"), the policy ("env_policy_denied"), +// the resource ("staging twin"). Generic phrasing ("their plan does not +// allow...") is forbidden — the LLM cannot expand "their plan" into +// something useful without inventing details. +// +// 3. EXACT NEXT ACTION. +// Every string MUST tell the user the precise action that clears the +// wall: "Upgrade to Pro", "Claim the resource", "Provision a twin", +// "Contact support". "Try again later" is not a valid action — that's +// a transient infra failure that should NOT carry an agent_action at all +// (those omit the field; see codeToAgentAction curation principles). +// +// 4. FULL HTTPS URL. +// Every string MUST contain an absolute `https://instanode.dev/...` URL. +// Plain "/pricing" or "the pricing page" forces the LLM to guess the +// hostname. The full URL is reproduced verbatim → guaranteed-correct +// link in the user's terminal. +// +// Plus the soft target: +// +// 5. UNDER 280 CHARS (the "tweet ceiling"). +// LLMs reliably reproduce sub-tweet copy verbatim. Longer strings get +// summarized, paraphrased, or truncated. The test asserts < 280 chars +// on every constant below. +// +// ───────────────────────────────────────────────────────────────────────────── +// HOW TO USE +// ───────────────────────────────────────────────────────────────────────────── +// +// 1. Static walls: add a new const to this file, give it a clear name +// (AgentAction), reference it from the handler. +// 2. Dynamic walls (tier/limit interpolation): add a builder function +// (newAgentAction(args...)) here. Builders MUST keep +// the four-contract requirements after interpolation. +// 3. The codeToAgentAction registry in helpers.go is the fallback path for +// handlers that pass only an error code — those defaults are also under +// test. +// +// Adding a new agent_action string OUTSIDE this file (inline in a handler) is +// a contract violation. The audit runs `grep "agent_action" internal/handlers` +// on every PR — new strings must land in this file or extend codeToAgentAction. + +import "fmt" + +// ───────────────────────────────────────────────────────────────────────────── +// Multi-env / stack tier walls +// ───────────────────────────────────────────────────────────────────────────── + +// AgentActionMultiEnvUpgradeRequired is returned when a hobby/free team +// tries to use a multi-env workflow (stack family read, stack promote). +const AgentActionMultiEnvUpgradeRequired = "Tell the user multi-env workflows (staging/production promote, env families) require the Pro plan. Have them upgrade at https://instanode.dev/pricing — takes 30 seconds." + +// AgentActionStackPromoteMissingImageRef is returned when the source stack +// predates the image-ref persistence migration (no cached image to copy). +const AgentActionStackPromoteMissingImageRef = "Tell the user this stack predates the image-ref persistence migration, so promote has nothing to redeploy. Redeploy the source stack first at https://instanode.dev/app/stacks, then retry the promote." + +// ───────────────────────────────────────────────────────────────────────────── +// Deploy tier walls +// ───────────────────────────────────────────────────────────────────────────── + +// newAgentActionDeploymentLimitReached builds the 402 copy returned when a +// team hits its deployments_apps cap (plans.yaml). Names the tier and the +// exact cap, points the user at the upgrade URL. +func newAgentActionDeploymentLimitReached(tier string, limit int) string { + return fmt.Sprintf( + "Tell the user they've hit the %s tier deployment cap (%d apps). Upgrade to Pro for 10 deployments at https://instanode.dev/pricing — takes 30 seconds, no card for upgrade preview.", + tier, limit, + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Storage / vault tier walls (called from respondErrorWithAgentAction) +// ───────────────────────────────────────────────────────────────────────────── + +// newAgentActionStorageLimitReached builds the 402 copy returned when a +// team hits the per-tier object-storage cap. +func newAgentActionStorageLimitReached(tier string, limitMB int) string { + return fmt.Sprintf( + "Tell the user they've hit the %s tier storage cap (%dMB). Upgrade to Pro for 5GB at https://instanode.dev/pricing to provision more storage.", + tier, limitMB, + ) +} + +// newAgentActionVaultQuotaExceeded builds the 402 copy returned when a team +// hits its vault-entry cap for the current plan. +func newAgentActionVaultQuotaExceeded(tier string, maxEntries int) string { + return fmt.Sprintf( + "Tell the user they've hit the %s tier vault cap (%d entries). Upgrade to Pro for more secrets at https://instanode.dev/pricing — takes 30 seconds.", + tier, maxEntries, + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Env-policy / role walls (403) +// ───────────────────────────────────────────────────────────────────────────── + +// newAgentActionEnvPolicyDenied builds the 403 copy returned when a team's +// env_policy refuses an action because the caller's role isn't in the +// allowed set. Names the env, the action, the allowed roles, and the +// caller's actual role. +func newAgentActionEnvPolicyDenied(env, action, allowedRoles, callerRole string) string { + if callerRole == "" { + callerRole = "unknown" + } + return fmt.Sprintf( + "Tell the user the %s env requires the %s role to %s. Their role is %s — have a team owner run the prompt at https://instanode.dev/app/team to adjust the policy or run the action.", + env, allowedRoles, action, callerRole, + ) +} + +// newAgentActionOwnerRequired builds the 403 copy returned when an action +// requires the owner role (e.g. PUT /team/env-policy). +func newAgentActionOwnerRequired(callerRole string) string { + if callerRole == "" { + callerRole = "unknown" + } + return fmt.Sprintf( + "Tell the user updating the team's env-policy requires the owner role. Their role is %s — have the team owner run the prompt from https://instanode.dev/app/team instead.", + callerRole, + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Family-binding walls (resolveResourceBindings → mapBindingError) +// ───────────────────────────────────────────────────────────────────────────── + +// newAgentActionBindingInvalidUUID is returned when resource_bindings[KEY] +// is neither a UUID nor a "family:" reference. +func newAgentActionBindingInvalidUUID(envKey, rawValue string) string { + return fmt.Sprintf( + "Tell the user the deploy's resource_bindings.%s value must be a resource token UUID or family:. They provided %q. See https://instanode.dev/docs/family-bindings.", + envKey, rawValue, + ) +} + +// AgentActionBindingFamilyDisabled is returned when family: prefix is used +// but the server has FAMILY_BINDINGS_ENABLED=false. +const AgentActionBindingFamilyDisabled = "Tell the user this server has family bindings disabled. Remove the family: prefix and pass a raw resource-token UUID instead — see https://instanode.dev/docs/family-bindings." + +// newAgentActionBindingNotFound is returned when the referenced resource +// (raw or family root) doesn't exist. +func newAgentActionBindingNotFound(envKey string) string { + return fmt.Sprintf( + "Tell the user the resource referenced in resource_bindings.%s doesn't exist. Have them list their families with GET https://instanode.dev/api/v1/resources/families and use a valid root id.", + envKey, + ) +} + +// newAgentActionBindingCrossTeam is returned when the referenced resource +// belongs to a different team. +func newAgentActionBindingCrossTeam(envKey string) string { + return fmt.Sprintf( + "Tell the user the resource in resource_bindings.%s belongs to a different team. They can only reference resources owned by their own team — check the team picker at https://instanode.dev/app.", + envKey, + ) +} + +// newAgentActionBindingNoEnvTwin is returned when a family binding resolves +// to a family that has no member in the deploy's env (e.g. deploying to +// staging but only the production twin exists). +func newAgentActionBindingNoEnvTwin(rootID, resourceName, env string) string { + name := resourceName + if name == "" { + name = rootID + } + return fmt.Sprintf( + "Tell the user to provision a %s twin of %q first: POST https://instanode.dev/api/v1/resources/%s/provision-twin with {\"env\":\"%s\"}. The deploy targets env=%s but no family member exists there.", + env, name, rootID, env, env, + ) +} + +// AgentActionBindingLookupFailed is returned for transient lookup failures +// during binding resolution (503 path). Even though this is a transient +// error, the user-visible advice is "retry in a few seconds" which is a +// concrete action the LLM can pass on. +const AgentActionBindingLookupFailed = "Tell the user the platform couldn't resolve the resource binding right now. Retry the deploy in ~10 seconds — if it persists, check https://instanode.dev/status." diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go new file mode 100644 index 0000000..67cf251 --- /dev/null +++ b/internal/handlers/agent_action_contract_test.go @@ -0,0 +1,159 @@ +package handlers + +// agent_action_contract_test.go — enforces the U3 contract (see +// agent_action.go) on every string the handler package returns via +// `agent_action`. One failure here means the contract regressed. +// +// Why one giant table: +// - Reviewers see every wall in one place. +// - Adding a new `agent_action` const without adding a row to this table +// is the violation we want CI to flag (you can grep this file to find +// constants without coverage). + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// agentActionContractCases is the canonical list of every agent_action +// string returned by this package. Static constants are exercised directly; +// builders are exercised with representative inputs. +// +// All strings MUST pass the four contract requirements (plus the < 280 char +// soft ceiling). assertContract enforces them. +func agentActionContractCases() map[string]string { + cases := map[string]string{ + // Static constants. + "AgentActionMultiEnvUpgradeRequired": AgentActionMultiEnvUpgradeRequired, + "AgentActionStackPromoteMissingImageRef": AgentActionStackPromoteMissingImageRef, + "AgentActionBindingFamilyDisabled": AgentActionBindingFamilyDisabled, + "AgentActionBindingLookupFailed": AgentActionBindingLookupFailed, + "RecycleGateAgentAction": RecycleGateAgentAction, + + // Builders — representative inputs covering tier/env/role/limit + // interpolation. + "newAgentActionDeploymentLimitReached(hobby,1)": newAgentActionDeploymentLimitReached("hobby", 1), + "newAgentActionStorageLimitReached(hobby,500)": newAgentActionStorageLimitReached("hobby", 500), + "newAgentActionVaultQuotaExceeded(hobby,50)": newAgentActionVaultQuotaExceeded("hobby", 50), + "newAgentActionEnvPolicyDenied(prod,deploy)": newAgentActionEnvPolicyDenied("production", "deploy", "owner", "developer"), + "newAgentActionOwnerRequired(developer)": newAgentActionOwnerRequired("developer"), + "newAgentActionBindingInvalidUUID(KEY)": newAgentActionBindingInvalidUUID("DATABASE_URL", "not-a-uuid"), + "newAgentActionBindingNotFound(KEY)": newAgentActionBindingNotFound("DATABASE_URL"), + "newAgentActionBindingCrossTeam(KEY)": newAgentActionBindingCrossTeam("DATABASE_URL"), + "newAgentActionBindingNoEnvTwin(uuid,name,env)": newAgentActionBindingNoEnvTwin("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", "owner-db", "staging"), + } + + // codeToAgentAction registry — every entry must also pass the contract. + for code, meta := range codeToAgentAction { + cases["codeToAgentAction["+code+"]"] = meta.AgentAction + } + + return cases +} + +// assertContract enforces the four U3 requirements + the soft length ceiling +// against a single string. Used by TestAgentActionContract and any future +// per-string assertions. +func assertContract(t *testing.T, name, s string) { + t.Helper() + + // 1. Imperative opening. + assert.True(t, strings.HasPrefix(s, "Tell the user"), + "%s: agent_action must start with \"Tell the user\" (the imperative the LLM agent re-articulates to the human). Got: %q", name, s) + + // 4. Full HTTPS URL. + assert.Contains(t, s, "https://instanode.dev/", + "%s: agent_action must contain a full https://instanode.dev/ URL — not a relative path. Got: %q", name, s) + + // 5. Soft length ceiling — LLMs reproduce sub-tweet copy verbatim. + assert.Less(t, len(s), 280, + "%s: agent_action must be < 280 chars (LLMs paraphrase longer strings). Got %d chars: %q", name, len(s), s) + + // Surface area for requirements 2 and 3 (specific reason + exact action). + // These can't be enforced by a generic regex, but the next-action + // vocabulary is bounded: every string MUST contain at least one of the + // known action verbs. This catches passive constructions like + // "Their plan does not allow X" which give the LLM no remedy. + actionVerbs := []string{ + "Upgrade", "upgrade", + "Have them", "Have the", "have a", "have them", "have the", + "Wait", "wait", // rate-limit + "Retry", "retry", + "provision", "Provision", + "claim", "Claim", // recycle gate + "log in", + "sign up", + "Ask", "ask", // invitations + "email", + "Remove", "remove", // family-disabled + "Redeploy", "redeploy", + "Confirm", "confirm", + "check ", "Check ", // bindings cross-team / not-found + "use ", "Use ", // bindings not-found + "must be ", // bindings invalid-uuid → action is "must be a UUID" + } + foundVerb := false + for _, v := range actionVerbs { + if strings.Contains(s, v) { + foundVerb = true + break + } + } + assert.True(t, foundVerb, + "%s: agent_action must contain at least one concrete action verb (Upgrade / Have them / Wait / Retry / provision / claim / log in / sign up / Ask / email / Remove / Redeploy / Confirm). Got: %q", + name, s) +} + +// TestAgentActionContract is the U3 audit gate. Every string in +// agentActionContractCases must satisfy: +// +// 1. Open with "Tell the user". +// 2. Name a specific reason (covered by per-handler tests). +// 3. Name an exact next action — enforced here via the action-verb +// vocabulary check. +// 4. Contain a full https://instanode.dev/ URL. +// 5. Be < 280 chars. +// +// Adding a new agent_action without adding a row here is a contract +// violation — the audit-trail comment in agent_action.go points reviewers +// at this test. +func TestAgentActionContract(t *testing.T) { + cases := agentActionContractCases() + require.NotEmpty(t, cases, "agentActionContractCases must list every string") + + for name, s := range cases { + t.Run(name, func(t *testing.T) { + require.NotEmpty(t, s, "%s: string must not be empty", name) + assertContract(t, name, s) + }) + } +} + +// TestAgentActionContract_RegistryCoverage guards against the most likely +// regression: someone adds a new code to codeToAgentAction but its string +// silently fails the contract. The map iteration in +// agentActionContractCases covers this — this test just asserts the +// expected codes are present so a deletion is loud. +func TestAgentActionContract_RegistryCoverage(t *testing.T) { + expectedCodes := []string{ + // Quota walls. + "quota_exceeded", "storage_limit_reached", "vault_quota_exceeded", + "vault_not_available", "vault_env_not_allowed", "member_limit", + "upgrade_required", "tier_unavailable", "rate_limit_exceeded", + // Auth. + "unauthorized", "auth_required", "invalid_token", "missing_token", + "vault_requires_auth", "invitation_invalid", "already_accepted", + "already_claimed", + // Expired / gone. + "webhook_inactive", "resource_not_found", + // Permission denied. + "forbidden", "last_owner", + } + for _, code := range expectedCodes { + _, ok := codeToAgentAction[code] + assert.True(t, ok, "codeToAgentAction[%q] must be registered — drop is a contract regression", code) + } +} diff --git a/internal/handlers/agent_action_test.go b/internal/handlers/agent_action_test.go index 3d30487..880d481 100644 --- a/internal/handlers/agent_action_test.go +++ b/internal/handlers/agent_action_test.go @@ -99,7 +99,7 @@ func TestRespondError_KnownCode_PopulatesAgentAction(t *testing.T) { code: "upgrade_required", status: fiber.StatusPaymentRequired, wantUpgradeURL: true, - wantActionSubstr: "higher plan", + wantActionSubstr: "Pro plan", }, { name: "rate_limit_exceeded gets upgrade_url", @@ -127,14 +127,14 @@ func TestRespondError_KnownCode_PopulatesAgentAction(t *testing.T) { code: "auth_required", status: fiber.StatusPaymentRequired, wantUpgradeURL: false, - wantActionSubstr: "log in at https://instanode.dev/login", + wantActionSubstr: "https://instanode.dev/login", }, { name: "webhook_inactive tells agent to re-provision", code: "webhook_inactive", status: fiber.StatusGone, wantUpgradeURL: false, - wantActionSubstr: "POST /webhook/new", + wantActionSubstr: "https://instanode.dev/webhook/new", }, { name: "forbidden suggests checking team membership", diff --git a/internal/handlers/deploy.go b/internal/handlers/deploy.go index 749287a..57070ba 100644 --- a/internal/handlers/deploy.go +++ b/internal/handlers/deploy.go @@ -351,13 +351,10 @@ func (h *DeployHandler) New(c *fiber.Ctx) error { } limit := h.planRegistry.DeploymentsAppsLimit(team.PlanTier) if limit >= 0 && existing >= limit { - agentAction := fmt.Sprintf( - "Tell the user they've hit the %s tier deployment cap (%d apps). Upgrade them to Pro for 10 medium deploys: https://instanode.dev/start?t=...", - team.PlanTier, limit) return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired, "deployment_limit_reached", fmt.Sprintf("Your %s tier allows %d deployment(s).", team.PlanTier, limit), - agentAction, + newAgentActionDeploymentLimitReached(team.PlanTier, limit), "https://instanode.dev/pricing") } } diff --git a/internal/handlers/env_policy.go b/internal/handlers/env_policy.go index a5d446e..9ec2430 100644 --- a/internal/handlers/env_policy.go +++ b/internal/handlers/env_policy.go @@ -98,7 +98,7 @@ func (h *EnvPolicyHandler) Put(c *fiber.Ctx) error { "error": "owner_required", "role": role, "allowed_roles": []string{middleware.RoleOwner}, - "agent_action": "Tell the user that updating the team's env-policy requires the owner role. Their role is " + role + ". Have the team owner run the prompt instead.", + "agent_action": newAgentActionOwnerRequired(role), }) } diff --git a/internal/handlers/family_bindings.go b/internal/handlers/family_bindings.go index efdf9e1..b5c21fe 100644 --- a/internal/handlers/family_bindings.go +++ b/internal/handlers/family_bindings.go @@ -302,35 +302,36 @@ func mapBindingError(e *BindingError) (status int, code, message, agentAction st case BindingErrInvalidUUID: return 400, "invalid_resource_binding", fmt.Sprintf("resource_bindings[%s] is not a valid UUID or family:", keyLabel), - fmt.Sprintf("Tell the user the deploy's resource_bindings.%s value must be a resource token UUID or the form family:. They provided %q.", - keyLabel, e.RawValue) + newAgentActionBindingInvalidUUID(keyLabel, e.RawValue) case BindingErrInvalidBinding: return 400, "invalid_resource_binding", fmt.Sprintf("resource_bindings[%s]: %s", keyLabel, e.Detail), - "Tell the user this server has FAMILY_BINDINGS_ENABLED=false. Remove the family: prefix and pass a raw resource-token UUID instead." + AgentActionBindingFamilyDisabled case BindingErrNotFound: return 404, "resource_binding_not_found", fmt.Sprintf("resource_bindings[%s]: no resource found for %q", keyLabel, e.RawValue), - fmt.Sprintf("Tell the user the resource referenced in resource_bindings.%s doesn't exist. Check the UUID — if they meant a family root, list their families with GET /api/v1/resources/families.", - keyLabel) + newAgentActionBindingNotFound(keyLabel) case BindingErrCrossTeam: return 403, "resource_binding_forbidden", fmt.Sprintf("resource_bindings[%s]: resource belongs to another team", keyLabel), - fmt.Sprintf("Tell the user the resource in resource_bindings.%s belongs to a different team. They can only reference resources owned by their own team.", - keyLabel) + newAgentActionBindingCrossTeam(keyLabel) case BindingErrNoEnvTwin: - name := e.ResourceName - if name == "" { - name = e.RootID - } return 409, "no_env_twin", - fmt.Sprintf("resource_bindings[%s]: family for %q has no member in env=%s", keyLabel, name, e.Env), - fmt.Sprintf("Tell the user to provision a %s twin of %q first (POST /api/v1/resources/%s/provision-twin with body {\"env\":\"%s\"}). The deploy is targeting env=%s but no family member exists there yet.", - e.Env, name, e.RootID, e.Env, e.Env) + fmt.Sprintf("resource_bindings[%s]: family for %q has no member in env=%s", keyLabel, nameOrEmpty(e.ResourceName, e.RootID), e.Env), + newAgentActionBindingNoEnvTwin(e.RootID, e.ResourceName, e.Env) default: // BindingErrLookupFailed return 503, "resource_binding_lookup_failed", fmt.Sprintf("resource_bindings[%s] resolution failed: %s", keyLabel, e.Detail), - "Tell the user the platform couldn't resolve the resource binding right now. Retry the deploy in a few seconds." + AgentActionBindingLookupFailed + } +} + +// nameOrEmpty returns name when non-empty, else fallback. Local helper so +// mapBindingError doesn't re-implement the trivial fall-through inline. +func nameOrEmpty(name, fallback string) string { + if name == "" { + return fallback } + return name } diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index 94b8674..f754c13 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -58,83 +58,86 @@ type errorCodeMeta struct { // the agent should retry, not show prose to the user. var codeToAgentAction = map[string]errorCodeMeta{ // ── Quota walls ──────────────────────────────────────────────────────── + // Each string adheres to the U3 contract (see agent_action.go): opens + // with "Tell the user", names the specific reason, names the exact next + // action, contains a full https://instanode.dev/ URL, under 280 chars. "quota_exceeded": { - AgentAction: "Tell the user they've hit their plan's usage limit. To unlock more, have them upgrade at https://instanode.dev/pricing.", + AgentAction: "Tell the user they've hit their plan's usage limit. Upgrade to Pro at https://instanode.dev/pricing — takes 30 seconds and unlocks higher quotas.", UpgradeURL: "https://instanode.dev/pricing", }, "storage_limit_reached": { - AgentAction: "Tell the user they've hit their storage limit for this plan. Have them upgrade at https://instanode.dev/pricing to provision larger or additional resources.", + AgentAction: "Tell the user they've hit their plan's storage limit. Upgrade to Pro for 5GB at https://instanode.dev/pricing to provision more storage.", UpgradeURL: "https://instanode.dev/pricing", }, "vault_quota_exceeded": { - AgentAction: "Tell the user they've hit their vault entry quota. Have them upgrade at https://instanode.dev/pricing to add more secrets.", + AgentAction: "Tell the user they've hit their plan's vault entry quota. Upgrade to Pro at https://instanode.dev/pricing to add more secrets.", UpgradeURL: "https://instanode.dev/pricing", }, "vault_not_available": { - AgentAction: "Tell the user the vault feature isn't available on their current plan. Have them upgrade at https://instanode.dev/pricing to use it.", + AgentAction: "Tell the user the vault feature isn't available on their current plan. Upgrade to Hobby or higher at https://instanode.dev/pricing to use it.", UpgradeURL: "https://instanode.dev/pricing", }, "vault_env_not_allowed": { - AgentAction: "Tell the user their plan only allows the default vault environment; multi-env vault requires Pro or higher. Upgrade at https://instanode.dev/pricing.", + AgentAction: "Tell the user their plan only allows the default vault env; multi-env vault requires Pro. Upgrade at https://instanode.dev/pricing — takes 30 seconds.", UpgradeURL: "https://instanode.dev/pricing", }, "member_limit": { - AgentAction: "Tell the user they've hit the team member limit for their plan. Have them upgrade at https://instanode.dev/pricing to add more teammates.", + AgentAction: "Tell the user they've hit the team member limit for their plan. Upgrade to Pro at https://instanode.dev/pricing to add more teammates.", UpgradeURL: "https://instanode.dev/pricing", }, "upgrade_required": { - AgentAction: "Tell the user this feature requires a higher plan. Have them upgrade at https://instanode.dev/pricing.", + AgentAction: "Tell the user this feature requires the Pro plan or higher. Upgrade at https://instanode.dev/pricing — takes 30 seconds.", UpgradeURL: "https://instanode.dev/pricing", }, "tier_unavailable": { - AgentAction: "Tell the user this resource type is not available on their current plan. Have them upgrade at https://instanode.dev/pricing.", + AgentAction: "Tell the user this resource type isn't available on their plan. Upgrade to Pro at https://instanode.dev/pricing to unlock it.", UpgradeURL: "https://instanode.dev/pricing", }, "rate_limit_exceeded": { - AgentAction: "Tell the user they've sent too many requests in a short window. Have them wait a minute and retry, or upgrade at https://instanode.dev/pricing for higher limits.", + AgentAction: "Tell the user they've sent too many requests in a short window. Wait 60 seconds and retry — or upgrade to Pro at https://instanode.dev/pricing for higher limits.", UpgradeURL: "https://instanode.dev/pricing", }, // ── Auth / token errors ──────────────────────────────────────────────── "unauthorized": { - AgentAction: "The user's INSTANODE_TOKEN is missing or invalid. Have them log in at https://instanode.dev/login to mint a new one.", + AgentAction: "Tell the user their INSTANODE_TOKEN is missing or invalid. Have them log in at https://instanode.dev/login to mint a new one — takes 30 seconds.", }, "auth_required": { - AgentAction: "This action requires an authenticated user. Have them log in at https://instanode.dev/login (or sign up — both flows mint a token).", + AgentAction: "Tell the user this action requires an authenticated session. Have them log in or sign up at https://instanode.dev/login — both flows mint a token.", }, "invalid_token": { - AgentAction: "The user's INSTANODE_TOKEN is invalid or expired. Have them log in at https://instanode.dev/login to mint a new one.", + AgentAction: "Tell the user their INSTANODE_TOKEN is invalid or expired. Have them log in at https://instanode.dev/login to mint a new one.", }, "missing_token": { - AgentAction: "No INSTANODE_TOKEN was provided. Have the user log in at https://instanode.dev/login and pass the token in Authorization: Bearer .", + AgentAction: "Tell the user no INSTANODE_TOKEN was provided. Have them log in at https://instanode.dev/login and pass it via Authorization: Bearer .", }, "vault_requires_auth": { - AgentAction: "Vault access requires an authenticated session. Have the user log in at https://instanode.dev/login.", + AgentAction: "Tell the user vault access requires an authenticated session. Have them log in at https://instanode.dev/login to mint a token.", }, "invitation_invalid": { - AgentAction: "This invitation link is invalid or has already been used. Ask the team owner to send a fresh invitation.", + AgentAction: "Tell the user this invitation link is invalid or already used. Ask the team owner to send a fresh invitation from https://instanode.dev/app/team.", }, "already_accepted": { - AgentAction: "This invitation has already been accepted. The user is already on the team — no action needed.", + AgentAction: "Tell the user this invitation has already been accepted — they're on the team. Have them open https://instanode.dev/app to see their resources.", }, "already_claimed": { - AgentAction: "These resources have already been claimed by another account. If the user believes this is wrong, have them contact support@instanode.dev.", + AgentAction: "Tell the user these resources were already claimed by another account. If they believe this is wrong, have them email support@instanode.dev — see https://instanode.dev/support.", }, // ── Expired / gone ───────────────────────────────────────────────────── "webhook_inactive": { - AgentAction: "This webhook token has expired or been deactivated. Have the user provision a fresh one with POST /webhook/new.", + AgentAction: "Tell the user this webhook token has expired or been deactivated. Have them provision a fresh one with POST https://instanode.dev/webhook/new.", }, "resource_not_found": { - AgentAction: "This resource no longer exists. It may have expired (anonymous resources auto-expire after 24h). Have the user provision a fresh one with POST /{type}/new.", + AgentAction: "Tell the user this resource no longer exists — anonymous resources auto-expire after 24h. Have them provision a fresh one at https://instanode.dev/docs/quickstart.", }, // ── Permission denied ────────────────────────────────────────────────── "forbidden": { - AgentAction: "The user does not have permission for this action. If they expected access, double-check they're logged in to the right team.", + AgentAction: "Tell the user they don't have permission for this action. Have them confirm they're logged in to the right team at https://instanode.dev/app/team.", }, "last_owner": { - AgentAction: "The team needs at least one owner. Have the user promote another member to owner before changing or removing this one.", + AgentAction: "Tell the user the team needs at least one owner. Have them promote another member to owner at https://instanode.dev/app/team before changing or removing this one.", }, } diff --git a/internal/handlers/provision_helper.go b/internal/handlers/provision_helper.go index 6224fba..f68283a 100644 --- a/internal/handlers/provision_helper.go +++ b/internal/handlers/provision_helper.go @@ -86,10 +86,11 @@ const RecycleGateErrorCode = "free_tier_recycle_requires_claim" const RecycleGateClaimURL = "https://instanode.dev/claim" // RecycleGateAgentAction is the verbatim sentence the calling agent surfaces -// to the human user when the gate fires. -const RecycleGateAgentAction = "Tell the user their previous free resource expired. " + - "Free tier resources require a one-time email claim before provisioning a replacement. " + - "Have them claim at https://instanode.dev/claim — takes 30 seconds, no card." +// to the human user when the gate fires. Adheres to the U3 contract +// (agent_action.go): "Tell the user" opening, specific reason +// (previous free resource expired), exact action (claim at the URL), full +// https://instanode.dev/ URL, under 280 chars. +const RecycleGateAgentAction = "Tell the user their previous free resource expired and the free tier requires a one-time email claim before re-provisioning. Have them claim at https://instanode.dev/claim — takes 30 seconds, no card." // RecycleGateMessage is the human-readable explanation accompanying the // machine error code. diff --git a/internal/handlers/stack.go b/internal/handlers/stack.go index efcd2b1..5aebdd9 100644 --- a/internal/handlers/stack.go +++ b/internal/handlers/stack.go @@ -1313,7 +1313,7 @@ func respondMultiEnvUpgradeRequired(c *fiber.Ctx, currentTier string) error { "error": "upgrade_required", "message": "Multi-env workflows require the Pro plan or higher. Your team is on the " + currentTier + " plan.", "upgrade_url": "https://instanode.dev/pricing", - "agent_action": "Tell user to upgrade to Pro at https://instanode.dev/pricing to unlock multi-env workflows.", + "agent_action": AgentActionMultiEnvUpgradeRequired, }) return ErrResponseWritten } @@ -1533,7 +1533,7 @@ func (h *StackHandler) Promote(c *fiber.Ctx) error { "ok": false, "error": "missing_image_ref", "message": "Source stack service " + ss.Name + " has no recorded image_ref; promote cannot deploy a cached image.", - "agent_action": "This stack predates the image-ref persistence migration. Redeploy the source stack first so its image is cached, then promote.", + "agent_action": AgentActionStackPromoteMissingImageRef, }) return ErrResponseWritten } diff --git a/internal/handlers/storage.go b/internal/handlers/storage.go index feb959c..5965117 100644 --- a/internal/handlers/storage.go +++ b/internal/handlers/storage.go @@ -286,7 +286,7 @@ func (h *StorageHandler) newStorageAuthenticated( if usedBytes >= limitBytes { return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired, "storage_limit_reached", fmt.Sprintf("Storage limit reached (%dMB). Upgrade your plan.", storageLimitMB), - fmt.Sprintf("Tell the user they've hit the %s tier storage limit (%dMB). Have them upgrade at %s to provision more storage.", team.PlanTier, storageLimitMB, DefaultPricingURL), + newAgentActionStorageLimitReached(team.PlanTier, storageLimitMB), DefaultPricingURL) } } diff --git a/internal/handlers/vault.go b/internal/handlers/vault.go index 3790e13..3de94bf 100644 --- a/internal/handlers/vault.go +++ b/internal/handlers/vault.go @@ -288,7 +288,7 @@ func (h *VaultHandler) upsertSecret(c *fiber.Ctx, action string) error { return respondErrorWithAgentAction(c, fiber.StatusPaymentRequired, vaultErrQuotaExceeded, fmt.Sprintf("Plan %q allows %d vault entries; you have %d. Upgrade to add more.", team.PlanTier, maxEntries, n), - fmt.Sprintf("Tell the user they've hit the %s tier vault quota (%d entries). Have them upgrade at %s to add more secrets.", team.PlanTier, maxEntries, DefaultPricingURL), + newAgentActionVaultQuotaExceeded(team.PlanTier, maxEntries), DefaultPricingURL) } } diff --git a/internal/middleware/env_policy.go b/internal/middleware/env_policy.go index 778025c..0a677ec 100644 --- a/internal/middleware/env_policy.go +++ b/internal/middleware/env_policy.go @@ -187,8 +187,14 @@ func RequireEnvAccess(action string, opts ...EnvPolicyOption) fiber.Handler { if agentRole == "" { agentRole = "unknown" } + // agent_action conforms to the U3 contract — see + // internal/handlers/agent_action.go. The middleware lives in a + // different package, so the string is built inline here; the + // shape (open with "Tell the user", name the specific reason, + // name the exact next action, include full https://instanode.dev/ URL) + // must stay in sync. agentAction := fmt.Sprintf( - "Tell the user this team's %s env requires the %s role to %s. Their role is %s. Have an owner run the prompt instead.", + "Tell the user the %s env requires the %s role to %s. Their role is %s — have a team owner run the prompt at https://instanode.dev/app/team or adjust the env-policy.", env, formatAllowedRoles(allowed), action, agentRole, ) return c.Status(fiber.StatusForbidden).JSON(fiber.Map{