Tasks, decisions and policies
Three planes sit beside memory for coordination between agents. Every op is callable by name on any MCP listing;
which ones the all listing shows is set out in MCP toolsets.
| Plane | Answers | Ops |
|---|---|---|
| Task inbox | "This work belongs to another agent. How do I hand it over?" | inbox.*, task.* |
| Decisions | "What did we decide in a case like this, why, and did it work?" | decision.* |
| History policy | "May this agent do this now, given what it just did?" | policy.* |
The owner decides. Loom stores the mode an owner sets and applies it. It never promotes a poster to trusted, never auto-accepts a triaged task, and never moves a policy rule from shadow to enforce. Where Loom offers evidence (a poster's history, a rule's shadow ledger), a person or the owner agent reads it and changes the mode. An automatic trust change would let a writer earn its way past a gate by gaming the signal it watches.
Task inbox
Each agent has a durable queue. A poster can write into an inbox it cannot read. The inbox owner sets how far to trust each poster. A claimed task has one owner at a time. Agents pull their work; an optional signed webhook only says "pull now".
sequenceDiagram
participant S as Sender (public-bot)
participant L as Loom inbox private-bot
participant R as Receiver (private-bot)
S->>L: task.create (kind, brief, disclosure)
Note over L: policy: deny / review / auto
R->>L: task.pull (long poll)
L-->>R: claimed task + fence
R->>L: task.heartbeat (fence)
R->>L: task.complete (fence, outcome)
S->>L: task.updates (since_seq)
L-->>S: events: accepted, claimed, done
Statuses
stateDiagram-v2
[*] --> triage: untrusted poster (review)
[*] --> todo: trusted poster (auto)
triage --> todo: task.accept
triage --> wont_do: task.wont_do
todo --> in_progress: task.pull / task.claim
in_progress --> done: task.complete
in_progress --> failed: task.fail
in_progress --> deferred: task.defer
in_progress --> blocked: task.block
in_progress --> todo: lease expired
deferred --> todo: resume_at / task.resume
blocked --> todo: task.unblock
failed --> todo: task.reopen
wont_do --> todo: task.reopen
cancelled --> todo: task.reopen
done --> [*]
task.wont_do(owner) andtask.cancel(owner, or the poster while nobody owns the task) apply from any unfinished status. A finished task (done,wont_do,failed,cancelled) answers 409TASK_TERMINAL.task.reopen(owner) movesfailed,wont_doorcancelledback totodo.- Every move off
triage → todo → in_progress → donecarries areason.
Register an inbox
An admin of the owner namespace registers the inbox and sets trust per (poster, kind):
from loom import Agent
async with Agent(org="acme", namespace="private", agent="admin", api_key=ADMIN_TOKEN) as a:
await a.tasks.register_inbox(
"private-bot",
owner_agent="private-bot",
posters=["sa:public-bot"], # may post without holding the namespace
accept_disclosure=["confidential", "internal"],
default_lease_seconds=1800,
max_attempts=3,
)
await a.tasks.set_policy(
"private-bot",
poster="sa:public-bot",
kind="security_finding",
mode="review",
require_evidence=["inputs.finding_id", "inputs.test"],
)
await a.tasks.set_policy("private-bot", poster="sa:public-bot", kind="*", mode="deny")
| Mode | A task from this poster and kind… |
|---|---|
deny |
is refused with 403 TASK_POSTER_DENIED. |
review |
lands in triage until the owner agent or a person accepts or declines it. |
auto |
lands in todo, ready to pull. |
- With no row, the inbox owner and a writer on the owner namespace post
auto; anyone else postsreview. - A row keyed on an agent label grants
autoonly to a writer on the owner namespace, because the caller picks its label. Key outside posters on their service-account subject (sa:<name>). require_evidencenames brief fields a create must carry; a 422 names the missing ones.- Disclosure levels are
confidential,internalandpublic_ok. A task with a level the inbox does not accept gets a 422 naming the accepted values.
inbox.register arguments:
| Argument | Default | Meaning |
|---|---|---|
default_lease_seconds |
1800 | Lease a claim gets unless the pull asks for another. |
max_attempts |
3 | Expired leases before the task is blocked on a person. |
max_defers |
5 | Defers before the task is blocked on a person. |
triage_escalate_after_seconds |
14400 (4 h) | Age at which an unanswered triage item is escalated, once. |
webhook_url |
empty | Receives a signed wake-up notice when the inbox has new events. |
webhook_secret_ref |
empty | Environment variable holding the webhook signing secret. |
webhook_url is an http or https URL whose host resolves only to public addresses:
each delivery resolves it, refuses loopback, private, link-local (cloud metadata) and
multicast addresses, connects to the address it checked (the name stays the Host
header and TLS SNI) and follows no redirect. webhook_secret_ref must name one of the
inbox org's own variables, LOOM_WEBHOOK_SECRET_<ORG>__<NAME>: ORG is the org id
with lower-case letters upper-cased, digits kept and every other byte (upper-case
letters included) written _ and two hex digits (org acme-corp →
LOOM_WEBHOOK_SECRET_ACME_2DCORP__ALERTS, acme_corp → ACME_5FCORP), NAME is
letters and digits.
inbox.register refuses any other name (422), and a row that names one is never read,
so no tenant can have Loom sign with another tenant's secret. No two org ids share an
ORG part.
Post a task
async with Agent(org="acme", namespace="public", agent="public-bot", api_key=PUBLIC_TOKEN) as a:
out = await a.tasks.create(
"private-bot", # the target inbox
kind="security_finding",
title="Out-of-bounds read in the JSON reader",
brief={
"objective": "confirm and file in the private repository",
"inputs": {"finding_id": "f-77", "test": "json_oob.sql"},
},
disclosure="confidential",
idempotency_key="f-77", # a retried create lands once
)
out["task"]["status"] # "triage", or "todo" when the poster is trusted
Two creates by one poster with one idempotency_key store one task, even when they race: they run one at a
time under a lease on the key, each answers with the task, and only the one that stored it says created=true.
A key is its poster's own: another poster's create with the same key stores its own task. A retry of a create
that was cut off after storing its task writes the created event it left out; its missing lease row is the
lease sweep's to write, so the task is claimable after the sweep's 60 s grace and one more pass. When the first
create's own event still lands (it was cancelled mid-insert), the two share one deduplication token and the
readers (task.history, task.updates, /v1/task/watch, webhooks) keep only the first, so a task is created
once in its history and in each cursor's delivery. A create that waits more than 10 s for another with its key
answers 409 PRECONDITION_FAILED, and 503 when the lease store (Keeper) does not answer; retry either.
namespace is the poster's own; the first argument names the inbox. create also takes priority (default 3),
deadline, not_before, origin and parent_task_id. A task with not_before is todo but hidden from pull
until then, which is how a bot keeps its own "retry later" backlog.
| Refusal | Answer |
|---|---|
| May not post but reads the inbox | 403 AUTH_INSUFFICIENT |
| May neither post nor read it | 404 RESOURCE_NOT_FOUND, as for an unregistered inbox |
| Policy denies this poster or kind | 403 TASK_POSTER_DENIED |
| Disclosure not accepted | 422, with the accepted values |
Brief misses a require_evidence field |
422, with the missing field names |
Pull, work, finish
async with Agent(org="acme", namespace="private", agent="private-bot", api_key=PRIVATE_TOKEN) as a:
async for task in a.tasks.stream("private-bot", kinds=["security_finding"]):
fence = task["fence"]
try:
result = await work(
task["brief"], heartbeat=lambda: a.tasks.heartbeat(task["task_id"], fence=fence)
)
await a.tasks.complete(task["task_id"], fence=fence, outcome={"issue_url": result.url})
except Exception as e:
await a.tasks.fail(task["task_id"], fence=fence, reason=str(e)[:500])
- Long poll.
task.pullandtask.updateswait up to 30 s (wait_seconds). A create on the same server process wakes a waiting pull at once; one on another replica is seen within about 2 s. - Leases and fences. A claim gets a lease and a
fencetoken that every owner write presents.task.heartbeatextends the lease. When a worker dies, thetask_lease_sweepworker returns the task totodo, and aftermax_attemptsexpiries blocks it on a person. A stale fence gets 409TASK_FENCE_STALE. A claim stands only when the task, read again after the claim wins its lease, is stilltodoat that fence; otherwise the claim gives the lease back as it found it. Each sweep also puts right a lease row a race left out of step with its task: atodoor triage task with no row gets one, an idle or expired row takes its task's fence (a claim cut off after winning leaves one a fence ahead), and an ended task's row goes. It skips rows written in the last 60 s, which may belong to a create or reopen still in flight; a row the reconcile wrote back for a task a cancel was ending goes in the first pass after its 60 s grace. A cancel that lands between a claim's lease and its status write is overwritten by the claim, which leaves the taskin_progress; this race predates the reconcile and is not closed by it. - Other moves.
task.defer(withresume_at),task.block(blocked_on:sender,human,external),task.wont_do, andtask.progress(a note, no state change). - Triage.
task.pull(status="triage")returns review items unclaimed; decide withtask.acceptortask.wont_do. A poster may not accept its own task (403TASK_SELF_ACCEPT). Nothing expires or auto-accepts a triaged task.
Follow the outcome
The sender reads its own tasks' events with a cursor:
upd = await a.tasks.updates(since_seq=cursor, wait_seconds=30)
for ev in upd["events"]: # accepted, claimed, done, wont_do, blocked, ...
reconcile(ev)
if upd["events"]:
cursor = upd["next_seq"] # save only after reconcile succeeds
A crash between the read and the save replays events, so reconcile must tolerate repeats. The cursor stays about
3 s behind the newest events so a row still landing on another replica is not skipped.
Operations
| Group | Ops |
|---|---|
| Inbox setup (admin) | inbox.register, inbox.policy_set, inbox.principals; inbox.list, inbox.policy_list (viewer) |
| Posting (sender) | task.create, task.updates, task.cancel |
| Working (owner) | task.pull, task.claim, task.heartbeat, task.progress, task.complete, task.fail, task.defer, task.block |
| Triage and repair (owner) | task.accept, task.wont_do, task.unblock, task.resume, task.reopen, task.update, task.reassign |
| Reading | task.get, task.list, task.counts, task.history |
The Python SDK exposes them as agent.tasks. Over MCP, ?toolset=tasks lists the ones a working agent uses:
task.pull, task.create, task.complete, task.fail, task.wont_do, task.defer, task.heartbeat,
task.updates, task.list, task.get, inbox.list.
The dashboard's Tasks page lists every inbox of the namespace, with tiles for what needs a person, rows grouped by
next step, Accept / Won't do inline, and a panel per task (task.update with if_match_version). A writer moves a
task through every transition the state machine has: from the panel's buttons (Start is task.claim under a one-day
lease held by loom-ui; Mark done and Fail are task.complete / task.fail, fenced with the current lease), or by
dragging a row or card onto another group, which runs the one-step move there (into To do accepts, unblocks, resumes
or reopens; into In progress starts; into Waiting blocks; into Closed marks done or won't do). Groups a task cannot
reach in one step are dimmed while it is held. Inbox settings pick posters from the identities Loom already knows in
the org (inbox.principals, admin).
Settings
| Setting | Default | Effect |
|---|---|---|
LOOM_TASK_LEASE_SWEEP_ENABLED |
true | Runs the sweeper: expired leases, deferred tasks reaching resume_at, stale triage, lease rows out of step with their task. |
LOOM_TASK_WEBHOOK_DELIVER_ENABLED |
true | Posts one signed notice per new event batch to an inbox's webhook_url. |
See configuration: task inbox.
Decisions
A decision record holds a choice that matters later: a vendor, an architecture, a policy exception.
decision.record takes category, scenario, reasoning, outcome, alternatives, confidence, and the keys it
was caused_by or influenced_by. It writes:
- a typed record of the built-in
decisionschema; - a searchable memory row of type
decisionunder the same key; - an edge from each
caused_by/influenced_bykey to the decision.
A decision recorded in a repository's trunk code namespace also reaches MCP loom_search from a checkout of that
repository, for a caller who may read the namespace (unified search).
The memory row's value is one statement,
<scenario>. Decided <outcome> because <reasoning>. Rejected: <alternative>; …, then Realized: … once
decision.outcome has recorded one. The category is not in it: it stays on the record and in the row's
decision:category:<category> tag, which is what a filter by category reads. A reasoning that is the outcome word for
word (case, whitespace and the closing punctuation aside) records a ban or an invariant stated with no reason: the
statement reads Decided <outcome>. and context.pack gives no because. Every part is stored as the record keeps
it, whole within the record's limits (scenario 2,000 characters, outcome 1,000, reasoning 4,000, realized outcome
2,000, up to ten alternatives of 300 each). A longer alternative is cut at a space, or between two CJK characters, and
ends in …, so no half name reads as a file. A statement can run to about 12,000 characters. memory.for_file shows
it whole, or names it when it would pass the block's ceiling, and search embeds its excerpt, the first 1,200 stored
characters less its (#<key>) suffix, so it opens with the scenario; a longer statement is also stored as chunks,
which search reads too.
stage is decided (the default) or proposed: an option put forward and not yet chosen or built, such as a proposal,
a plan or an action item. A proposed decision's statement reads Proposed <outcome> because <reasoning>. and its row
carries the decision:proposed tag, so the pre-edit block and search present it as a proposal. A proposed ban keeps its
constraint tag: the pre-edit block still shows it first, worded as a proposal, and reads Proposed <outcome>. when no
reason is stated.
The stage is kept on the row, not the record: context.pack lists a proposed decision as → proposed: <outcome>, and
decision.precedents and decision.chain answer its stage, read from the tag on the row that holds the record (a
decision whose outcome is recorded reads as decided).
decision.outcome drops the stage, since an outcome records that the decision was acted on: the statement then reads
Decided … Realized: …. Recording the decision again with stage: "decided" drops it too. decision.record ignores a
caller's own decision:proposed tag; memory.update, which replaces a row's tags, edits the tag like any other, and
the readers follow it.
A loom decisions extract run keeps the stage of a row the document still states (Extract decisions from a
document, Stage), so a proposal later marked accepted keeps its rows proposed.
Mark such a decision decided by sending the body a --dry-run reports for it (it carries the extractor's identity, so
it replaces the row) with stage: "decided", record its outcome with decision.outcome, or delete the document's
extracted rows that are not pinned or attested and extract it again, which reads every row's stage afresh.
- Checks: the blacklist reads every stored field in full; the sensitivity and the attribute labels read the stored
category, scenario, outcome, reasoning, alternatives and realized outcome (not
decision_makerorrun_id, which the row never holds). - Files:
filessets the row's files as onmemory.set; unset, they are detected from the stored fields, never from the realized outcome.memory.for_fileserves the decision before an edit of a file it names, as it is,(pinned)when pinned and(source changed)once taggedsource-changed(how the block reads). - Source:
source_resource_idandsource_classset those columns as onmemory.set;source_resource_idcites the document the decision was taken from. - Outcome:
decision.outcomekeeps the row's files, source, declared class and the caller's tags. It drops thedecision:proposedtag, sets thedecisiontags again and works out the server'ssensitivity:andattribute:labels again.
A decision is identified by its key, not its text. The write-time conflict check never merges a decision into another
decision's row, files it as a duplicate of one, lets one replace it or lets a pinned one refuse it, however alike they
read; a close decision under another key is flagged as a conflict. The conflict resolver may later retire one of a
close pair it judges an update of the other; that is recorded as memory.auto_supersede, and memory.restore undoes
it.
category plus scenario identify a decision. Deciding the same scenario again supersedes the earlier record
(superseded_prior=true); history keeps it. The key is derived as a record's identity key is (see typed
records): a scenario in any script records, and a category or scenario with no
letter or digit (punctuation, symbols or marks alone) is refused with 400 MISSING_IDENTITY_FIELD. A decision
recorded again word for word writes the same statement, which memory.set takes as a restatement: it reinforces the
row, and a pinned decision accepts it. The restatement compares the statement's bytes, so a row stored under another
statement form is revised rather than restated by the same fields, whether they come from decision.record or from
decision.outcome, which rebuilds the statement too. A row whose value is locked refuses either with 423 LOCKED
until the lock ends or is released; an attested row takes the write, and its attestation reads stale until
memory.verify binds it again; a pinned row refuses decision.record until it is unpinned, and the pin is set again
only through memory.pin, while decision.outcome keeps the pin. When the writer of a pinned decision decides it
otherwise, the call is refused with 409 PINNED_CONFLICT and nothing is written; an admin unpins it with
memory.unpin first. The pin guards only its writer's row: another identity's decision on the same scenario still
supersedes the record. The new record holds only what the new decision states: fields it leaves out, and an earlier
realized_outcome or success, are not carried over, whether it is written by decision.record or
memory.record.write. A decision.outcome without success keeps the record's verdict.
The record holds the text of its last writer, the agent, user and session that recorded it, so a delete of that writer's decision row retires it; an expired row still counts as live. Another identity's row under the same key was superseded in the record plane and does not keep the record.
- Retires the record:
memory.deleteormemory.delete_versionof the writer's decision row, includingmemory.movewithkeep_redirect=falseandmemory.applywithaction="delete", which delete throughmemory.delete;conflicts.resolveandmemory.dedup_resolvefor the side they tombstone;blacklist.addwithsweep=trueandblacklist.sweep.decision.chain,decision.precedentsanddecision.outcomestop serving it, andmemory.record.historyshows the last version withstatus="tombstoned". Two retirements that race can each write a tombstoned version, so history may list the tombstone twice. - After a retirement:
memory.restore, ornamespace.rollbackpast the delete, revives the row but not the record; calldecision.recordagain. A row moved withkeep_redirect=falseis no longer a decision: its new key has no record, anddecision.recordwith the same category and scenario records it again under its derived key. - Leaves the record active: a delete of another identity's row;
memory.clearandnamespace.rollback, which tombstone rows another way;memory.movewithkeep_redirect=true, whose redirect row still counts as the writer's live row under the old key; amemory.setunder the writer's identity that rewrites the key with anothermemory_type; the expiry sweep's tombstone of an expired writer row. - A record with no decision row:
memory.record.writeon thedecisionschema writes the record alone.decision.chainserves it, anddecision.outcomeanswers not-found for it; record decisions withdecision.record. decision.outcome: writes under the record's writer, keeping a pinned row's pin, and answers not-found when the writer's row is not live. Its row write is conditional on the row it read: it answers 409PRECONDITION_FAILEDwhen the decision changes during the call or another conditional write holds the key, and 503 when the key's lease store is unreachable; call it again. A re-record that keeps the decision's text leaves the row's value as it was, so the outcome also checks the writer's record before its row write and again before its record write, and answers the same 409 when it moved; caught at the second check, the row holds the outcome's statement and outcome tags until the call is made again, and that call counts as a restatement of the row. The condition is on the row's value, not its pin: amemory.unpinthat lands between the outcome's read and its write is undone, as the outcome writes the row pinned again.
| Operation | Role | Does |
|---|---|---|
decision.record |
writer | Record a decision and its causal edges. |
decision.precedents |
viewer | Semantic search over past decisions (at most 25), optionally by category or with_outcome_only. |
decision.chain |
viewer | Walk causal edges upstream, downstream or both, up to 4 hops. |
decision.outcome |
writer | Record realized_outcome and success; credit or charge the memories the deciding run relied on. |
Pass the run_id the decision was made under: decision.outcome finds the memories to credit through that run's
memories_used (runs).
rec = await agent.decision.record(
category="storage",
scenario="retention for raw transcripts in the support namespace",
reasoning="Replay needs 30 days; legal hold is handled by export, not by retention.",
outcome="keep transcripts 30 days",
alternatives=["keep forever", "7 days"],
confidence=0.8,
influenced_by=["policy/legal/hold"],
)
prior = await agent.decision.precedents("how long do we keep transcripts?", category="storage")
await agent.decision.outcome(rec["memory_key"], "no replay gaps in the quarter", success=True)
Extract decisions from a document
loom decisions extract reads one knowledge document and records the decisions it states. It runs
in-process with no token, so it is an operator command, and it calls the configured LLM per window of the
document's sections: a read (twice for a window read again, below) and a support check, plus one more check per window
when a stored decision looks gone, also in a dry run. Its model calls are not gated by the
namespace's cost cap. A model call held for the org (its model calls switched off, or refused or deferred by the
inference gateway), for a model read or for an embedding a write needs, stops the run: nothing more is written,
nothing is retired, and it exits 1. The document-source sync runs the same extraction on each document it writes,
within a share of the namespace's cost cap (mining decisions).
loom decisions extract --org O --namespace NS --source-uri URI # dry run: prints what it would write
loom decisions extract --org O --namespace NS --source-uri URI --apply # writes
loom decisions extract --org O --namespace NS --source-uri URI --repo OWNER/NAME --into source --apply
- Repository: by default the document's namespace is where its code names are looked up and its decisions are
written, which fits a document added to a repository's code namespace. A document in a person's or a team's
namespace names the repository with
--repo OWNER/NAME(or a remote URL), matched by owner/name ascode.reposmatches a checkout's remote (a fork's remote is not followed to its upstream: name the upstream); its names are then looked up in that repository's trunk code namespace, and--intochooses where the rows go:--into source(the default for a document outside a code namespace): the document's namespace, each row taggedrepo:<id>, the tag the coding plugins send tomemory.for_file, so a session whose own namespace (the plugin'snamespace) is the document's gets the row before editing a file it names. The pre-edit hook reads only that namespace and the repository's trunk, so a team's shared document whose readers work from their own namespaces goes in with--into repo. A document in the trunk itself gets untagged rows, read there by every editor.--into repo(the default for a document in a code namespace): the repository's trunk code namespace, where every reader of the repository gets the row before an edit.source_resource_idstill cites the document in its own namespace; there is no per-document access, so a repository reader who cannot read the document's namespace sees the row's decision but is refused the document it cites. The rows are the trunk's: erasing the document's namespace leaves them, andmemory.deletein the trunk removes them.- Tags: under
--into sourcefor a document outside the trunk, a row of the extractor's that the run keeps as it is (pinned, attested or with a recorded outcome, still stated or not judged, or one the retire step keeps because it is still stated or was not judged), and a row it tagssource-changed, gains therepo:<id>tag by a tags-only write; another writer's row gains none. - Access: the run reads the document's namespace and the repository's, and writes where the rows go. With an auth context bound it checks each (viewer, viewer, and writer, or viewer for a dry run) and refuses before any model call; the CLI runs with none, as the operator.
- Rows from an earlier run: every memory row the reconcile and retire steps read is in the namespace the rows go
to, so the run refuses before any model call when this document's rows are in another namespace a run may write
them to (the document's namespace, the named repository's trunk, or, for a document in a branch's code namespace,
its repository's trunk), or carry another repository's
repo:<id>tag (any, for a run without--repoor with a repository that has no remote): going on would leave the document's decisions in two places, or linked to the wrong repository. The refusal names a linked repository byowner/namewhen the caller may read it, and a caller who cannot read that other namespace is refused. Two cases the run does not see: rows an--into reporun wrote to a repository other than the one whose code namespace holds the document (any repository, for a document outside a code namespace), when a later run drops--repoor names another repository, so pass the same--repoagain; and two runs on one document at once with different--into, which can both find the other namespace empty. - Exit 1 before any model call, besides those refusals, for:
- a repository that is not configured in the org, or that the caller may not read (one answer for both);
- a repository with no remote to tag rows with, under
--into sourcefor a document outside the trunk; --intowithout--repo;--into sourcefor a document in another repository's code namespace (its rows would link to the named repository's code and be read in the other's);- the repositories added from the dashboard could not be read.
- Windows: the document's live chunks for the current embedding model, grouped by section, at most
--window-charsper call (defaultLOOM_MEMORY_DECISION_EXTRACT_WINDOW_CHARS, 24,000): a document that fits is read whole, so a reason the document gives in another section than the choice (a problem statement, a measurement, a list of missing cases) is read with it. The default keeps a dense design document's reply, about as many tokens as the window, inside the read's output cap (decision_extractinLOOM_LLM_MAX_TOKENS_OVERRIDES); a reply cut at the cap loses its window, and the dropped line says to lower--window-chars(the sync reads the setting) or raise the cap. A document chunked under another embedding model has no chunks: the command says to runloom rechunkfirst and writes nothing. A--source-uriwith no live document exits 1 and writes nothing. The prompt lists the category and scenario of up toLOOM_MEMORY_DECISION_EXTRACT_KNOWN_MAX(default 30) decisions the extractor already holds for the document and asks the model to reuse them for a decision the text still records, so a re-run reads the same keys; the old outcome is not shown, so a changed outcome is read from the text as it is now. - Read: for each decision the model writes its outcome, the reasons the document gives and the options it rejects
(with the reason given, or none), in its own concise words, and each of these claims cites one to three short quotes
of the document (
server/memory/domain/decision_extract.py,SYSTEM). A decision the document states with no reason is written with none. The current or former approach a new design replaces is a rejected option of the new design; a measurement, a recap, an agenda or a benchmark result is no decision. When the reply holds no decision and the window's text is more than a caption (80 characters once images and link markup are removed), the window is read once more with the same prompt, reported asread again. When the document has a title, the window's text opens with a[document: <title>]line (its first 200 characters). - Grounding: each quote must be in the text the model read: whitespace runs as one space, letter case ignored, and
up to three label lines read across between two words, as a model copies a sentence a slide or diagram export splits
(
find_span). A found quote is kept as the document's own words on one line, its label lines left out, and one under 12 characters rests on nothing. A reason or rejected option none of whose quotes is found is left out (reason '...' left out: none of its quotes is in the document text); a decision none of whose outcome quotes is found is dropped (none of the outcome's quotes is in the document text). An outcome quote in a section withrecap,summaryorbackgroundas a word of a heading at any level of its breadcrumb locates no decision; a decision with no other outcome quote is dropped (the quote is in a recap, summary or background section), while a reason may rest on such a section's text. - Support check: a second call over the same window reads each kept claim with its quotes and supports it only
when the quotes, read in the context of the document, state or directly imply it; summarising and combining quotes is
fine, adding a reason, a cause, a number, a name or a rejected option is not. A claim it does not support is left out
(
... left out: the support check found it not stated), and a decision whose outcome it does not support is dropped. It does not judge whether a decision was decided or only proposed: that is the read's stage. The same call reads each of the extractor's stored decisions on the document (up to the known-decision cap) against the text: still stated, and which candidate restates it. A stored decision is stated when any window's check states it, and found no longer stated only when every window says so; it is then read once more without candidates, and counts as no longer stated only when every window's second check agrees. Anything else (a check that fails or comes back unreadable, a reply that leaves the decision out) gives no verdict, and a check that is lost also loses its window's candidates. - Image-only sections: a section whose own text has no words (a heading alone or over an image), or holds an image
and at most 80 characters of other text (a caption), is not sent to the model and gets the line
image-only, rationale not extracted; a heading alone over its subsections is not one. The text above the first heading, or a document with no heading, is skipped the same way when it holds an image and at most a caption; it is reported as(untitled). An image is a Markdown image (inline, or by reference as![diagram][fig1]; a shortcut reference such as![spill flow]is not read, since it looks like a macro such asvec![0; n]), an HTML image or a placeholder such as[image],[Image 3]or[image: ...]. The caption's characters are counted without HTML tags, link targets, reference labels, the empty brackets a link around an image leaves, and bare URLs, so a badge wrapped in a link adds none; a tag is a lower-case name on one line, sox < 10kandOption<A>stay text. Image markup inside code (or<img>in a code span or block) is text. A chunk with no words is not sent either. - Duplicates and limits per section: a candidate under a key, or with an outcome (whitespace and letter case aside),
an earlier candidate of the run has is dropped, as are a candidate that does not fit
decision.recordand one past--max-per-section(default 3) for the section its first outcome quote is in. - Stage: the model marks each candidate
decidedorproposed; anything but a plaindecided(a proposal, an RFC, a plan, future work, a follow-up, or no answer) is recorded withstage: "proposed", because a proposal served as a choice made misleads an agent more than a choice served as a proposal. A stored decision the text still states keeps its stage, and the run's note names the stage the document reads as when it differs, except for a row whose outcome is recorded, which reads as decided whatever the document says. A pinned or attested row is never re-recorded by a run, so its stage changes only by hand. - Write: through
decision.recordas agentloom-decisions(user and session-), the reasons joined with;as the reasoning (the outcome itself for a decision with none, which the statement reads as no stated reason), each rejected option as<option> — rejected: <reason>or the option alone, confidence 0.5,source_class="derived",source_resource_idknowledge:<namespace>/<source_uri>#<section breadcrumb>, tagssource-kind:design_doc,constraintfor a ban or an invariant andrepo:<id>under--into sourcefor a document outside the repository's trunk. The row'sactorisprocess:cli:decisions. Eachdecision.record, and eachmemory.delete_versionthat deletes a row, leaves the audit row the transport writes for that op, with the actorprocess:cli:decisions; the tag write,memory.update_by_id, declares none, as over HTTP. - Files: each backticked or qualified name and each bare class name the document text holds is looked up with
code.symbolin the document's namespace, or in the repository's trunk with--repo; exactly one defining file (a class preferred over its constructor) goes intofiles, several or none add nothing. A name only tests or Markdown pages define links nothing: a decision governs code, and an edit of a test or a page would show it instead. The dry run saysonly a test or a page defines itand lists the name as unresolved (only tests or pages) for a human to add the code file, or as a setting when it is snake_case and a page documents it; a snake_case name only tests define is a helper and is not listed. Which paths count as tests isgovernsinserver/memory/domain/decision_extract.py: common test, mock and end-to-end layouts; one it misses keeps its link. A repository path a cited quote writes out (src/Storages/MergeTreeData.cpp) is added as well, asmemory.setdetects one. When the code index cannot be read for a name, a decision already recorded keeps the files its row has, and a new one is written with the files that did resolve. Pull request numbers and setting names are printed as unresolved in a dry run, for a human to add; a backticked snake_case name is looked up first and reported as a setting when the index does not define it. A name defined in several files is printed as unresolved, for a human to pick the file. - Reconcile: the document says otherwise when the support check finds the key's stored decision no longer
stated; the model's wording alone is no change. A decision still stated is recorded again only for its row fields,
in its stored words. A row is frozen against re-extraction when it is pinned or attested in its current form
(
memory.verify). A decision the document still states keeps its stored words even when a revision adds a reason or a rejected option to it: the check asks whether the text still says what the row says, not whether it says more. By the row the key's active record belongs to:- The extractor's own, not pinned or attested, from this document: still stated with the same
files,source_resource_id,constrainttag andrepo:tag: no write. No longer stated: recorded again as the text now reads (the record is superseded, history keeps it); still stated with other row fields (a file that resolves once the code is indexed, a renamed heading, a first run with--repo): recorded again in its stored words. - No verdict (a stored row past the known-decision cap, one a reply left out or answered with neither true nor false, or one whose check was lost): a pinned, attested or acted-on row is left as it is; any other is recorded again when the run gives its key, as the text now reads when the words differ, or in its stored words and stage when only its files, source or tags changed.
- The extractor's own, pinned or attested, citing this document: no longer stated: no write; the full
decision.recordbody is printed with the row's agent, user and session, and the row is taggedsource-changedunless it already carries the tag. To accept the body, a maintainer unpins a pinned row withmemory.unpin(decision.recordon a pinned writer row answers 409PINNED_CONFLICT) and sends it; an attested row takes the body, and its attestation then reads stale untilmemory.verifybinds it again. - Another writer's row, or a row from another document: neither revised nor tagged; its body is printed. The support check reads only the extractor's own rows.
- Its outcome recorded (
decision.outcome): never recorded again, sincedecision.recordwould rebuild it without the outcome; one no longer stated is taggedsource-changed, as a pinned or attested row is. - A restatement: a candidate the support check names as restating a stored decision it does not find gone is
not written (
restates <key>, not found gone) when its key is new or is the key of a stored decision not found stated: the stored row and its stage stand, and the row under the candidate's key is retired when found gone and keeps its stored words when it has no verdict. This comes before the re-record of a row no longer stated and of a row with no verdict. - No record under the key: of the
LOOM_MEMORY_DECISION_EXTRACT_RESTATEMENT_TOP_K(default 20) decision keys closest to the candidate (cosine only), a pinned or attested one under another key that reads the same at or above the namespace's conflict threshold skips the candidate:already recorded by <agent> under <key>. The query is the candidate's statement, which leaves the category out, so a pinned or attested decision filed under another category counts. Otherwise the decision is recorded. The fetch is capped at 500 rows, so a large value orLOOM_MEMORY_SEARCH_PER_KEY_CAP=0can read fewer keys.
- The extractor's own, not pinned or attested, from this document: still stated with the same
- The tag write:
memory.update_by_idby the row'smemory_id, tags only, conditional on the value read: a value changed since, bydecision.outcomeor a re-record, is left alone and reported as a race. It is a new version by the extractor: the row's pin, value and attestation stay (the value's hash does not change, so the attestation still binds it), and its actor and age move to it, so the pre-edit line readsby cli:decisions, just now. It is a full revision, as anymemory.update_by_idis: the row is embedded again and meets the conflict check, so a decision with a close peer is markedconflicted(memory.for_filestill lists it unlessinclude_conflicted=false), and a stale conflict mark is cleared. - Races:
decision.recordtakes no precondition, so adecision.outcomethat lands between the run's read and its re-record is replaced with the rest of the record; the tag write's precondition covers the value, not a tags-only change in the gap. - Retire: a
loom-decisionsrow, not pinned or attested, citing the document, that is no longer stated (above) is deleted withmemory.delete_version, which also retires its record; a pinned or attested one, or one whose outcomedecision.outcomerecorded, is taggedsource-changedinstead. One whose decision this run recorded under another key in its place is reported as replaced by that key. A row still stated stays, whether or not the run emitted it again, and so does a row with no verdict (one past the known-decision cap, or one a check left out or lost). The verdict reads the extractor's own record: a key whose active record another identity wrote is left alone. A decision whose statement spans two windows of a long document can read as no longer stated in each. - Citing the document: a row cites it when its
source_resource_idstartsknowledge:<document namespace>/<source_uri>#, the namespace that holds the document (not where the row is), so a heading holding#(C# bindings) is no obstacle, unless a document<source_uri>#<part>it also starts with was ever ingested in that namespace:doc#v2#Introcitesdoc#v2once that document exists. - Clearing
source-changed: when the run emits a candidate under a tagged row's key and the support check finds the row stated again, the run drops the tag, pinned or not, as long as the row cites this document. The retire step never drops it. By hand, a maintainer drops it withmemory.update_by_idon the row'smemory_idwithtagslesssource-changed, or re-records the decision (aftermemory.unpin) or deletes it. - Refusals and races: a write the server refuses (a blacklisted decision, a row pinned during the run, a busy key, a
store timeout) is reported with its code, and one the embedding provider's error stops (
memory.setandmemory.searchembed in-process) with the error's name; a write that finds its row deleted or rewritten since the run read it, or a key whose row is deleted between the run's reads, is reported as such, a race the next run settles. Either way the run goes on with the next candidate and the retire step. - Limits:
--max-per-sectionis at least 1 and--window-charsat least 500; a smaller value is refused. A reply cut at the model's token limit is reported with the cap it hit (decision_extract, 12000, ordecision_support, 8000, unlessLOOM_LLM_MAX_TOKENS_OVERRIDESsets it) and the advice to lower either flag or raise that entry; a window's read, or its second read after an empty reply, or its support check, that fails (a provider error, a timeout) loses that window only. - Model and exit code: the run refuses before any model call when it would read the document with the fake model,
whose decisions pass grounding by construction: a keyed provider (
LOOM_LLM_PROVIDER=openaioranthropic) with no API key set, or no provider configured at all.LOOM_LLM_PROVIDER=fakeset explicitly runs on it on purpose. Otherwise a window the fake model answers (a fallback model,LOOM_LLM_FALLBACK_MODEL, with no key) is dropped as unread. A run whose every window's model call failed or came back unreadable, or answered nothing and failed when read again, reads nothing and retires nothing. The command exits 1 when it could not run on the document (no document, no chunks, no usable model, a refusal under Repository above, the extractor's own rows unreadable before any model call, no window read), 2 when a write, or a read it needed, was refused or failed (a dry run's reads count too, and so does a window or a support check the model could not answer readably while others were read), else 0; a race does not change the exit code.
The quotes ground the claims and are not stored: the record keeps the decision in the model's words and cites the
document's section. An extracted row is served as it is; nobody needs to review it. Pinning is optional: to freeze a
row against re-extraction, read its identity with memory.get and call memory.pin under it, or attest its current
value with memory.verify; a later run whose document no longer says it then tags the row source-changed instead of
revising or deleting it. Before an edit,
memory.for_file marks a pinned extracted row (pinned) and one tagged source-changed (source changed), as it does
every decision row (Decisions). Extracted into a repository's trunk code namespace, the rows also reach
loom_search from a checkout of the repository, as every decision recorded there does
(unified search); that surface marks no pin state, nor whether its
source changed, so an extracted row reads there as any decision does.
History policies
A role says who may call what. A history policy adds rules over the caller's own recent history in the agent event
trace (loom.agent_events): "only after a read", "no more than 200 writes an hour". A policy can only take permission
away; a call that fails its role check never reaches the policy gate.
A rule is effect + actions + condition:
effect:forbiddenies when the condition holds;requiredenies when it does not.actions: op names or globs (memory.delete,memory.*).condition: a tree of past-only leaves combined withall,anyandnot.
| Leaf | Holds when |
|---|---|
formerly |
A matching event happened within the window. |
count_within |
The count of matching events compares (gt, gte, lt, lte, eq) to value. |
not_since |
An anchor event happened within the window and the named event has not happened since the latest anchor. |
breadth_within |
The distinct keys the principal was shown compare to a value or a fraction of the namespace's live rows. |
Each leaf takes within (30s, 15m, 1h, 7d; capped by LOOM_POLICY_MAX_WINDOW_SECONDS), operation, and
key: agent (default), session or user (breadth_within defaults to actor, the verified token subject). The
compiler pins the caller, so a rule never reads another principal's events. args_equal compares event arguments to
a literal or to $args.<field> of the current call.
await agent.policy.set(
"read-before-delete",
effect="require",
actions=["memory.delete"],
condition={
"op": "formerly",
"within": "1h",
"operation": "memory.get",
"args_equal": {"memory_key": "$args.memory_key"},
},
description="An agent deletes only what it has just looked at.",
)
verdict = await agent.policy.check(
operation="memory.delete", agent="cleanup-bot", args={"memory_key": "notes/old"}
)
# verdict: {decision, would_enforce, mode, write_shaped, determining_rules, errors, rules, latency_ms}
Modes
LOOM_POLICY_MODE sets the deployment's mode:
| Mode | Effect |
|---|---|
off (default) |
The gate reads and writes nothing. |
shadow |
Rules run and each decision is appended to loom.policy_decisions; nothing blocks. |
enforce |
A denying rule answers 403 POLICY_FORBIDDEN with the rules and per-leaf values that decided it. |
A rule's own mode (inherit, shadow, enforce) overrides the deployment's, except when the deployment is off.
Moving a rule to enforce is the owner's step, taken after reading policy.decisions.
In enforce, a rule that cannot be evaluated denies a write-shaped call (writer role or above) and allows a read,
reporting the error. The gate never applies to policy.* or ops.*, so an operator can always fix a rule. A change
that widens what agents may do also writes a policy.widened audit row.
| Operation | Role | Does |
|---|---|---|
policy.set |
admin | Create or replace a rule; returns leaf_count and whether it widened. |
policy.delete |
admin | Remove a rule. |
policy.list |
viewer | The namespace's rules and the deployment's mode. |
policy.check |
viewer | Dry-run a call against the stored rules or against rules you pass. |
policy.decisions |
viewer | Read the decision ledger, filtered by allow / deny. |
The dashboard's Policies page (admin drawer) manages rules, dry-runs a call and reads the ledger. Settings:
LOOM_POLICY_MODE, LOOM_POLICY_MAX_WINDOW_SECONDS (default 86,400) and LOOM_POLICY_CACHE_TTL_SECONDS (default 30;
other replicas see a rule change within it), in configuration: auth.
Limits
- Claims are exclusive but not transactional with the work. Completing a task and writing its result are two calls; make the result write idempotent.
decision.chainstops at 4 hops;decision.precedentsreturns at most 25.- Policy evaluation is past-only: the current call is not in the trace yet, so a witness is always an earlier event.
Related
- Runs: the run ledger decisions credit through.
- Namespaces and sharing: owner namespaces and grants.
- Tokens and grants: service-account subjects for
posters. - Headless bots.
- The dashboard: the Tasks board and the Policies page.
- Security architecture: where the policy gate sits.