Skip to content
BasaltDBBasaltDB
Star

Error codes

View as Markdown

Every refusal, hold and failure from BasaltDB carries a code. Each code on this page has its own anchor, such as /reference/errors/#stale-read, so an error from the engine can link straight to its entry. If your agent just reported a problem and you want the short version, start with When something is refused.

A tool error is an MCP tool result with isError: true. The text is the JSON body, with no sentence before it. This is an example:

{
  "code": "STATEMENT_REFUSED",
  "message": "DDL_NOT_ALLOWED: DropStmt",
  "reason": "DDL_NOT_ALLOWED",
  "personMessage": "The agent tried to change the database schema (DDL). Basalt never lets an agent do that. Make the change yourself if you want it.",
  "nextSteps": [
    {
      "actor": "AGENT",
      "action": "STOP",
      "text": "Do not retry and do not look for another way to change the schema."
    },
    {
      "actor": "AGENT",
      "action": "ASK_PERSON",
      "text": "Tell the person which schema change you need; they make it themselves."
    }
  ],
  "docsUrl": "https://docs.basaltvault.app/reference/errors/#statement-refused"
}
Field When it is there
code Always. One of the codes below. Stable.
message Always. One line for the agent.
reason For every code that has more than one case. Listed under each code.
nextSteps Always. At least one step, first step first. See Next steps.
personMessage When you must know or act. The agent shows it to you word for word.
docsUrl Always. The link to the code’s section on this page.
sqlState When Postgres raised the error. SQL_ERROR uses it to say which.
errorId On UNAVAILABLE and INTERNAL. Quote it when you report a problem.
approval On APPROVAL_REQUIRED: approvalId, expiresAt, rows, headline, approvalUrl, approveCommand and personMessage. See Approvals.

A refused write is also a change record with status REFUSED and the same code and reason, so it shows in basalt log. The exception is INTENT_REQUIRED: a write with no intent leaves no record. See Ledger and Change record.

Each step in nextSteps has an actor (AGENT or PERSON), an action and one plain sentence of text. The engine fills in the arguments and commands, so none of them has a placeholder.

Actor Action What it means
AGENT CALL_TOOL Call tool with arguments. With repeatCall: true the arguments merge over the call that failed.
AGENT REVISE_CALL Change the statement or arguments as text says, then call the tool again.
AGENT RETRY_SMALLER_BATCHES Split the write into writes of at most maxRows rows. Only after you said yes.
AGENT ASK_PERSON Ask you a question and wait for the answer.
AGENT END_TURN End the turn. You act next.
AGENT STOP Do not retry. Do not work around it.
AGENT RUN_COMMAND Run command. Only on an engine the agent operates. See A bot on its own computer.
PERSON OPEN_LINK You open url. Often with a command that opens the same page.
PERSON RUN_COMMAND You run command.
PERSON TELL_AGENT You tell the agent that you did it.

The agent follows the AGENT steps and shows you each PERSON step’s command and url unchanged. How an agent reads all of this: For agents.

Code In short
INTENT_REQUIRED A write had no reason.
STALE_READ The row changed after the agent read it.
STATEMENT_REFUSED The statement is one an agent may not run.
ROW_LIMIT_EXCEEDED More than 50 rows in one action.
QUERY_TIMEOUT A read or an undo ran out of time or rows.
APPROVAL_REQUIRED The write is held for your approval.
APPROVAL_EXPIRED Nobody approved within 24 hours.
APPROVAL_INVALID The approval cannot be used.
REVERT_INVALID The undo cannot be applied as asked.
REVERT_EXPIRED The undo was not signed in time.
NOTHING_TO_REVERT The undo would restore nothing.
PERMISSION_DENIED The agent’s role cannot touch that table.
NOT_FOUND The id is not this agent’s, or does not exist.
SQL_ERROR Postgres refused the statement.
INVALID_ARGUMENT The call has a wrong or unknown argument.
UNAVAILABLE The engine, the guard or the database cannot be reached.
INTERNAL A defect in the engine or the guard.

