Docs How it works
Automations
Status: built. The sandbox, event and scheduled triggers, propose-then-validate, run records, auto-disable, a cascade bound, and manual and dry runs all exist; eight MCP tools and an Automations page expose them. Where the implementation departed from this design, the section says so.
Sharing everything tagged runbook with the ops team is a one-time action. Making it keep
happening is a script. (Sharing a folder needs no script: a folder share stands, and shares what
is put there later — see adr/0014.) Automations are how continuous behaviour enters CoDNA without adding a rule
engine that grows a new option every quarter.
Scripts are JavaScript. In practice a user asks their agent to write one:
"whenever a document gets tagged
runbook, share it with the ops team"
and the agent writes the script, registers it switched off, explains what it will do, and enables it once the person agrees. Registering disabled is the default over MCP: a script nobody has read should not start acting on their documents because an agent decided it should.
Why scripts rather than declarative rules
A condition→action DSL is safer and simpler on day one, and then someone wants "unless it is already
shared", "only in the wiki space", "and notify the author", "but not on weekends" — and the format
grows into a language with no debugger, no tests, and no documentation. JavaScript starts where that
ends up, is what agents write most fluently, and is a language users can read back to check what
their agent actually did. See adr/0007.
Triggers
Two kinds of automation, one script contract.
Event-driven
Fires from the outbox (architecture.md).
| Family | Triggers | Subject in context |
|---|---|---|
| Document lifecycle | document.created, document.updated, document.moved, document.deleted, document.restored |
the document |
| Access | access.granted, access.revoked |
document + recipient |
| Link graph | link.became_stale, link.became_broken |
the linking document — its author is the one who can act |
The list is closed and checked at registration, and a test asserts that every trigger registration accepts is one something emits. An automation on an event nothing emits registers cleanly and never runs, and that failure looks exactly like a script bug. Two link triggers were accepted and emitted by nothing until that test was written.
Ingestion has no trigger of its own. An ingested document emits document.created like every other
creation path — a separate event type meant every consumer needed a case for it, and the ones
without one silently skipped ingested documents. Scope an automation to { space: 'raw' } to react
to new source material.
access.granted is the one that closes the loop on your inbox: a script owned by you, firing when
you receive a document, can file it, tag it, or raise a notification worded however you like.
A scope pre-filter ({space, pathPrefix, tags}) is evaluated in SQL before the sandbox starts, so
an automation scoped to runbook costs nothing on the thousands of events that do not match. Every
field given must match:
| Scope | Matches | Over MCP |
|---|---|---|
space |
documents in that space | scope_space |
pathPrefix |
a folder and every folder beneath it: se matches se and se/proposals, never se-old |
scope_path |
tags |
documents carrying all of them | scope_tags |
pathPrefix was a string prefix until 2026-10-14, so se also fired for se-old. Scopes stored
with a trailing slash (se/) match the same folder as se.
Scheduled
Fires from a cron tick. A five-field cron expression plus an IANA timezone.
schedule: '0 9 * * 1' timezone: 'Asia/Tbilisi'
next_run_at is computed with adhocore/gronx and stored in Postgres, not held by an
in-process scheduler. Due automations are claimed with FOR UPDATE SKIP LOCKED, so running several
replicas neither double-fires nor drops a tick.
A scheduled run has no subject document. That is precisely why the capability API includes reads — see below.
The script contract
One default export. Called with a single context object.
export default function ({ event, doc, actor, recipient, codna, actions }) {
// …
}
| Field | Present when | What it is |
|---|---|---|
event |
always | { type, at, id } — type is 'schedule.tick' for scheduled runs |
doc |
event-driven | The subject document, frozen — see below |
actor |
event-driven | Who caused the event: { id, email, name } |
recipient |
access.* only |
Who gained or lost access: { id, email, name } |
codna |
always | Bounded reads |
actions |
always | Proposed mutations |
A script is compiled when it is registered, changed or switched on, and refused then if it cannot
run: a syntax error, with its line, no default export, or an async one. Its top-level code runs
for that check, under the same limits as a run; the function it exports is not called. Until
2026-10-24 a script that did not compile was stored, and failed on the first event it met.
The document a script sees
doc, and every document codna.find and codna.get return, has exactly these fields;
codna.backlinks returns only id, address, title and space of each. Grants and anything
else about who can see a document never cross into the sandbox.
| Field | Example |
|---|---|
id |
'01a12a86-14ae-77d5-9f34-6758bd5e8fd8' |
address |
'wiki:se/proposals/radium-offer' — what a share, a tag or codna.get takes |
space |
'wiki' |
path |
'se/proposals' — the folder, '' at the top of a space |
slug |
'radium-offer' |
title |
'Radium offer' |
tags |
['proposal'] |
status |
'active', 'draft' or 'archived' |
version |
7 |
content |
the markdown body; on codna.find only with includeContent: true |
createdBy |
the owner's id |
createdAt, updatedAt |
RFC 3339 times |
Reads
codna.find({ space, pathPrefix, tags, updatedBefore, updatedAfter, status, limit }) // → [doc]
codna.get('wiki:engineering/onboarding') // → doc | null
codna.backlinks(docId) // → [doc]
Synchronous host calls. Executed as the automation's owner, through the same ACL-filtered path as any search by that user, and capped at 200 rows per call with a budget of 20 calls per run.
Scoping reads to the owner is not a detail. Without it, anyone could write a scheduled script that walks the entire knowledge base and reports it back — a complete bypass of the permission model, wearing the costume of a convenience feature.
Actions
actions.share(principal, permission) // 'dana@example.com' | 'group:ops' ; 'read' | 'write'
actions.unshare(principal)
actions.addTag(tag)
actions.removeTag(tag)
actions.notify(principal, message)
actions.suggestLink(targetRef, rationale)
actions.log(message) // to automation_runs.logs
Actions default to the subject document; an explicit { document: ref } targets another.
Propose, then validate
Actions are queued, not performed. The script returns; the host then validates every queued action against the owner's permissions and applies the survivors in one transaction.
script runs
│
▼
[ queued actions ]
│
▼
host validates each against the OWNER's permissions
│
┌────┴────┐
allowed rejected
│ └──▶ recorded in automation_runs with a reason
▼
applied in ONE transaction → grant_reason='script'
Three things follow, and together they are what make it safe to let an agent write these:
- A script cannot exceed its author. It proposes; the host decides. No amount of cleverness in the script changes what the host will approve.
- Partial application never happens. All surviving actions commit together or none do. If one fails, the run is recorded as failed, naming it, and the others are recorded as undone. Each action sees what the ones before it in the same run did -- a second tag keeps the first, and a notification reaches someone the script just shared with. An action whose effect already holds, such as sharing with someone who has access, is recorded as such and is not a failure; one naming someone who is not there -- no such address, or a group with nobody in it -- is refused before anything is applied, like one the owner may not make, and the rest go ahead.
- Failure is visible, not silent. A rejected action is recorded with its reason, so a script that has been quietly failing for three weeks is discoverable rather than merely ineffective.
Grants made this way carry grant_reason='script' and the automation id, so "why can this person
see this?" stays answerable months later.
The sandbox
QuickJS compiled to WebAssembly, executed by wazero — CGO-free, so the single-binary build
holds. fastschema/qjs provides the binding.
Isolation is layered, and the layers do different jobs:
| Layer | Provides |
|---|---|
| wazero | No host syscalls. No network stack exists to reach. No filesystem unless WASI is explicitly wired in, and it is not. Guest memory is a bounded linear region, not process memory. |
| QuickJS | JS_SetMemoryLimit caps heap; stack depth is bounded. Wall clock is enforced one layer down — see below. |
| CoDNA host | The context object is the entire surface. Nothing is ambient — no fetch, no require, no process, no Date.now, no Math.random. |
That last row is the one that matters most. The script cannot reach anything the host did not deliberately hand it, so the security review is a review of one small, explicit API rather than of everything JavaScript can express.
Date.now() and Math.random() are withheld to keep runs reproducible: a run can be replayed from
its automation_runs record and produce the same actions. event.at supplies the timestamp.
setTimeout, setInterval and queueMicrotask are removed too, and async scripts are refused.
There is no event loop, so work scheduled on one never runs: a script that queued its actions inside a
callback would report success having done nothing.
How a runaway script is actually stopped
The binding's MaxExecutionTime option does not stop a while (true) {} — it ran past twenty seconds
in testing. What works is cancelling the runtime's context: wazero closes the module mid-call, which
surfaces as a panic on the goroutine executing the script. The sandbox runs each script on its own
goroutine, recovers that panic, and reports it as a limit breach. The clock is armed after the
runtime is constructed, so wasm instantiation does not eat the script's budget.
Budget breaches — too many actions, too many reads — abort the sandbox the same way rather than
throwing into JavaScript. A thrown error is catchable, and a script wrapping its own loop in
try/catch must not be able to swallow the limit and report success.
One trap is worth recording. The binding compiles the wasm module once and caches it globally, and wazero's close-on-cancel behaviour is fixed on that compiled module. Whichever runtime is constructed first in the process decides, for every later one, whether a script can be stopped at all. All construction goes through one function for that reason.
Limits
| Limit | Default | On breach |
|---|---|---|
| Execution time | 100 ms | status='timeout', no actions applied |
| Heap | 16 MB | status='failed', limit_hit='memory' |
| Actions per run | 50 | run rejected, nothing applied |
| Read calls per run | 20 | read throws inside the script |
| Rows per read | 200 | silently truncated, flagged in the run record |
| Script size | 64 KB | rejected at registration |
Exceeding the action cap rejects the whole run rather than applying the first fifty. A script that wanted to share 4,000 documents and got cut off at 50 has done something nobody asked for, and half-doing it is worse than not doing it.
Each run gets a fresh runtime, not a pooled one. Warm construction costs about 3 ms, which is less than the bookkeeping of returning runtimes to a pool and remembering which ones a breach poisoned. No state survives between runs by construction.
Cascades
An automation that writes a document causes document.updated, which can match that same automation,
or a second one that writes back. Events therefore carry a cascade depth, and the consumer refuses to
run automations past depth 3. Depth rather than "skip the automation that caused this event", because
that check misses two automations triggering each other. This was found by registering an automation
against the running server and watching it re-fire on its own write.
Running by hand
An event automation acts on what happens after it is enabled, so a script written to share a folder
shares nothing already in it, and the only way to try one used to be making a page for it to fire on.
run_automation (and POST /api/automations/{id}/run) runs one by hand, enabled or not:
| Given | Runs |
|---|---|
document |
once, on that document, as if its trigger had fired for it |
all_in_scope |
once for every existing document its scope matches that its owner can see — what it would have done had it been running when they were written. Up to 200 a call, with more and next_offset |
| neither, on a scheduled one | once, as a tick |
event says which of its triggers the script is told fired; the first by default.
With dry_run the script runs and every action it proposes goes through the same checks as a real
run — the subject, the owner's permissions, whether whoever it names is there — and stops there.
Nothing is applied and nothing is recorded; each action comes back with would_apply or the reason
it would be refused. That is how an agent shows a person what a new or changed script will do before
it is enabled. A real manual run is a run like any other: applied in one transaction, recorded, and
counted towards auto-disable.
update_automation changes one in place — only the fields given — keeping its id and its runs.
get_automation returns its script, which list_automations leaves out.
Run records
Every run is recorded, successful or not:
automation_runs
status ok | failed | timeout | rejected
duration_ms
memory_peak
limit_hit null | memory | execution_time | actions | reads
actions [{ type:'share', principal:'…', applied:true },
{ type:'share', principal:'…', applied:false,
reason:'owner lacks share permission on <doc>' }]
reads 3
logs output of actions.log()
error
Five failed runs in a row switch the automation off and tell its owner. A run has failed when it
threw, ran out of time or over a budget, or proposed something and had all of it refused. That
last one ends ok: a script whose owner may not make the shares it asks for changed nothing, every
time, and until 2026-10-24 ran for ever without anyone being told. A run that did some of what it
proposed, or proposed what already held, did its job. A script that has been erroring since a
schema change three weeks ago should not stay quietly broken.
Why it was switched off stays on it (disabled_reason, disabled_at), and the Automations page
shows it where it shows "Off", until the automation is switched on again. The notice in the inbox
opens that automation with its runs showing. Running it by hand while it is off, to find out what is
wrong, records each run and neither tells its owner again nor replaces the reason.
While an owner is deactivated their automations do not fire: events pass them by, and schedules stay due until the owner is back. When management hands a departed person's automations to someone else, they arrive switched off, so the person they will now act as reads them first, and without their past runs, whose logs were written as someone else (permissions.md).
Examples
Make a tag share continuous — the standing version of a one-time bulk share:
// trigger: document.created, document.updated scope: { tags: ['runbook'] }
export default function ({ doc, actions }) {
if (!doc.tags.includes('runbook')) return
actions.share('group:ops', 'read')
actions.log(`shared ${doc.slug} with ops`)
}
Triage what you receive:
// trigger: access.granted
export default function ({ doc, actor, actions }) {
if (doc.tags.includes('security') || doc.space === 'raw') {
actions.notify('me', `${actor.email} shared ${doc.title} — needs review`)
}
}
Weekly staleness sweep — no subject document, so it reads:
// schedule: 0 9 * * 1 timezone: Asia/Tbilisi
export default function ({ codna, actions }) {
const stale = codna.find({
space: 'wiki', tags: ['runbook'], updatedBefore: '-180d', limit: 50,
})
for (const d of stale) {
actions.notify(d.createdBy, `${d.title} has not been reviewed in six months`)
}
actions.log(`flagged ${stale.length} stale runbooks`)
}
Keep raw material connected:
// trigger: document.created scope: { space: 'raw' }
export default function ({ doc, codna, actions }) {
for (const candidate of codna.find({ space: 'wiki', tags: doc.tags, limit: 5 })) {
actions.suggestLink(candidate.id, `both tagged ${doc.tags.join(', ')}`)
}
}
Suggestions, not links — the user still decides (linking.md).
Implementation note
fastschema/qjs is at v0.0.6. It is the right substrate — CGO-free QuickJS on wazero with the
limits we need — but that version number is young for a component on the security boundary.
CoDNA therefore defines its own sandbox.Runtime interface and treats the binding as an
implementation detail, so replacing it with a hand-rolled wazero host, or with a different
interpreter entirely, is a package swap rather than a rewrite. The interface is narrow by design:
compile a script, run it with a context, return proposed actions.
Deferred
- Outbound webhooks. Genuinely useful — post to Slack when a runbook changes — and genuinely the
moment the sandbox stops being airtight. If it arrives it will be a separately granted capability
with an allowlist of destinations, not an ambient
fetch. - Script libraries. Shared helper modules across automations.