@demystify/agent-kernel
v0.2.1
Published
Planner and run-ledger for multi-step agent work. Exactly-once runs by DB constraint, immutable plans, step-wise resumption, pre-step cost ceilings, capability declaration — and structurally incapable of sending: there is no transport on the ports interfa
Maintainers
Readme
@demystify/agent-kernel — a planner and run-ledger that cannot send
Multi-step agent work: claim a run, hand out one step at a time, record what came back. Zero runtime dependencies. Zero-config in-memory default; a Postgres migration ships alongside for production.
The property that shapes everything else
There is no transport here. No sender, no execute, no dispatch, no chokepoint.
The kernel plans and records. The host performs.
A chokepoint is still a place where sending happens, so it is still a target: compromise the plan and the chokepoint obediently sends. The stronger property is that the kernel holds no reference through which anything can leave the process. If there is nowhere for a message to go, no amount of prompt injection can make one be sent — not because a check refused, but because the capability was never in the room.
test/no-transport.test.ts enforces this rather than trusting it. It fails the build
if anyone adds fetch, a socket, child_process, a dynamic import, a runtime
dependency, a callback-accepting export, or a port method whose name implies an
outbound effect. Both routes are verified by deliberately re-introducing them:
adding `fetch(...)` to the kernel -> ✗ "never uses fetch"
adding `notify()` to the RunStore port -> ✗ "declares no port method whose name
implies an outbound effect"Install
npm install @demystify/agent-kernelThe loop
The host owns the loop and owns every effect:
import { AgentKernel, MemoryRunStore } from "@demystify/agent-kernel";
const kernel = new AgentKernel({ store: new MemoryRunStore() });
const claim = await kernel.claimRun({
runId: crypto.randomUUID(),
agent: "collections",
tenantKey: "org_abc", // opaque; never parsed
period: "2026-08",
plan: { steps: [...] },
ceiling: { amountMinor: 50_000, currency: "INR" }, // ₹500.00
});
if (!claim.acquired) return; // another tick won — clean no-op, not an error
for (;;) {
const next = await kernel.nextStep(claim.run.runId, declaration);
if (next.done) break; // succeeded, or halted with a reason
const outcome = await host.perform(next.step); // THE KERNEL CANNOT DO THIS
await kernel.recordStep(claim.run.runId, next.index, {
ok: outcome.ok,
failureReason: outcome.error,
spentMinor: outcome.costMinor,
});
}Between nextStep and recordStep the host does the work. The kernel never had a
way to do it.
The seven guarantees
| # | Guarantee | How |
|---|---|---|
| 1 | Exactly one run per (agent, tenant, period) | A UNIQUE constraint, not application logic. Two concurrent ticks both INSERT; one wins, the loser gets acquired: false and the existing row. A clean no-op — an overlapping cron is normal operation, and raising there teaches people to ignore alerts. |
| 2 | The plan is immutable after claim | A Postgres trigger rejects any UPDATE that changes plan_json. A reviewed artifact cannot be swapped underneath the reviewer. The in-memory store refuses identically. |
| 3 | Step-wise and resumable | The next step is derived entirely from the persisted cursor; nothing is held in memory between calls. A process killed between any two steps resumes exactly where it stopped — tested by discarding the kernel instance mid-run. |
| 4 | Cost ceiling checked before each step | In nextStep, before the step is handed out — never after the bill. Crossing it halts with cost_ceiling_reached. |
| 5 | Capability declaration per agent | A step requiring something the agent never declared halts with capability_denied and names what was missing. This is the inner of two checks; the host gates again. |
| 6 | Structurally incapable of sending | See above. |
| 7 | Failures carry a renderable reason | A failed step without a failureReason is rejected. Every non-success ending stores a reason and a detail, and the DB CHECK enforces it too. |
Standards
- Agnostic core. No Supabase, no ORM, no HTTP client. The store is a port.
- Tenancy is an opaque string key. Never parsed, joined, or assumed to be a UUID.
- Money is integer minor units with the currency alongside. No float exists in the code or the schema, and a non-integer ceiling or spend is rejected at the API.
- Zero-config / keyless / offline default.
MemoryRunStoreneeds nothing, and enforces the same two invariants as the SQL so an offline test is a faithful rehearsal rather than a convenient fiction.
Postgres
psql "$DATABASE_URL" -f node_modules/@demystify/agent-kernel/migrations/0001_agent_kernel.sql
select * from demystify_agent_kernel.verify_install(); -- every row ok = trueThen use the shipped adapter — you do not need to implement RunStore yourself:
import { AgentKernel, PgRunStore } from "@demystify/agent-kernel";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const kernel = new AgentKernel({ store: new PgRunStore(pool) });PgRunStore takes any { query(text, params) } — pg.Pool, a pooled transaction,
PGlite, or a wrapper over Supabase's connection. The package imports no driver.
The migration carries the unique constraint, the immutability trigger, CHECKs that a
non-success ending always has a reason, and deny-by-default RLS.
Tenant isolation
RLS scopes on app.current_tenant, which the host sets — the package owns no
identity model. It joins the app.* namespace that jarvis-memory already uses
(app.current_company, app.current_owner) so one routing context sets the tenant
once and every package that scopes on it agrees.
-- dedicated connection
select set_config('app.current_tenant', 'org_abc', false);
-- pooled connection: transaction-scoped, so the key cannot leak to the next borrower
begin;
select set_config('app.current_tenant', 'org_abc', true);
-- ... your queries ...
commit;Mind the third argument — the two mistakes fail in OPPOSITE directions.
| mistake | what happens | direction |
|---|---|---|
| true outside a transaction | driver puts set_config and your query in different implicit transactions; the key is discarded and every row is denied | fails safe |
| false on a pooled connection | session scope outlives the checkout; the next borrower of that connection inherits the previous tenant's key | fails toward cross-tenant READ |
The second is the dangerous one, and it is the one that looks like it is working. An earlier version of this README said the failure mode was "total denial, never total exposure". That was true only of the first row and wrong as a blanket claim — corrected after Finocket pointed it out.
Rule: if the connection is pooled, use true inside an explicit transaction. Only
use false when the connection is genuinely dedicated for the life of the request.
Verifying RLS as a superuser or the table owner proves nothing: superusers bypass RLS unconditionally.
test/rls.sqlreal.test.tsruns as an unprivileged role against real Postgres, which is the only verification that means anything.
Testing
pnpm test # 36 testsCoverage 90% statements. The regression suites were verified red-green: each was confirmed to fail with the guarantee removed, not merely to pass with it present.
MIT.