INTENT_REQUIRED

What it means. A write arrived without an intent. Nothing was changed.

Why it happens. Every write needs a reason a person can read next to the rows it changed. The engine checks it, and so does the guard in your Postgres (SQLSTATE BS001). A write with the agent’s own credential that skips the engine fails there too.

What the agent should do. Send the same write again with an intent: one sentence, at most 500 characters. A run started with run_start has its own reason, and each write still needs its own.

What you should do. Usually nothing. If an agent forgets this often, tell it in its instructions to start a run with run_start and to give an intent on every write. See Runs and Intent missing.

STALE_READ

What it means. The agent read a row, then tried to update or delete it, and the row changed in between. Nothing was changed.

Why it happens. Your team, your app or another agent wrote the row after the agent read it, so the agent would decide from values that are no longer true. The guard compares the row’s version (xmin:<n>) with the one in the run’s reads (SQLSTATE BS004). A row the run never read is a blind write. That is allowed.

What the agent should do. Read the row again with query, decide again from the fresh values and send a new write.

What you should do. Usually nothing. If it keeps happening on the same rows, something else is writing them while the agent works. Pause that writer, or run the job in a quiet moment. See Stale read.

STATEMENT_REFUSED

What it means. The statement is one an agent may not run, or it went to the wrong tool. Nothing was changed. The refusal is in the ledger.

Why it happens. The engine’s rules refuse DDL, several statements in one call, COPY, a WITH that changes data, functions that are not on the allowed list and other statements. The guard refuses the same things in Postgres (SQLSTATE BS007). Agents cannot drop or change tables. See Rules.

Reasons.

Reason What happened What the agent does
PARSE_ERROR The statement does not parse. Fix the SQL and send it again.
MULTIPLE_STATEMENTS More than one statement in one call. Send one statement per call.
WRONG_TOOL A write went to query, or a read went to write. Send it to the right tool.
DDL_NOT_ALLOWED CREATE, ALTER, DROP or TRUNCATE. Stop, do not retry, and ask you. You get a personMessage.
STATEMENT_NOT_ALLOWED SET, DO, COPY, EXPLAIN, transaction control or another utility statement. Rewrite as a plain SELECT, INSERT, UPDATE or DELETE.
SELECT_INTO SELECT ... INTO. Rewrite it as a plain SELECT.
LOCKING_READ SELECT ... FOR UPDATE and similar. Drop the locking clause.
MERGE_NOT_ALLOWED MERGE. Use separate INSERT and UPDATE writes.
DATA_MODIFYING_WITH A WITH that inserts, updates or deletes. Send the changes as separate writes.
FUNCTION_NOT_ALLOWED A function that is not on the allowed list. Rewrite without that function.
NOT_REPEATABLE A write that would be held contains a value that changes between transactions, such as now(). Pass the value as a parameter.
NO_PRIMARY_KEY The write targets a table whose rows the guard cannot identify. Stop. You get a personMessage.
NOT_ALLOWED The guard refused the statement for a reason outside the list above. Stop.

What you should do. If you want the change that was refused, run it yourself with your own admin connection. Agents never change your tables’ structure. For DDL_NOT_ALLOWED the agent asks you. See Refused.

ROW_LIMIT_EXCEEDED

What it means. One action touched more than 50 rows, cascaded rows included. Nothing was changed.

Why it happens. A delete of 5 rows that cascades into 50 more counts as 55. The limit is checked in your Postgres (SQLSTATE BS003). It applies to a direct connection with the agent’s credential too.

What the agent should do. Stop and ask you. Only if you say yes, split the write into writes of up to 50 rows, with the same intent and run. Each batch is held and needs your approval. It must not split the write on its own.

What you should do. Decide. A change this size usually belongs in a migration or a data job you run yourself. If the job really is this big, say yes to batches and review each one. Either way you can undo the run afterwards. See Row limit.

QUERY_TIMEOUT

What it means. A read hit the row limit or the time limit and was stopped. Nothing was changed.

