AI Agent Governance: Approval as a Database Rule, Not a Prompt Instruction
A prompt that says 'ask before writing' is a convention, not a control. The chronology of moving our AI agent approval rule into the database, and what broke on the way.

AI agent governance is the set of controls that decide what an autonomous agent is allowed to change, who authorises the change, and what record of it survives afterwards. The part most teams get wrong is not the policy. It is where the policy lives. If the rule sits in a system prompt, it is a request. Ours now sits in a Postgres row-level security policy, where refusal is not a decision the agent takes part in.
We rewrote that boundary four times in six weeks while building our own agent mesh, and each rewrite was forced by a test that failed rather than by a design review that passed. What follows is the chronology: what we believed, what actually happened, how we found it, and the one line we kept.
Why is a system prompt not a governance control?
Because the agent reads the prompt, and the agent also reads everything else. An instruction that says "ask before writing" is a convention held by the exact component an attacker is trying to influence. A client email, a PDF, a task description and the founder's own instruction all arrive through the same channel, and the model has no reliable way to rank them.
The OWASP GenAI Security Project ranks prompt injection as LLM01, the first entry in its Top 10 for LLM Applications, and its guidance is explicit that no prompt-level mitigation fully removes the risk. Simon Willison, who named prompt injection in 2022, describes the dangerous combination as the lethal trifecta: an agent with access to private data, exposure to untrusted content, and the ability to communicate externally. Every useful business agent has all three by design. Ours reads client records, reads inbound documents, and posts to messaging and CRM.
Our first version of the control failed the same way for a less exotic reason. We had planned an approval code held inside the agent's own container. That is theatre. The agent can read the code, and if the code is a three-digit hash it can brute-force it. The conclusion we wrote down on 29 July 2026 became the design of the whole system: the approval code and the write credentials must live somewhere the agent cannot reach.
What did the first write gate actually look like?
A root-owned helper on the host, reachable only through an SSH forced command, holding the write credentials and the messaging token in a root-only file. The agent submits a proposal. The founder receives a one-time code on their phone, types it back, and the helper executes. The agent relays the code and never sees it, and never holds a write credential of any kind.
The properties that mattered, each verified by trying to break it rather than by reading the config:
- The code is single-use, bound to one specific action, expires in 60 minutes, and auto-rejects with an alert after three wrong attempts.
- Execution is registry-only. Ten action types were registered at the start and 24 by the end of that week. Anything unregistered is refused at the door rather than parsed and reasoned about.
- The agent cannot read the pending code through the status command. We tried.
- The SSH key yields no arbitrary command. The forced command is the only thing it can invoke.
- The full submit, code, approve, execute, audit loop was proven live before anything real was routed through it.
Then we got the next part wrong. Having built the gate, we also made the agent's own tools and skills read-only, and switched off its learning curator. That reverted within a day on the founder's pushback, and the reasoning is worth keeping: with a server-side gate in place, freezing the agent's wrappers buys nothing, because editing a wrapper does not move the gate. It only disables a headline capability the platform exists to provide.
The principle we wrote instead is the one we have not had to revisit. Restrict the consequences, not the capabilities. Version-control drift rather than forbidding it. An over-restricted agent is a failed agent. We then went the other way and put arbitrary root on the host inside the gate as a registered action type, which sounds reckless and is the opposite: maximum capability behind a single control point beats limited capability behind five leaky ones. The full architecture of the mesh those agents run on and the AI operating system case study cover the runtime side of it.
What happens when one person becomes the approval queue?
The gate works exactly as designed and the business stops. Our CBO agent tried to record client MoUs on 30 July and hit an approval prompt, correctly, because the data store is read-only and the gate was the only write path. The arithmetic killed it: 27 clients times 12 record sections is roughly 324 approval codes, all through one person, to do data entry.
That is not a control. It is the reason the database never gets filled, and filling it was the highest-value work outstanding. Two changes fixed it without widening the hole.
The key is the identity
We had one shared act key, so every submission arrived as "an agent wants to write" with no attribution. We replaced it with one key per agent, each with the profile baked into its forced command. An agent cannot claim to be another agent, because the claim is not something it sends. The audit line and the approval prompt now name the submitter.
A carve-out narrow enough to state in one sentence
A client section update executes immediately when the submitting agent is the COO, CBO or founder agent and the section is not the commercial one. Pricing stays gated from every agent. The founder still gets a notification, marked as information rather than a request, and every auto-executed write is in the audit with the agent named.
Ten days later we narrowed the gate again from the other end, on the founder's directive: keep the code prompt only for actions that are irreversible, externally visible or unusually high impact. Invoice delivery, payment posting, live ad budget changes, workflow activation and anything touching host, production or security stayed gated. Onboarding pack generation, date-of-joining updates and creating inactive workflows became self-service. Removing the code prompt does not broaden role access, because the role boundary is enforced somewhere else entirely.
Even that left one approver, so on 9 August we added delegation: JARVIS may release a named subset of actions on the founder's behalf. The invariant that makes it delegation rather than a bypass is that an agent can never approve its own request, enforced twice. A founder-agent submission of a delegated type routes to the human by construction, and the release command refuses on identity. We have written up what the approval data looked like after that separately.
If you are building agents that touch live systems and want the boundary designed in rather than retrofitted, that is what our AI agent development work is for.
How do you write an approval rule an agent cannot bypass?
You write it as a database policy and let the database answer. In our control plane every request resolves a person or an agent to a Postgres role, sets that role for the transaction, and lets row-level security decide what is visible and what is writable. An endpoint that forgets a WHERE clause therefore leaks nothing, which is the exact failure the file layer kept producing.
Approval sits in the same place. Postgres policies take a WITH CHECK expression that constrains rows being written, separately from the USING expression that constrains rows being read, so "who may set this to approved" is a property of the table rather than of the code path that happens to reach it. Three rules follow from that:
- High-risk changes are founder-only: money, scope, deliverables, contracts, client lifecycle, and anything at all about a person.
- Nobody approves their own proposal. That includes the founder.
- The process that writes cannot approve. The applier runs every two minutes as its own role, whose insert policy permits the states applied and failed and nothing else.
The applier also re-derives who approved each change, refuses if it disagrees with the record, and refuses if the underlying document has changed since approval. Then it writes and commits with proposer, approver and evidence in the message.
We proved it as behaviour rather than reading it off the config, including by connecting directly with psql and trying to write the row we were not allowed to write:
- Submitter approving their own proposal: 403.
- CBO approving a high-risk change: 403.
- Founder approving their own proposal: 403.
- An employee role inserting an approved state directly through psql: refused by row-level security.
- The applier role inserting an approved state directly through psql: refused by row-level security.
- A leader role forging the acting person as somebody else: refused by row-level security.
Two smaller decisions in the same layer have paid off more than expected. Reads return 404 rather than 403 for records a caller may not see, because an employee should not learn that a colleague's pay record exists, and an empty policy result genuinely cannot distinguish withheld from absent. And a verification is stored against the sha256 of the document at the moment it was confirmed, so editing the document silently reverts the record to needs-confirmation. Without that column, verified only ever means was verified once.
What did we get wrong before the policies actually held?
Every policy we had written was inert for the connection that mattered, for about 24 hours, and we found it by checking rather than by being attacked. Before writing a line of the agent API we queried the app role's attributes and got back super: true, bypass_rls: true.
The cause is mundane and it is the default. POSTGRES_USER in a Postgres compose file becomes the cluster superuser, and a superuser carries rolbypassrls. Worse, we had added FORCE ROW LEVEL SECURITY the day before believing it closed exactly this gap. It does not. FORCE subjects the table owner to its own policies and has no effect on a role that holds bypass.
The uncomfortable part: we had declined a managed Postgres host specifically because its service-role key bypasses row-level security, and then recreated the same hole locally by accident within the hour. The risk is not where the bypass key is stored. It is that a bypass role exists at all and is the obvious thing to connect as.
The fix was a connection role that owns nothing, is named in no policy, and holds NOSUPERUSER, NOBYPASSRLS and NOINHERIT. NOINHERIT matters on its own: without it the role silently holds the union of every profile's privileges the moment it connects. The test we now keep is that before any role is set, the API gets permission denied for the table, not zero rows. Fail-closed by construction rather than by remembering.
How do you know an approval threshold is doing anything?
Ask what it would have caught last month. We gave the founder agent six criteria for releasing a delegated action, one of which refused any ad budget change above 2x the current value or above Rs 10,000 per day. That ceiling was invented, not measured. Checking it against the live accounts took ten minutes:
- Largest active daily budget across the portfolio: Rs 2,000.
- Median daily budget: Rs 30.
- Total across 15 active ad sets in 11 accounts: Rs 3,840 per day.
- Requests a Rs 10,000 ceiling would have escalated: 0 of 15. Ever.
One of the two money criteria could never fire. It would have read like a control in every review of it and done nothing, leaving the relative test as the only thing between an agent and an ad budget. We recalibrated to Rs 3,000, wrote the measurement next to the rule, and added an instruction to re-check it as budgets grow. A threshold nobody measured is a guess wearing a number's authority, and it survives review precisely because it looks specific.
Verifying the check turned up two more faults in the same hour, both of the same shape. Reading an ad set's daily budget returns paise while setting it takes rupees, so an approver comparing the two directly is wrong by 100x in the direction that waves a large rise through as though it were a cut: a request for Rs 4,500 against a stored 100000 looks like a reduction. And our own first pass at the budget analysis summed every account as rupees when one of them bills in AED, which did not change the conclusion but did change the rule. A fixed rupee ceiling is meaningless against a non-rupee account, so those escalate outright now instead of being compared with no exchange rate.
The other gap was in the record itself. Refusals carried a reason from the start and approvals carried nothing, so the only decisions anyone could review afterwards were the cautious ones. The audit was systematically biased toward caution while saying nothing about the calls that actually spent money. Approvals now carry a reason too.
What does an AI agent governance model need to have?
Six things, in the order we learned we needed them. None of them are prompt text.
- A single control point that holds the credentials, off the agent's machine and behind a forced command.
- A registry of permitted action types, so unregistered requests are refused at the door instead of interpreted.
- Per-agent identity that the agent does not assert. The key carries the profile, or attribution is a claim.
- Enforcement in the database, using row-level security with a WITH CHECK on the write, so that no code path can be the thing that forgets.
- Named refusal criteria for whoever approves. A second approver with no criteria is not a control, it is a delay.
- Thresholds measured against real data, and re-measured as the numbers move.
The criterion we use to decide what stays gated is not seniority. It is: can this be undone, and does it leave the company. Seniority would have handed the COO agent everything an operations chief may do, spend included. Reversibility and blast radius put a container restart and a CORS origin in one bucket, and a client payment in another, regardless of who asked. The refusal test that matters most in practice is provenance: if the justification for a request traces back to an email, a PDF or a client message the agent read, rather than to a person who asked, refuse it. That is the injection path, and it is the test most likely to be skipped because everything else about the request looks ordinary.
The primitive, if you keep one line from this: a control the agent can read is a suggestion. Put the rule where the agent cannot reach it, and it stops depending on the agent's cooperation.
Frequently asked questions
Does gating writes make the agent read-only in practice?
No, and treating it that way is the common failure. Our agents hold full capability including arbitrary root on the host, and the vast majority of what they do executes with no approval at all. The gate applies to a registry of consequential action types. We narrowed it twice, both times because approval volume was suppressing useful work rather than because anything went wrong.
Can you do this without Postgres row-level security?
You can enforce the same rules in application code, and it will hold until the day someone adds a second code path. Row-level security is attractive because it is enforced by the database for every connection, including a direct psql session by someone with the connection string. The specific reason we moved was that we kept writing access rules twice and the second copy was where the drift started.
How many approvals per week is the right number?
There is no benchmark worth quoting, but there is a diagnostic. If the answer is zero, your thresholds are probably dead code and you should measure them against last month's actual requests. If approvals are queuing behind one person, the fix is a second approver with written refusal criteria, not a wider self-service list.
Is an audit log enough on its own?
It is necessary and it is not a control. An audit tells you what happened after it happened, which is the right shape for reversible internal work and the wrong shape for a payment or a live budget change. Our audit records refused actions and auto-executed ones as well as approved ones, because a log that only contains successes cannot tell you whether the boundary is working.
What tends to break first when a gate goes in?
Attribution and reason text, in our experience. We ran a shared key for a week before noticing that submissions carried no submitter. Separately, our refusal reasons were arriving truncated to the first word, because an SSH forced command cannot do quoted expansion and the parser was taking only the next argument. A refusal reason that lies about itself is worse than no reason at all, and it only surfaced because a test ran through the real path rather than the convenient one.
If you are putting agents anywhere near client data, money or production systems and want the boundary designed before the incident rather than after it, talk to us about your setup.

Founder and Director at Neogen Media. Writing field notes on AI automation, growth systems, and the integrated playbook we ship for Indian SMBs. Based in Kochi.
Follow on LinkedIn