CoDNA Request access
Contents

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:

  1. A script cannot exceed its author. It proposes; the host decides. No amount of cleverness in the script changes what the host will approve.
  2. 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.
  3. 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.