Why it happens. A SELECT returned more rows than the read row limit, or ran past the engine’s time limit (SQLSTATE 57014). The same code covers request_revert when preparing an undo runs past its 100-second limit.

Reasons.

Reason What happened What the agent does
ROW_LIMIT The read returned too many rows. Add a WHERE or a LIMIT, or select fewer columns, and send it again.
TIME_LIMIT The read ran too long. Narrow the query and send it again.
TIME_LIMIT preview_revert or request_revert ran out of time. Stop, tell you, and end the turn. You run the revert from a terminal (below).

What you should do. For a read, nothing. For a timed-out undo, run it yourself. Preview it, then open it:

basalt revert --agent NAME --run RUN_ID --dry-run
basalt revert --agent NAME --run RUN_ID --open

Approve it in the browser. The command prints the revert id. Tell the agent that id. The limits are in Limits and defaults. See Timeout.

APPROVAL_REQUIRED

What it means. The write is allowed but not yet. It is held for your approval and has not run.

Why it happens. The rules hold a write of 11 to 50 rows, and any DELETE, until a person approves it with a passkey (SQLSTATE BS002). The engine previews the exact rows in a transaction it rolls back, records the change as APPROVAL_PENDING and returns the approval. The agent gets approval with an id, an expiry (24 hours), a one-line headline, a link and a command.

Reasons.

Reason What happened Steps
HELD The write is held and the engine serves the approval page. You open the link. The agent repeats the write once with approvalId, which waits up to 90 seconds.
HELD_NO_PAGES The engine does not serve the approval pages. You run basalt approve --agent NAME --open APPROVAL_ID. The agent then repeats the write.
NO_PASSKEY No passkey is registered, so nothing can be approved. You run basalt init --agent NAME --add-passkey and open the link. The agent ends its turn.
WAIT_ENDED The agent waited and you had not approved yet. You open the link and tell the agent when you approved. The agent ends its turn. It does not wait again.

What the agent should do. Show you personMessage word for word, with the link on its own line. Then, in the same turn, repeat the same sql, params, intent and runId with approvalId. That call waits for you. If the wait ends, end the turn and say the write is waiting. Never split the write to get under the hold.

What you should do. Open the link, check the rows and approve with your passkey. The waiting write then applies by itself. If the agent already ended its turn, tell it you approved. If you lost the link:

basalt approve --agent NAME --open APPROVAL_ID

To refuse, close the page. The write stays blocked and expires after 24 hours. How it works: Approvals. See Held and Passkeys.

APPROVAL_EXPIRED

What it means. Nobody approved the held write within 24 hours. It is blocked and nothing was changed.

Why it happens. A hold lasts 24 hours. No answer means blocked (SQLSTATE BS005). The ledger keeps the held and expired rows.

What the agent should do. Ask you whether to send the write again. If you say yes, send it as a new write, which is held anew.

What you should do. Tell the agent yes or no. You get a personMessage that says what expired.

APPROVAL_INVALID

What it means. The approval cannot be used for this call. Nothing was changed.

Why it happens. The approval does not exist, was already used, belongs to another agent, is not signed yet, or no longer matches the rows (SQLSTATE BS005). Only the guard in Postgres decides this, from your passkey signature.

Reasons.

Reason What happened What the agent does What you do
NOT_SIGNED The approval is not signed yet. End the turn. Open the link and approve.
ALREADY_USED The write already applied. Check run_history, then stop. Nothing.
DIFFERENT_WRITE The statement, parameters or run differ from what was held. Send the held write unchanged, with approvalId. Nothing.
NOT_THIS_AGENT The approval belongs to another agent. Call list_pending for its own. Nothing.
ROWS_CHANGED The rows changed after you looked. Read the rows again with query, then send a new write, which is held anew. Approve the new hold. You get a personMessage.
BAD_SIGNATURE The signature did not verify for this approval. End the turn. Open the link and approve again.
PASSKEY_REVOKED The passkey that signed was removed or is not registered. End the turn. basalt passkey list --agent NAME, then basalt init --agent NAME --add-passkey, then open the link.

What you should do. See the table. More: Held and Passkeys.

