Base44 gives agents the ability to act. That creates a choice nobody wants: hand an AI permanent authority over your order system, or keep it read-only and useless. Limen removes the choice. An agent can ask for a sensitive action but never holds the authority to perform it — a human grants one action, on one record, for one amount, once, expiring in minutes, and a protected backend function enforces exactly that. Replay it, change the amount, revoke it, or let it expire, and the same function refuses, with a stable code and a hash-linked receipt.
Watch the demo ↗ · Live app ↗ · Try it in 60 seconds ↗ · The four refusals ↗ · Honesty table ↗ · Run it locally ↗
Built for the Base44 Dev Build-Off · Web app · Everything below is verifiable in this repository.
An agent proposes a refund, a human grants narrow authority, the refund executes — then the same backend refuses a replay, an altered amount and a revoked grant.
YTDown.com_YouTube_Limen-AI-agents-that-ask-permission-not-_Media_wHuu3_F0h3c_001_1080p.mp4
No player above? GitHub renders inline video only on github.com — watch it on YouTube ↗.
- The 2-minute demo
- Try it yourself in 60 seconds
- The problem
- What Limen is
- The lifecycle
- Nova's exact boundary
- The four refusals — the part that matters
- The concurrency guard
- Binding an approval to an action
- Receipts you can recompute
- Why each Base44 capability is load-bearing
- Engineering decisions & the hard problems
- What's real vs simplified — the honesty table
- Tests
- Run it locally
- Project layout
- Docs
Open limen.base44.app and sign in:
enochid200+limendemo@gmail.com
Threshold-Demo-8754!
Then: Reset demo → Open CASE-204 → Ask Nova to resolve → approve $42 in the control room → run the boundary tests.
No extension, no setup, no database edits. The account is scoped to the synthetic Northstar Goods workspace and cannot write a single record directly — attempting to forge an order, a grant or a receipt with these exact credentials is refused, and that refusal is itself part of the test suite (scripts/smoke-judge-path.ts).
Open the control room in a second window before asking Nova. The request appears there without a refresh — two independent surfaces, both reading persisted Base44 state.
Maya runs support operations at Northstar Goods. She built Nova, a Base44 agent, to resolve customer issues. Nova can read a case, understand a duplicate charge, and work out that a $42 refund is appropriate.
To finish the job, Nova needs a refund capability. Maya's options today are both bad:
- Give the agent standing access to the refund operation — permanent, unlimited, for any order, forever.
- Keep it read-only — and handle every routine $20–$50 request herself.
Base44's tool permissions answer "may this agent use this class of capability?". A business needs the runtime question answered: this exact action, on this exact record, for this amount, this many times, within this window — and revocable right now.
A B2B SaaS permission layer for teams running AI agents against real business systems.
The unit is not an AI response. It is an action grant:
| Bound to | Value in the demo |
|---|---|
| one agent | nova_support |
| one action + resource | refund.create on ORD-1042 |
| an amount ceiling | $42.00 — never above the request or the policy |
| a use limit | 1 |
| an expiry | 5 minutes, server time |
| a payload hash | the exact action, canonically hashed |
| an evidence digest | the exact document the reviewer saw |
Anything outside those bounds is refused by the same protected function that performs the legitimate action — and every outcome, allowed or refused, writes a receipt.
sequenceDiagram
autonumber
participant C as Customer
participant N as Nova (Base44 agent)
participant P as proposeRefund
participant M as Maya (reviewer)
participant X as executeWithGrant
participant DB as Base44
C->>N: "I was charged twice for $42"
N->>DB: read CustomerCase, Order, Policy
Note over N: read-only tools — it cannot write
N->>P: proposeRefund(CASE-204, ORD-1042, $42)
P->>P: idempotency → resolve → bind payload hash
P->>P: 14 deterministic checks (no model involved)
P->>DB: ActionRequest = AWAITING_APPROVAL
DB-->>M: appears live, no refresh
M->>DB: approve $42 · 1 use · 5 min
Note over M: may narrow, can never widen
N->>X: executeWithGrant(attempted payload)
X->>X: reload everything, recompute hash
X->>X: compare-and-set the grant ← the guard
X->>DB: Refund · Order REFUNDED · Case RESOLVED · Receipt
DB-->>C: resolution appears live
Only backend functions may perform state transitions. The browser cannot change a refund, a grant, or a receipt — it can only ask a function to.
The entire security argument fits in one checked-in file: base44/agents/nova_support.jsonc.
| Nova has | Nova does not have |
|---|---|
read on CustomerCase |
any write on any entity |
read on Order |
approveActionRequest / denyActionRequest |
read on Policy |
revokeGrant |
proposeRefund |
access to Grant, Receipt, Execution, AuditEvent |
executeWithGrant |
a raw refund function |
Verified against the deployed agent — in a real conversation Nova used exactly:
read_CustomerCase(success) · read_Order(success) · read_Policy(success) · proposeRefund(success)
arguments: {"case_id":"CASE-204","order_id":"ORD-1042","amount":42,"currency":"USD"}
…and afterwards the order was still PAID, with no grant and no execution.
Be precise about what enforces this. A function invoked as an agent tool runs with the conversing human's session, so if
approveActionRequestwere listed above, Nova could approve as Maya. It is not listed. The allow-list is server-owned configuration, pushed from this repository and unreachable by the agent — a real control, but a configuration control, and the threat model says so.
A successful refund is table stakes. What makes Limen a security product is that the same protected function refuses these — each producing a stable code, an Execution row, and a receipt.
| Attack | Code returned | Why it fails |
|---|---|---|
| Replay the grant | GRANT_ALREADY_CONSUMED |
compare-and-set already flipped used_count to 1 |
| Change $42 → $500 after approval | PAYLOAD_MISMATCH |
recomputed payload hash ≠ the hash the grant is bound to |
| Use a revoked grant | GRANT_REVOKED |
grant state is re-read at execution, not trusted from approval |
| Use an expired grant | GRANT_EXPIRED |
server-time comparison, independent of any sweep |
| (bonus) Swap the evidence | EVIDENCE_DIGEST_MISMATCH |
digest is re-read and compared to what was approved |
All five are reachable from the Boundary tests drawer in the live app. Each button makes a genuine call to runBoundaryTest, which routes through the identical runExecution code path Nova uses — the drawer shows the expected code before the run and the actual code after, and flags a divergence rather than hiding it. A drawer that always printed the expected answer would prove nothing.
Base44 documents no transactions, no conditional updates, and no optimistic locking. So the design is built on something measured, not assumed.
updateMany accepts Mongo-style operators and reports how many records it changed — which makes it a compare-and-set:
updateMany(
{ id, status: "ACTIVE", used_count: 0 }, // only while unclaimed
{ $set: { status: "CONSUMED" }, $inc: { used_count: 1 } },
);
// updated === 1 → this caller won the race and may proceed
// updated === 0 → someone else already did; refuseThe guard commits before any other write. A caller that loses is refused before it can touch the order — ordering the writes any other way would allow a double refund under load.
Measured against the deployed app:
| Test | Result |
|---|---|
| 4 simultaneous redemptions of one grant | 1 EXECUTED, 3 GRANT_ALREADY_CONSUMED |
Refund rows created |
exactly 1 |
used_count after |
1 |
| Raw primitive, 8-way concurrency, 3 runs | 1 winner every time |
What is not claimed: not atomic, not ACID, not exactly-once. This is an observed property of a single-record guarded update, not a documented platform guarantee. The honest statement is that concurrent redemptions are guarded by a compare-and-set, and acceptance test A16 demonstrates the outcome.
An approval is not "yes to a request id". It is a commitment to one exact action, expressed as a hash over a closed field set:
version · organization_id · action_type · resource_type
resource_id · amount_cents · currency · evidence_digest
Approval stores that hash on the grant. Execution rebuilds it from freshly loaded records and from what the caller is actually attempting, then compares. A substituted $500 produces a different hash and is refused — without any field-by-field comparison.
Three details make it hold:
- Money is integer cents. Floating point would let two equal sums serialize differently. The test that matters:
42.10→4210, not4209, which is what naivevalue * 100gives. - Canonicalization is strict. Keys sorted at every depth;
undefinedomitted butnullpreserved;-0normalized;Date,bigintand class instances rejected rather than coerced. - The field list is closed. Adding a column to an entity cannot silently widen or narrow what a grant is bound to.
receipt_hash = SHA-256( canonical(body) + previous_hash )
Every outcome writes one — executed, blocked, or denied. The Verify button does not compare two stored strings in the browser. It calls verifyReceipt, which reads the stored body, recomputes the hash server-side, and returns both values so the drawer can show them side by side.
Proven by tampering: editing a stored receipt directly made verification fail, the chain report broken, and the break located at the correct sequence number. Restoring the body restored validity.
Tamper-evident, not tamper-proof. Not externally signed, not distributed, not a blockchain. Someone able to rewrite the entire chain could produce a self-consistent forgery. What the chain gives you is that a partial edit is detectable.
Not decoration — remove any one and the product stops working. Full mapping with evidence: docs/base44-feature-map.md.
| Capability | What breaks without it |
|---|---|
| Authentication | No reviewer identity. An approval could not be attributed, and the grant's reviewer_user_id would be a guess. |
| Database / entities | The browser could forge a grant. create: false on Grant, Execution, Receipt and AuditEvent is what makes "only functions may write" true — verified by attempting exactly those forgeries. |
| Backend functions (Deno) | There would be no protected path. executeWithGrant is the only code that can move money. |
| AI / agents | The proposal would come from a button, not an agent. Bounding what an agent may do requires a real agent with real tools. |
| Realtime subscriptions | The handoff between Nova's console and Maya's control room would be a page refresh. |
| File & media storage | Nothing to bind a digest to. The "evidence changed after approval" refusal needs a real document with a real hash. |
Pure domain logic, injected time. Canonicalization, policy evaluation, grant narrowing, the execution recheck, receipt hashing and explanation validation are all pure functions — no I/O, no clock, nowMs passed in. That is why 202 tests can cover the security model without deploying anything, and why any refusal can be reasoned about by reading one file.
The model explains; code decides. The reviewer explanation is validated against the record and rejected — not repaired — if it states an amount or an order that isn't there. Stripping an invented number would leave a plausible sentence a reviewer might trust. Writing that validator produced its own bug: the first regex read 1042 out of ORD-1042 and rejected valid output; it now strips identifiers and matches only currency-marked figures.
Three-tier AI with an honest fallback. Base44 InvokeLLM first (reproducible by anyone who deploys this repo), OpenAI if a key exists, then a deterministic summary flagged ai_generated: false. The UI says "AI explanation unavailable" rather than passing the fallback off as generated text.
A dropped connection is not a refusal. A network failure is classified retry, never blocked. Rendering an offline browser as "grant revoked" would be a lie in a product whose entire value is truthful refusals — and a test catches it, because the first implementation got this wrong.
Refusal codes are ordered deliberately. After a successful execution the request reads EXECUTED, and after revocation it reads REVOKED — so checking request status first returned REQUEST_NOT_APPROVED for both, burying the real reason. Grant state now takes precedence, with regression tests pinning it.
Nothing after the grant is transactional. Grant, refund, order, case, receipt and audit are separate writes. The guard commits first and every later step is guarded or idempotent, so a mid-sequence failure cannot produce a second refund — but there is no rollback, and that is stated rather than glossed.
| Capability | Status |
|---|---|
| Real Base44 agent | Real. nova_support with four tools, verified making genuine tool calls. Not a simulator. |
| The four refusals | Real. Produced by the same protected function Nova calls, each writing an Execution row and a receipt. |
| Concurrency guard | Real, and measured — 4 simultaneous redemptions, 1 winner, 1 refund row. Not claimed as atomic. |
| Protected entities | Real. Grant.create, Receipt.create, AuditEvent.create, Order.update, Order.delete all denied to a signed-in caller. |
| Evidence digest binding | Real. Digest computed server-side from the stored bytes; swapping the document refuses execution, restoring it lets the same grant succeed. |
| Receipt verification | Real. Recomputed server-side; detects an edit and locates the break. |
| Realtime subscriptions | Implemented and deployed — entities.subscribe() on two independent surfaces. Not yet verified by a human in two browser windows. |
| Read isolation | Absent. Business entities are readable by any authenticated user; tenant scoping lives in function queries, not RLS. |
| Multi-user session isolation | Unverified. A second Base44 identity could not be created programmatically, so role and tenancy tests reassign the existing user's membership. |
| Scheduled grant expiry | Not scheduled. This app cannot use automations ("This app uses Workflows — legacy automations are disabled"). Expiry is enforced at redemption on server time; the reconciliation sweep runs on demand. |
| Money movement | Simplified. A synthetic Base44 order is updated. No payment provider, no real funds. |
| Evidence document | Synthetic. Generated deterministically by scripts/generate-evidence-pdf.mjs — no real person, account or card. |
| Security review | None. This is a hackathon build. |
| Demo video | Not yet recorded. |
npm run verify # format, types, lint, 202 tests, build
deno check --no-lock base44/functions/*/entry.ts # Deno-side typecheckUnit tests cover the security model without a network. The scripts in scripts/ then verify the same properties against the deployed app, asserting persisted state rather than response bodies.
| ID | Scenario | Result |
|---|---|---|
| A1 | Nova proposes a valid $42 refund | ✅ real agent tool call |
| A2 | Approve valid request | ✅ one active, one-use grant |
| A3 | Execute valid grant | ✅ refund, order REFUNDED, case RESOLVED |
| A4 | Replay the consumed grant | ✅ GRANT_ALREADY_CONSUMED |
| A5 | Nova proposes $500 | ✅ AMOUNT_EXCEEDS_POLICY |
| A6 | Change amount after approval | ✅ PAYLOAD_MISMATCH |
| A7 | Revoke then execute | ✅ GRANT_REVOKED |
| A8 | Execute past expiry | ✅ GRANT_EXPIRED, before any sweep ran |
| A9 | Duplicate idempotency key | ✅ original returned, no duplicate |
| A10 | Agent lacks the capability | ✅ tool surface verified |
| A11 | Reviewer from another organization | ✅ request invisible across tenants |
| A12 | AI explanation fails | ✅ deterministic fallback, labelled |
| A13 | Recompute receipt hash | ✅ matches, and detects an edit |
| A14 | Upload synthetic evidence | ✅ digest bound and rechecked |
| A15 | Two surfaces update via subscriptions | ⏳ implemented, awaiting the recorded run |
| A16 | Two simultaneous executions | ✅ 1 winner, 1 refund row |
| A17 | Unauthenticated / customer approval | ✅ refused |
| A18 | Clean-browser production run | ⏳ deployed, awaiting the recorded run |
Reproduce any of them:
cat scripts/smoke-concurrency.ts | npx base44 exec --privileged # A16
cat scripts/smoke-evidence-binding.ts | npx base44 exec --privileged # A14
cat scripts/smoke-authorization.ts | npx base44 exec --privileged # A11, A17
cat scripts/smoke-nova.ts | npx base44 exec --privileged # A1, A10
cat scripts/smoke-judge-path.ts | npx base44 exec # the judge's own pathRequires Node ≥ 20.19, a Base44 account, and Deno (for base44 exec).
npm install
npx base44 login
npx base44 link # link to your own Base44 app
npx base44 entities push --yes
npx base44 agents push --yes
npx base44 functions deploy
echo "VITE_BASE44_APP_ID=$(node -e "console.log(JSON.parse(require('fs').readFileSync('base44/.app.jsonc','utf8').replace(/^\s*\/\/.*$/gm,'')).id)")" > .env
cat scripts/seed-demo.ts | npx base44 exec --privileged # Northstar, Maya, CASE-204, ORD-1042
node scripts/generate-evidence-pdf.mjs
cat scripts/attach-evidence.ts | npx base44 exec # upload + bind the digest
npm run devReset the demo from the UI, or:
echo 'await base44.functions.invoke("resetDemo", {})' | npx base44 execReset archives rather than deletes — receipts and audit events from previous runs are kept.
base44/
entities/ 16 schemas; business entities deny client writes
agents/ Nova's tool allow-list — the security boundary, in config
functions/ 11 Deno functions — every state transition
shared/ pure domain logic: canonicalize, policy, payload, receipts
shared/server/ the only code that touches the SDK
src/
lib/base44/ typed client, API layer, error mapping
hooks/ realtime subscriptions, session, Nova conversations
features/ landing · demo theater · support console · control room
scripts/ probes and smoke tests against the deployed app
docs/ architecture · threat model · capability map · probes · feedback
tests/unit/ 202 tests
| Document | What's in it |
|---|---|
| architecture.md | the transaction, layering, concurrency guard, deployment |
| threat-model.md | 19 threats with evidence, and the limitations section |
| base44-feature-map.md | every capability → path → test → acceptance criterion |
| platform-probes.md | measured Base44 behaviour, including where docs were wrong |
| baas-feedback-log.md | 12 reproducible findings from the build |
| demo-script.md | shot-by-shot for the video |
Limen was created during the Base44 Dev Build-Off as a new Base44-native application. It draws conceptual inspiration from earlier experiments in scoped permissions, guarded execution, proof-carrying receipts and integrity verification — but its product, data model, agent, workflow and interface were built for this challenge.
The demo changes only synthetic Base44 records. It does not move real money, and its receipt chain is not a blockchain.
limen — Latin for threshold: the boundary an action must cross to gain authority.