REVERT_INVALID

What it means. The undo cannot be applied as asked. Nothing was restored.

Why it happens. The revert does not exist, was already applied, was rejected, carries a bad signature, or its rows changed since the preview (SQLSTATE BS005). For a large plan applied in batches: the plan is not this agent’s, is not signed, is already finished, or a line is not in the plan, is repeated or is already applied.

Reasons.

Reason What happened What the agent does What you do
LINE_INVALID A batch line is not in the plan, repeated or already applied. Call revert_plan with pending: true and use the lines it lists. Nothing.
PLAN_NOT_SIGNED The large plan is not signed yet. Call revert_plan, then end the turn. Open the revert link and approve.
PLAN_FINISHED The plan is already signed, or already finished. Call revert_plan to confirm, then stop. Nothing.
NOT_THIS_AGENT The revert belongs to another agent. Stop. Nothing.
ROWS_CHANGED Rows changed since the preview. Ask for the undo again with request_revert, same scope. Approve the new undo. You get a personMessage.
BAD_SIGNATURE The signature did not verify for this revert. End the turn. Open the revert link and approve again.
PASSKEY_REVOKED The passkey that signed was removed or is not registered. End the turn. basalt passkey list --agent NAME, then basalt init --agent NAME --add-passkey, then open the link.

A revert you rejected on the page is also REVERT_INVALID. See Undo a run.

REVERT_EXPIRED

What it means. The undo was not signed in time. Nothing was restored, or, for a large plan, only what was applied before the deadline.

Why it happens. A prepared undo must be signed within 1 hour. A signed large plan must be finished within 24 hours of the signature (SQLSTATE BS005).

Reasons. NOT_SIGNED (nobody signed within the hour) and NOT_FINISHED (a signed large plan was not finished in 24 hours).

What the agent should do. Ask you whether to prepare it again.

What you should do. Run the undo again and approve it:

basalt revert --agent NAME --run RUN_ID --open

NOTHING_TO_REVERT

What it means. An undo would restore nothing.

Why it happens. Every row in the scope would be skipped, or the scope wrote no rows (SQLSTATE BS009). Rows are skipped when your team or your app changed them since the agent did. People win.

Reasons.

Reason What happened What the agent does
READS_ONLY The scope wrote no row an undo could restore: only reads, or rows it both inserted and deleted. Stop. You get a personMessage.
ALL_CHANGED_BY_PEOPLE Every row would be skipped because it changed after the agent. Stop. You get a personMessage.
ALREADY_REVERTED Every row was last written by a revert of this scope. Ask you whether to undo that undo. If yes, request_revert with that revert’s changeId.

What you should do. Usually nothing. Read the personMessage. For ALREADY_REVERTED you can say yes to bring the agent’s values back. You still approve it.

PERMISSION_DENIED

What it means. The agent’s role has no privilege on that table. Nothing was changed.

Why it happens. Postgres refused the statement with SQLSTATE 42501. The agent can read and write only the tables you gave it with --write and --read.

What the agent should do. Stop and ask you whether it may use the table. Do not look for another route to the data.

What you should do. If you want the agent to use the table, run basalt init again with the table added to --write or --read, and tell the agent. Init keeps the agent’s password. The personMessage has the command. See Something else and CLI.

NOT_FOUND

What it means. The run, change, approval or revert named in the call is not this agent’s, or does not exist.

Why it happens. An agent sees only its own runs, changes, approvals and reverts. An id that exists for another agent reads exactly like an unknown id.

Reasons.

Reason What happened What the agent does
RUN No such run for this agent. Call list_runs and use an id from there.
CHANGE No such change for this agent. Call list_runs.
APPROVAL No such approval for this agent. Call list_pending.
REVERT No such revert for this agent. Ask you for the revert id that basalt revert printed.
TASK The agent never ran in that task. Call list_runs. You get a personMessage with the command to revert it yourself.
LINK A page link without its secret, or one that was replaced. Pages only. You ask the agent for a new link.
LINK_RESET A revert link the agent reset to its own work. Pages only. You ask the agent for the new link.

What you should do. Usually nothing. For TASK, to undo work in a task your agent never ran in, run basalt revert --agent NAME --task TASK_ID --open with an agent that did, or ask that agent to request it. An agent never undoes another agent’s work. An own scope with nothing to undo is NOTHING_TO_REVERT, not this code.

SQL_ERROR

What it means. Postgres raised an error for the agent’s statement.

Why it happens. A syntax error, an unknown column, a constraint violation or any other error that no more specific code covers. sqlState says which.

Reasons. NO_ROWS_NOW: the statement found no rows to act on, often because the row changed or was removed since the agent looked.

What the agent should do. Read message and sqlState, fix the statement and send it again. For NO_ROWS_NOW, read the row with query first.

What you should do. Nothing, unless the same error repeats. Then a constraint in your schema is probably refusing what the agent tries to do.

INVALID_ARGUMENT

What it means. The call has an argument that fails validation, or an argument the tool does not know. Unknown arguments are refused, never ignored.

Why it happens. The call does not match the tool’s input schema. See MCP tools.

Reasons.

Reason What happened What the agent does
none The call fails validation. Fix the call and send it again.
STALE_CURSOR The rows of a preview changed between pages. Send the same call again without cursor.
SCOPE_FILTER A runs or tables filter names something outside the agent’s own work. Drop the refused entries. Use run_history or list_runs to see its own runs and tables.
SCOPE_FILTER_CAP More than 20 runs or tables in a filter. Use at most 20 entries, or no filter. You can narrow on the page.

What you should do. Nothing.

UNAVAILABLE

What it means. The engine, the guard or your database cannot be reached, or the agent’s setup has not run. Nothing was changed.

Why it happens. See the reasons. The status tool and basalt status --agent NAME name the cause and the fix.

Reasons.

Reason What happened What the agent does What you do
DATABASE_UNREACHABLE Your Postgres cannot be reached. Try the same call again after 10 seconds, then ask you. Check the database is up.
CREDENTIAL_REJECTED The database refused the agent’s password. Stop. Restart the engine. If that does not fix it, basalt init --agent NAME --rotate-password, then restart.
GUARD_MISSING The guard is not in this database. Stop. Run basalt init again with the flags you used the first time.
GUARD_OUTDATED The guard is older than this basalt needs. Stop. Only status works until you upgrade. basalt upgrade --agent NAME. See Upgrade.
ENGINE_UNREACHABLE The engine is not running. Stop. status still works. Start the engine. basalt status --agent NAME prints the command for your machine.
PAGES_OFF The engine does not serve the approval pages. Call list_pending and hand you the approveCommand. Run the command.
SETUP_PENDING The agent’s setup from your laptop has not run. End the turn. After you tell it, call status. Run basalt init --setup-code CODE on your own computer.
SETUP_EXPIRED The setup code expired after 24 hours. End the turn. Ask the agent for a new code. See A bot on its own computer.

What you should do. See the table and Something else. On an engine your bot runs, the commands that need your admin connection string run on your own computer, never on the bot’s machine. The agent gives you a setup code instead.

INTERNAL

What it means. A defect in the engine or the guard. The action did not complete.

Why it happens. Something broke that should not (SQLSTATE BS006 or BS008, or an engine fault). It is not caused by your data or the agent’s call.

What the agent should do. Stop. Do not retry in a loop. Give you the errorId.

What you should do. Send the errorId to the project. See Security.

A query or write that worked can carry warnings, and so can run_start. A warning has a code, a message, the table when it applies, and its own personMessage and nextSteps.

Warning What it means What to do
RLS_HIDES_ROWS Row-level security hides every row of a table in the statement from the agent role, and it read or changed 0 rows. The agent asks you. To let it see the rows, run basalt init again with --agent-rls=all-rows. See Hosts.
GUARD_UPGRADE_AVAILABLE The guard has non-breaking migrations to install. The work goes on. Run basalt upgrade --agent NAME when you are ready.

BasaltDB · Apache-2.0