FORAGER — KNOWLEDGE · v0.4.2.14
Knowledge API
Source: modules/anthill/docs/KNOWLEDGE_API.md, a path from the root of the product's repository — versioned with the code, rendered here at every release.
The colony's own knowledge surface. Base path /knowledge, ANTHILL's standard envelope, ANTHILL's
standard auth.
The console never talks to FORAGER directly. FORAGER authenticates every /api route and has
since its 0.7.0 (Authorization: Bearer, operator key or an fgr_… integration token, loopback
included); ANTHILL holds that credential, the browser holds an ANTHILL session, and the two are never
the same secret. v0.3.8.158 and W3-04 each corrected an earlier claim here that FORAGER had no
authentication at all. That does not make a browser-to-FORAGER path acceptable: it would put the
knowledge base on the operator's network, over plain HTTP, under a credential the console would then
have to hold. ANTHILL is the authenticated edge, and FORAGER is built to own its loopback interface.
Envelope
Every response is the standard {success, message, data} shape (ApiJson). Errors carry a code:
| Status | error |
When |
|---|---|---|
| 400 | bad_request |
Missing query, malformed body, or a provider rejection |
| 401 | unauthorized |
Not signed in |
| 403 | permission_denied |
Role lacks the permission, the caller's role on the named project is below a write's rank (its viewer, or an owner of its organization who holds no role), or the path is outside the workspace |
| 402 | the commercial gate's code (capability_not_granted, period_ended, entitlement_unverifiable, …), or no_entitlement |
A curation act (K2) on a colony whose Forager subscription does not carry forager.knowledge.curate; data.capability_id names it. no_entitlement is FORAGER's own refusal, when FORAGER is the one that refuses |
| 404 | not_found |
Unknown id in this scope, or no knowledge base mapped for the project |
| 409 | conflict, refused_at_the_door, reconciliation_failed |
A dead letter's state stands in the way of the action (E-19, see Dead letters) |
| 409 | conflict |
A curation or export act the record's state stands in the way of (K2): data.code is already_decided, not_decided, not_ready, written_to_folder, or FORAGER's own conflict (an export while the knowledge base is being processed) |
| 409 | already_bound |
Making a knowledge base for a project (or the console default) that already reads one (K2, see POST /knowledge/bases) |
| 410 | gone |
An export whose archive FORAGER's retention removed (data.code archive_expired, K2) |
| 500 | binding_failed |
FORAGER made the knowledge base and the colony could not save the binding; the answer names the base |
| 502 | upstream_mismatch |
The archive FORAGER sent is not the one its export recorded — by SHA-256 (digest_mismatch) or by size (size_mismatch) — and none of it was handed over (K2) |
An id belonging to another project answers not_found, never permission_denied — telling a caller
that an id exists somewhere they cannot see is itself a disclosure.
Permissions
| Permission | Covers |
|---|---|
read_knowledge |
status, engine, search, retrieve, items, evidence, entities, conflicts, sources, job reads, the feed and its dead letters; FORAGER's own application's reads (K2): the statement list, people and organizations and an entity's page, possible duplicates, a source's page, the export history; making an export, reading one, and downloading one (K2 — downloading also needs the named project's operator or admin role) |
manage_knowledge |
measuring, starting, cancelling and retrying ingestion; syncing the feed; replaying and discarding its dead letters; making a knowledge base (K2); FORAGER's curation (K2): deciding a disagreement or reopening one, merging a possible duplicate or keeping it apart, undoing a merge, marking a statement reviewed, rejecting it or resetting its review |
Both default to granted at the permission layer; the capability gate is knowledge_enabled, which
ships off. Two gates, and the outer one is closed.
Since v0.3.8.124 that outer gate can be opened from the console — the card every Knowledge section
shows while knowledge is off, the Knowledge area's Connection section, and since v0.3.9.14 the
Modules screen — which posts {"knowledge_enabled": true} to POST /settings under
manage_settings. The switch only starts using what the file already says. Four more knowledge keys
are console-writable through the same surface and the same gate: the study schedule
(knowledge_auto_study), what a published update does (knowledge_revision_study), the endpoint
(loopback addresses only — the server refuses the rest) and the credential (written, never read
back). knowledge_project_map is written through its own route, below, under manage_knowledge.
knowledge_forager_allow_remote stays file-only: it decides whether the colony may trust a service
on another machine, and a console compromise must not be able to widen that.
Scope
Every route accepts ?project=<anthill-project-id>, translated to a FORAGER project through
knowledge_project_map. A caller cannot name a FORAGER project directly, so no request can reach
a knowledge base the operator has not deliberately mapped. Omitting it uses
knowledge_default_project; an unmapped project is not_found, never a silent fallback.
POST /knowledge/project-map — manage_knowledge
Binds an ANTHILL project to a FORAGER one. Added at v0.3.8.153 to the documentation and at v0.3.8.148 to the API — the gap between those two numbers is the point of this section existing.
{ "project": "colony-docs", "knowledge_base": "proj_7f2a" }
| Body | Meaning |
|---|---|
project set, knowledge_base set |
bind or rebind that project |
project empty, knowledge_base set |
set knowledge_default_project |
project set, knowledge_base empty |
unbind — the project then refuses, it does not fall back |
Persists immediately: KnowledgeOptions re-reads the runtime per call, so the next retrieval sees
it without a restart. The response echoes the whole project_map and default_project.
Deliberately not a tool. FORAGER_SHARED_CONTRACT.md §3 calls project mapping "an authorized
server operation", and §1 insists an agent may never choose scope — no role's AllowedTools names
this and none should. It widens nothing on its own: every read still goes through
ResolveKnowledgeScope and the provider's RequireScope.
The console binds from the bar at the top of every Knowledge section — the project the bar shows,
to a knowledge base picked from what FORAGER reports it holds (GET /knowledge/projects, proxying the
producer's GET /api/projects — P11 in the shared contract, closed producer-side; v0.3.8.158),
never typed — and lists every binding, with Study and Unbind per row, the console default
with its own Unbind (project and knowledge_base both empty clear it), and a bind by typed ids,
on the Connection section under All bindings.
POST /knowledge/bases — manage_knowledge (K2)
Makes a knowledge base in FORAGER and binds it in the same request, so an operator goes from nothing to a knowledge base they can import into without leaving the console. The console offers it in the bar's unbound state and in Sources' empty state — as FORAGER's own Projects page does, it asks a name and what the knowledge base is for.
{ "name": "Harbour handbook", "description": "What the harbour team wrote down", "project": "colony-docs" }
| Field | Meaning |
|---|---|
name |
1–120 characters, whitespace collapsed (FORAGER's CreateProjectSchema). Required |
description |
At most 2000 characters. Optional |
project |
The colony project to bind it to. Empty binds the console default instead |
In order: signed in with manage_knowledge; the commercial gate (platform.project.manage, a base
capability that no subscription state refuses — it is written down, not a paywall); the body; a named
project that exists, held to its project admin (W1-03 §8.3 — which knowledge base a project reads
is one of its settings); knowledge usable; then, under one lock, a project (or console default) that
already reads a knowledge base is refused 409 already_bound before anything is made — making a
second and silently moving the project onto it would move every mission's knowledge with it, and
rebinding stays the bar's own control. FORAGER makes the project (POST /api/projects, with the
caller's Idempotency-Key passed through), then the binding is saved exactly as
POST /knowledge/project-map saves one. If FORAGER refuses, nothing is bound; if the binding cannot be
saved, 500 binding_failed names the base FORAGER made so it can be bound from the list. The act is
recorded in the event log as knowledge_base_created.
{ "knowledge_base": { "project_ref": "proj_3a63d515934f", "name": "Harbour handbook",
"source_count": 0, "knowledge_count": 0, "open_conflict_count": 0, "state": "empty" },
"project": "colony-docs", "bound": true,
"project_map": { "colony-docs": "proj_3a63d515934f" }, "default_project": "" }
Scope Installation, as POST /knowledge/project-map: the binding map is the installation's
configuration.
Request field names
Request bodies are snake_case, exactly like responses, and every multi-word field says so in the
record that reads it. ReadFromJsonAsync uses ASP.NET's default HTTP JSON options — camelCase
naming, case-INsensitive matching — and case-insensitive is not separator-insensitive: knowledge_base
and knowledgeBase differ by an underscore and do not match.
That is not theoretical. It shipped: POST /knowledge/project-map declared KnowledgeBase with no
wire name, the console sent knowledge_base, the field arrived null, and an empty knowledge base
means UNBIND — so the Bind button removed the mapping and reported it accurately. Fixed at
v0.3.9.5; RequestWireNameTests scans every request record the API deserializes so the next one
fails a test instead of a button.
Routes
GET /knowledge/status
The one route that answers usefully when knowledge is off — the console must distinguish not configured, unreachable and working, and a 404 collapses all three into a blank panel.
{ "enabled": true, "reachable": true, "usable": true,
"version": "0.1.0", "schema_version": 1,
"search_backend": "sqlite-fts5",
"model_provider": "none (deterministic mode)",
"endpoint": "http://127.0.0.1:8790",
"reason": null,
"projects": ["falcon"],
"configured_endpoint": "http://127.0.0.1:8790",
"allow_remote": false,
"auto_study": "suggest",
"revision_study": "inherit",
"revision_study_resolved": "suggest",
"open_changes": 3,
"dead_letters": { "scope": "colony", "unresolved": 2, "escalated": 1, "unreachable": 0,
"unreachable_escalated": 0, "escalation_days": 180 },
"gate_env_var": "ANTHILL_KNOWLEDGE_ENABLED",
"gate_env_pinned": false,
"change_feed": true,
"package_version": 7, "host_package_version": 7,
"package_agreement": "agreed", "package_note": null,
"input_roots": { "key": "knowledge_forager_input_roots", "variable": "FORAGER_ALLOWED_INPUT_ROOTS",
"managed": true, "workspace": "/srv/colony/workspace",
"setting": ["handbook", "/etc"],
"next": { "roots": ["/srv/colony/workspace/handbook"], "from": "colony" },
"refused": [ { "folder": "/etc", "reason": "it is outside the colony's workspace (…)" } ],
"running": { "roots": [], "from": "none" }, "waiting": true } }
input_roots (K2) is what an import by path may read on the FORAGER this colony runs: the
workspace every allowed folder must lie inside, the colony's setting as written
(knowledge_forager_input_roots), what the engine's next start is handed and where that comes from
(colony — the setting; environment — FORAGER_ALLOWED_INPUT_ROOTS in the colony's own environment,
which applies while the setting is empty; none), each listed folder that is not handed over and
why, what the engine running now read when it started, and whether a saved change is waiting for
its next start. Knowledge › Sources › Import by path says each of these before anyone presses Import.
For a FORAGER an operator runs (managed: false) nothing is handed from here: it reads its own.
endpoint is reported; the token never is. dead_letters counts the change feed's dead letters
across the whole colony (scope: "colony", like open_changes): those waiting on an operator, and
how many of those have waited 180 days or more (E-19, F65). unreachable is the part no project's
list shows — entries whose project no longer reaches the knowledge base they were filed on — which
only the installation's list can discard (see What an operator does with one). It reads unresolved
entries only. The console's Dead letters card counts the selected project's knowledge base from its
own list, and names these numbers only as what waits elsewhere. A colony that never followed a feed
reports zeros and is not given a queue.
The ANTHILL package version (v0.4.0, W3-05)
host_package_version is the exact package version this host reads — the knowledge module's
forager.export.anthill contract, which is where the number is written and the only place in the
source it appears. package_version is what the engine declares for that format in
capabilities.exports.anthill.package_version. The probe compares them, which it did not do before
v0.4.0: the declaration was parsed and held against nothing, so a host reading one package version
and an engine emitting another reported a healthy integration and left the disagreement to be found
by whoever opened the package.
package_agreement is one of:
| Value | Means | package_note |
|---|---|---|
agreed |
The engine emits the version this host reads | null — the ordinary case says nothing |
mismatched |
The engine emits a different version | The sentence, naming both numbers |
unstated |
The engine declares no package version — an older engine, or one with no such export | The sentence, naming this host's number and saying the engine stated none |
unknown |
This host could not say what it reads (knowledge off; never probed) | null |
A mismatch is stated in both numbers, because "the package version disagrees" is not something an operator can act on. The wording is composed once, server-side, so no reader can word it two ways:
This host reads ANTHILL package version 7 and this knowledge service emits version 6, so the ANTHILL package from this engine is not one this host can consume. Search, retrieval, review, the change feed and per-source study are unaffected.
and for an engine that says nothing:
This knowledge service states no ANTHILL package version; this host reads version 7. Whether a package from this engine can be consumed here is unstated, not disagreed. Search, retrieval, review, the change feed and per-source study are unaffected.
It never makes knowledge unusable. usable, reachable and compatible are untouched by it,
and the console still draws the connection as connected. The package is one channel of several; the
HTTP API carries search, retrieval, review, the feed and per-source study, and none of them depend
on the package's shape. Reporting the engine as down because one format moved would cost an
operator their whole knowledge base over a channel they may never use.
The pin moved from 6 to 7 in v0.4.0: FORAGER v7 adds a revision object to
anthill-package.json (id, publication_sequence, logical_content_hash, publication_status,
producer_instance_id, producer_generation, published_at), so the package bytes name the
revision they were cut from and a package can be placed on the ledger below. It is an exact
version, not a floor — forager.engine is the floor; this is not — so an older package version is
as unreadable as a newer one and is reported the same way.
The last four are what the console's on/off control needs to tell the truth (v0.3.8.124).
endpoint is the endpoint that was probed, and a disabled provider probes nothing — so with
knowledge off it is empty and the page could not say what it was about to point at;
configured_endpoint is the configured value either way. allow_remote is reported because
enabling knowledge against a non-loopback endpoint with it false fails at the client, and an
operator shown an Enable button should know that before pressing it. gate_env_pinned is true
when gate_env_var is set in the process environment: the runtime projects that gate as
env-over-file, so a settings write would persist and then lose to the variable. The console
withholds the control in that case and names the variable instead of shipping a button that appears
to do nothing.
auto_study is off | suggest | on (v0.3.9.3) and open_changes is how many findings are
waiting on the operator. Both are on status rather than behind the section that shows them,
because a badge belongs on the tab: a count an operator has to open a panel to discover is a count
that tells them nothing.
revision_study and revision_study_resolved are the second lane (v0.4.0, W3-05 part two). The
setting is inherit | off | suggest | on and governs what a revision on FORAGER's change
feed causes; auto_study governs the six-hourly poll and nothing else. They are reported as
two fields because inherit — the default, and the behaviour every colony had before this key
existed — answers nothing on its own: revision_study is what the operator wrote and
revision_study_resolved is what the colony will actually do, which is what the console shows
under the control. See knowledge_revision_study in CONFIGURATION.md.
GET /knowledge/engine
What the FORAGER this host manages is doing (W3-03; console reader since v0.3.9.13). Attached
colonies -- knowledge_forager_managed off, the default -- answer {"managed": false, "note": ...}
and nothing else. Managed colonies answer:
{ "managed": true, "state": "running",
"endpoint": "http://127.0.0.1:8801",
"instance_id": "fgi_...", "generation": "gen_...", "version": "0.8.0",
"reason": null, "ready_since": "2026-09-15T10:12:07.0000000Z",
"restarts_in_window": 0 }
state is disabled | starting | running | backoff | failed | stopping, lower-cased
and stable; the console maps it to copy. reason is present exactly when the state is not
running -- starting has none, backoff and failed carry the last crash or refusal, which is the
actionable half. The Knowledge area's Connection section renders the managed answer as one card
above the status card (and above the off and unreachable states on every section), and draws
nothing for managed: false; the bar above every section says the same state in a word (FORAGER
running, starting, restarting, stopped), and while the engine is on its way up the page reads
this route again by itself.
The supervisor accepts an engine of Forager 0.8.0 or later (MinManagedEngineVersion): the
identity, store lock, capabilities, host credential and shutdown it verifies are that release's.
GET /modules -- read_status
Not under /knowledge, but the rail's door dots (v0.3.9.13) and the Modules screen at
Settings › Modules (v0.3.9.14) read it, so it is documented here (W3-01 item 3). One record per module the shell hosts -- colony, knowledge,
devices -- with five independent axes: installed, configured, healthy, enabled, entitled.
{ "modules": [
{ "id": "knowledge", "label": "Knowledge", "route": "/knowledge", "engine": "forager",
"summary": "attention",
"axes": {
"installed": { "state": "ok", "ok": true, "condition": "...", "next_action": null, "who_can_fix": null, "last_successful_check_at": null },
"configured": { "state": "attention", "ok": false, "condition": "No FORAGER endpoint is configured.",
"next_action": "Set knowledge_forager_endpoint on Knowledge › Connection.", "who_can_fix": "administrator",
"last_successful_check_at": null },
"healthy": { "state": "attention", "ok": false, "condition": "...", "next_action": "...", "who_can_fix": "operator", "last_successful_check_at": null },
"enabled": { "state": "ok", "ok": true, "condition": "knowledge_enabled is on.", "next_action": null, "who_can_fix": null, "last_successful_check_at": null },
"entitled": { "state": "unverifiable", "ok": null, "condition": "Entitlement for Knowledge has never been checked: no grant service is configured for this colony.",
"next_action": null, "who_can_fix": null, "last_successful_check_at": null } },
"first_problem": { "state": "attention", "ok": false, "condition": "No FORAGER endpoint is configured.", "...": "..." } } ],
"entitlement_note": "The entitled axis is informational. It never gates read, search, export or any safety path.",
"checked_at": "2026-09-15T10:12:07.0000000Z" }
summary is off when enabled.ok is false, attention when installed, configured or healthy is
false, ok otherwise -- entitled is not in it. An axis' state is ok | attention | off |
unverifiable | not_applicable; ok is null when the axis was not decided (not checked because
something before it is off, unverifiable, or not applicable). first_problem is the first of
enabled, installed, configured, healthy whose ok is false, or null. who_can_fix is operator
or administrator. The permission is read_status, not read_knowledge: a module's axes are the
colony's status at module grain. Nothing in the payload is a secret -- endpoints and reasons,
never tokens. The Modules screen renders every axis as a row and puts a control on the row only
where this console can take the next action itself -- today that is the Knowledge enabled axis
(POST /settings {knowledge_enabled}, administrators, unless ANTHILL_KNOWLEDGE_ENABLED pins it);
every other next action is shown as the sentence with who_can_fix. Entitled gets no control.
GET /knowledge/search?q=&limit=&include_historical=
Ranked candidates, no evidence expansion. limit clamps to 1–50.
{ "query": "launch date", "backend": "sqlite-fts5", "took_ms": 3,
"hits": [ { "knowledge_id": "ki_68c6b4a77fc81cb5",
"statement": "The launch date for Project Falcon is March 3, 2026.",
"type": "fact", "support": "DirectFact", "status": "Disputed",
"confidence": 0.9, "score": 11.649,
"why": "Full-text match in title and statement of this fact",
"evidence_count": 3, "contested": true } ],
"entities": [ … ] }
POST /knowledge/retrieve
The main path: evidence, entities and conflicts assembled into a context.
{ "query": "why did the launch date change", "project": "falcon",
"top_k": 8, "include_historical": false }
Returns the structured context and rendered — the exact text a model is given, verbatim. The
console shows it, which is the difference between a knowledge feature you can audit and one you have
to trust.
There is no include_conflicts parameter. Rule 10 says conflicts are never hidden, and an option to
hide them is a way to hide them.
A POST rather than a GET because the body carries options and a retrieval is expensive enough that it should not be repeated by a cached re-issue.
GET /knowledge/items/{id}
One item: statement, support, status, confidence, effective date, evidence ids, conflict ids,
has_provenance, contested — and (K2) what a person decided about it in FORAGER's reading view:
review_status, review_notes, reviewed_by, reviewed_at.
GET /knowledge/items/{id}/evidence
The "why do we believe this?" call. Each link carries source_name, location, excerpt,
excerpt_hash, extractor and missing_excerpt — the last surfaced rather than hidden, because an
evidence link whose text can no longer be found is the strongest signal a claim needs re-checking.
GET /knowledge/entities?name=
Canonical entities with the aliases they resolved from — how Bob Smith and Robert Smith turn out to be one person.
Without name (K2) it is FORAGER's People and organizations list — everyone and everything named
in the knowledge base, paged in FORAGER's SQL: q (a name or an alias), type (FORAGER's words, one
or several, comma-separated), sort (name, the default; mentions; confidence, least confident
first), page, page_size (1–100, default 50). This used to refuse ("An entity name is required.").
{ "entities": [ { "entity_id": "ent_f07c6c99484e1002", "name": "Robert Smith", "type": "person",
"aliases": ["Bob Smith", "rsmith@acmerobotics.example"], "mention_count": 10,
"knowledge_count": 3, "source_count": 4, "confidence": 0.85,
"merged_count": 1, "status": "active" } ],
"page": 1, "page_size": 50, "total": 17 }
aliases are the other names, the canonical one not repeated; merged_count is how many references
were merged into it.
FORAGER's own application's reads (K2)
What a person reads in FORAGER's own application, read through the colony. Every one resolves its
knowledge base through ?project= exactly as every read above does; a record named by id (an entity, a
source) is answered only when it belongs to that knowledge base — FORAGER's GET /api/entities/:id and
GET /api/sources/:id are not project-scoped upstream, so the colony holds the answer to the scope and
another knowledge base's id is not_found. All are knowledge.read in the catalog, except the export
history, which is knowledge.export; neither is ever gated. List filters take FORAGER's own words
(lower case, digits, underscores) — a word FORAGER adds later filters here without a colony release —
and a value that cannot be one is 400 with the filter named.
GET /knowledge/statements
FORAGER's Knowledge page: every statement, filtered and paged as it filters and pages.
| Query | Meaning |
|---|---|
q |
Filter text: title, statement or subject containing it |
type, status, support, review_status |
FORAGER's words, one or several (fact,decision) |
conflict_state |
in_conflict, clean, or any (the default) |
min_confidence |
0 to 1 |
entity_id, source_id |
Only statements about one person or organization, or supported by one source |
sort |
recent (default), confidence, date, title |
page, page_size |
From 1; 1–100, default 25 |
{ "items": [ { "knowledge_id": "ki_03cc80edbe9b8283", "title": "Project Falcon launch date: April 10, 2026",
"statement": "…", "type": "fact", "subject": "Project Falcon",
"support": "DirectFact", "status": "Disputed", "confidence": 0.9, "confidentiality": "Tenant",
"review_status": "unreviewed", "evidence_count": 1, "conflict_ids": ["cf_c90bf1c94b46b5f7"],
"contested": true, "effective_date": "2026-04-10", "extraction_method": "deterministic",
"entities": [ { "entity_id": "ent_61c993a1bbac4e73", "name": "Project Falcon", "type": "project" } ] } ],
"page": 1, "page_size": 25, "total": 67,
"facets": { "type": { "fact": 32, "decision": 6 }, "status": { "active": 61, "disputed": 6 },
"support": { "direct_fact": 66 }, "review_status": { "unreviewed": 67 } } }
support and status are the enum names every other payload here sends; the filters take FORAGER's
wire words. facets are FORAGER's counts per filter value, which the console shows beside each
choice. total is FORAGER's count of what matched.
GET /knowledge/entities/{id}
One person's or organization's page, as FORAGER's entity page lays it out: entity (a list row),
normalized_name, merged_into, aliases (alias, kind, confidence, source_count),
merge_history (who merged what, and why), possible_duplicates (the pending merge candidates it is
one side of, both sides named), relationships (by name: from_label, type, to_label,
statement, support, confidence), statements, mentions (source_id, source_name,
chunk_id, text, confidence), attributes (what FORAGER gathered — emails, roles,
organizations — as lists of text; a value that is not text is left out) and created_at.
GET /knowledge/duplicates?status=
FORAGER's possible duplicate people or organizations: two it thinks may be one and did not merge on its
own because the evidence was not strong enough. pending (the default) or all.
{ "duplicates": [ { "candidate_id": "mc_b09942897930077c",
"entity_a": { "entity_id": "ent_f07c…", "name": "Robert Smith", "type": "person", "aliases": ["Bob Smith"] },
"entity_b": { "entity_id": "ent_020f…", "name": "R. Smith", "type": "person", "aliases": [] },
"confidence": 0.7, "reason": "Initial \"R.\" matches robert smith", "status": "pending",
"decided_by": null, "decided_at": null, "notes": null, "created_at": "…" } ],
"pending": 1 }
Deciding one — merge, or keep apart — is POST /knowledge/duplicates/{id}/decide, below.
GET /knowledge/sources/{id}
One source's page: source (the same shape GET /knowledge/sources lists), relative_path,
mime_type, origin, ingestion_status, error (code, message, stage — FORAGER's own words — or
null), parser, char_count, created_at, knowledge_count, duplicates (the sources registered as
byte-for-byte copies of it), text (the extracted text as far as FORAGER serves it), chunks (the first
200, each cut to its first 400 characters as FORAGER's page cuts them: chunk_id, index, location,
text, char_start, char_end, token_estimate, content_hash) and chunk_total. The statements it
supports are GET /knowledge/statements?source_id=.
GET /knowledge/exports
FORAGER's export history for the knowledge base, newest first: export_id, format (obsidian,
jsonl, anthill), status, file_name, size_bytes, stats (FORAGER's own counts of what it
holds), error (FORAGER's sentence), sha256, revision_id, retry_of, archive_expired_at,
created_at, finished_at, downloadable — completed, its archive still kept, and not written into
a folder — and written_to_folder: written into a folder on FORAGER's machine by its operator, so
there is no archive to take away. Making one and taking it away are below.
GET /knowledge/conflicts
Open conflicts in scope, with both sides, the suggested resolution and whether anyone has ruled.
?status= (K2) reads FORAGER's Review in one status: open, resolved, dismissed, decided
(resolved and dismissed together, the most recently decided first) or all — FORAGER's first 500 in
that status. Without it the route answers exactly as it always has: the open ones, as a mission reads
them. Every disagreement also carries (K2) suggested_winner_id — the statement FORAGER suggests
keeping, one of its own sides or null; nothing applies it until a person does — sources (the files
it came from, by name, with type and document_date), detected_at, and decision (action,
winner_id, notes, decided_by, decided_at), null while it is open.
FORAGER's own application's writes (K2)
What a person decides in FORAGER's own application, and the export they take away, through the colony. The rules every one keeps:
- The knowledge base is the caller's project's, resolved through
?project=at the operator's rank on a named project (W1-03 §8.3:project.knowledge.reviewandproject.exportare the project's administrator and operator, never its viewer); its viewer, and an owner of its organization who holds no role, are answered403 permission_denied; the console default only from the installation's organization. - The record is read first and held to that knowledge base. FORAGER's record routes are not
project-scoped upstream, so a disagreement, possible duplicate, person, statement or export of
another knowledge base is
404in the same words an id that does not exist gets, and nothing is changed. An id is held to the shape of one FORAGER mints before it is sent anywhere. - FORAGER's own words go to FORAGER, a field with no value left out rather than sent as null; the
signed-in user's name goes with every curation act as FORAGER's
actor, so FORAGER's audit trail says who. A note is optional, trimmed, and refused past 2,000 characters rather than cut. - Nothing is decided for anyone. The colony never applies FORAGER's suggestion, never picks a side, and never merges on its own.
- Every act is recorded in the colony's event log —
knowledge_curated(withact:resolve,reopen,merge,keep_apart,unmerge,review),knowledge_export_started,knowledge_export_downloaded(with the archive's SHA-256 and size) — naming who, which knowledge base and project, which record and what was chosen; never the person's note, which is FORAGER's to keep.
Curation is manage_knowledge, and forager.knowledge.curate in the catalog — Forager's paid
capability, the one that claims FORAGER's own curation routes — so each curation route asks the
commercial gate right after authentication: a colony whose Forager subscription does not carry it is
refused 402 in the colony's words before FORAGER is asked anything, and an unenrolled colony is
refused nothing. Exporting is read_knowledge and knowledge.export, never gated: a lapsed
subscription still takes away what it has.
POST /knowledge/conflicts/{id}/resolve — manage_knowledge
{ "action": "accept_winner", "winner_id": "ki_6de8adf45ded738c", "notes": "The signed memo wins" }
action |
What FORAGER does | For |
|---|---|---|
accept_winner |
Keeps winner_id (one of the disagreement's knowledge_ids) and marks the other superseded; both stay readable with their sources |
a disagreement between statements |
keep_both |
Records the disagreement as known and accepted | any |
dismiss |
Closes the issue; nothing in the data changes | any |
archive_duplicate |
Keeps one file and archives the copies, which stay readable and can be restored | duplicate_source only |
Refused 400: an action FORAGER does not take, one that does not fit the disagreement's type
(FORAGER's own API would "archive the duplicates" of two contradicting documents), accept_winner
without a winner_id or with one that is not a side. Refused 409 already_decided when it is not
open — a second decision would overwrite the one on record, so it is reopened first. Answers the
disagreement as decided, with its decision.
POST /knowledge/conflicts/{id}/reopen — manage_knowledge
No body. A decided disagreement goes back in the queue; the earlier decision stays in FORAGER's audit
trail. An open one is 409 not_decided.
POST /knowledge/duplicates/{id}/decide — manage_knowledge
{ "action": "merge", "notes": "Same person" }
merge, or keep_apart ("Not the same" — FORAGER records that they are different and will not
suggest the pair again). The possible duplicate is found in the knowledge base's own list, since
FORAGER has no read of one; one already decided is 409 already_decided. FORAGER keeps the one it
has seen named more often and moves everything from the other onto it, keeping its name as an alias;
the answer says which, read back from FORAGER rather than predicted:
{ "duplicate": { "candidate_id": "mc_5bfe64e7f79ad3c0", "status": "accepted", "decided_by": "admin", "…": "…" },
"merged": true,
"kept": { "entity_id": "ent_f07c6c99484e1002", "name": "Robert Smith", "type": "person", "aliases": ["Bob Smith"] },
"merged_away": { "entity_id": "ent_020f6b7fe030184b", "name": "R. Smith", "type": "person", "aliases": [] } }
POST /knowledge/entities/{id}/unmerge — manage_knowledge
Undoes a merge. {id} is the one merged away; the body ({"notes": "…"}) is optional. It is restored
with what the merge moved, and the pair is recorded as different so processing does not merge it
again. One that is not merged into another is 400. Answers restored and winner, each a people
list row.
POST /knowledge/items/{id}/review — manage_knowledge
{ "action": "mark_reviewed", "notes": "Checked against the signed memo" }
FORAGER's reading view's three: mark_reviewed, reject (the statement is archived in FORAGER) and
restore ("Reset review": unreviewed and active again). Answers knowledge_id, status,
review_status, reviewed_by, reviewed_at, review_notes.
POST /knowledge/exports — read_knowledge
{ "format": "obsidian",
"options": { "include_sources": true, "include_chunks": false, "include_superseded": false,
"include_general_learning": false, "include_mission_fixtures": false, "vault_name": "Harbour" } }
format is FORAGER's: obsidian, jsonl or anthill (the colony package). Every option is
FORAGER's own and optional; vault_name is at most 80 characters. The caller's Idempotency-Key is
passed through. FORAGER answers at once with the export (status: "running") and builds it in the
background; it refuses (409, in its words) while the knowledge base is being processed, because an
export taken then would mix old and new results.
GET /knowledge/exports/{id} — read_knowledge
One export's state, read while it builds: the export history's row.
GET /knowledge/exports/{id}/download — read_knowledge, and the project's operator or admin
The archive, through the colony. A GET resolves a named project as a read (any role on it, or
ownership of its organization), so this route asks for the operator's role first (W1-03 §8.3,
project.export). An owner who holds no role on the project reads its export history and is refused
the archive. The export must be this knowledge
base's, completed (409 not_ready), built as an archive (409 written_to_folder) and still kept
(410 gone, archive_expired), with a recorded digest. The colony receives the whole archive into a
temporary file of its own, removed when the answer closes, hashing it as it arrives; it is handed over
only when its SHA-256 is the one the export recorded — and the one FORAGER sent beside the bytes — and
its size is the recorded size (502 upstream_mismatch otherwise, and nothing leaves). The answer is
the archive, Content-Disposition with FORAGER's file name reduced to letters, digits, -, _ and
., X-Export-Sha256 with its digest, and Cache-Control: no-store.
GET /knowledge/sources
Registered sources: hash, type, size, processing status, document date, duplicate and superseded markers.
Ingestion
Asynchronous, always. No ANTHILL request ever waits on a document being parsed.
POST /knowledge/jobs -> queue work, return a job id immediately
GET /knowledge/jobs -> recent jobs
GET /knowledge/jobs/{id} -> real persisted stage state
POST /knowledge/jobs/{id}/cancel
POST /knowledge/jobs/{id}/retry -> resumes from checkpoints, not from the start
POST /knowledge/jobs — manage_knowledge
{ "project": "falcon", "paths": ["docs/procedures"], "force": false }
Every path is resolved through IWorkspacePathGuard before anything is sent. Containment follows
symlinks and refuses an escape by throwing; a blocked path (.git, data, caches) is refused too.
FORAGER's own FORAGER_ALLOWED_INPUT_ROOTS is the second, independent fence — neither is trusted to
be the only one. A path outside the workspace is 403 permission_denied and no request leaves.
For the FORAGER this colony runs, the colony sets that second fence (K2). FORAGER reads a folder
over its API only under one it was allowed, and allowing one in FORAGER's own interface takes its
operator key — so on a managed engine an import by path always failed. knowledge_forager_input_roots
(empty by default; see CONFIGURATION.md) is handed to the engine as
FORAGER_ALLOWED_INPUT_ROOTS when it starts. Every folder in it must lie inside the colony's
workspace — the fence above — so the engine is never allowed a folder no import through the colony
could use; a write naming one outside is refused by POST /settings with the folder and the reason
(400 setting_refused), and a folder that has fallen outside since (the workspace moved) is not handed
over at the next start and is reported in /knowledge/status's input_roots. A saved change reaches a
running engine at its next start, or at once with POST /maintenance/restart-forager, which applies
saved allowed folders on the same terms as saved retention windows: only to an idle engine.
Job payload
{ "job_id": "job_7047a24dc7358c4b", "status": "running",
"current_stage": "semantic_extraction", "progress": 0.45, "terminal": false,
"stages": [ { "name": "chunking", "status": "completed",
"processed": 8, "skipped": 0, "failed": 0, "warnings": 0 } ] }
progress is derived by FORAGER from its persisted stage rows. ANTHILL never interpolates it and no
timer advances it. The eleven stages are source_registration, parsing_extraction,
normalization, chunking, semantic_extraction, entity_resolution, dedup_conflict,
provenance, validation, indexing, export_availability.
status is one of queued, running, completed, failed, cancelled. Cancellation stops at the
next stage boundary and keeps completed work.
POST /knowledge/upload — manage_knowledge
multipart/form-data: one or more file parts, project, optional force, optional
admission_job_id (below). Bytes rather than paths, so no IWorkspacePathGuard is involved — a
browser file picker hands JavaScript a name and a stream, never a location, and there is no path to
contain. Returns the same job payload as POST /knowledge/jobs.
The transport caps are the producer's, quoted rather than invented: a request over 100 MB or 400 files is refused here, with those numbers, rather than sent to fail there. That is the ceiling of one request, not the plan's allowance — the plan's allowance is what the route below measures against.
POST /knowledge/preflight — manage_knowledge (v0.4.0, W1-07 §6)
What a batch measures, before any of it is stored. Same form fields as the upload, minus force and
admission_job_id.
{ "admitted": false, "measured_bytes": 734003200, "measured": "700 MB",
"limit_bytes": 524288000, "limit": "500 MB", "admission_job_id": null,
"code": "over_tier_limit", "message": "…",
"entry_count": 412, "admitted_count": 0, "rejected_count": 412,
"measurement_truncated": false, "provisional": false,
"largest": [ { "path": "archive/2019.zip", "bytes": 268435456 } ],
"oversized": [], "rejected": [] }
Three numbers exist for an import and only one decides. The browser's is advisory and wrong in both directions at once — it cannot apply FORAGER's exclusions, cannot expand an archive it sees compressed, cannot detect a type that will be refused — so the console shows a count until this route answers, and never a size of its own. The producer's measurement at staging is authoritative, and this route is it. The worker's, at execution, must equal it or the batch does not run.
This is the same gate the import runs, asked on its own: one boundary, one implementation (§6.2), so an operator cannot be told a batch fits and then be refused. The bytes travel and are deleted before the answer returns — nothing is registered, nothing processed, nothing charged.
A refusal is a 200 with admitted: false and every integer intact, because the question was
"does this fit" and "no, by this much" answers it. over_tier_limit, entry_exceeds_grant and
no_entitlement are answers. A producer failure (working_storage, a 5xx, a timeout) is not a
measurement and comes back as a failure, never as admitted: false.
When admitted is true the answer carries an admission_job_id. Passing it back on
POST /knowledge/upload makes the measurement and the import it authorised one admission rather
than two.
The change feed (v0.4.0, W3-05)
FORAGER publishes: a completed run is an immutable revision on a ledger, and every revision — and
every export of one — is an envelope on a per-project, replayable feed (the Formicaria cross-module
event contract, protocol_version 1). The colony follows that feed instead of polling the source
list, and keeps three things about it: an inbox (every envelope, verbatim, recorded before it
is acknowledged, and kept for a bounded window — see Inbox retention below), a watermark per
producer generation (how far it has contiguously read), and
one action per revision (the effect key from W1-09 §2.2 — instance, mapped project, revision,
action type, policy version — so a redelivery, a replay, a restored producer or three exports of
one revision are all one action).
GET /knowledge/feed?project= -> the engine's head revision, the colony's watermark(s), recent
envelopes and actions (read_knowledge)
POST /knowledge/feed/sync -> one pass of the feed, now (manage_knowledge)
{ "change_feed": true, "instance_id": "fgi_…", "generation": "gen_…", "auto_study": "suggest",
"revision_study": "inherit", "revision_study_resolved": "suggest",
"head": { "revision_id": "krev_…", "publication_sequence": 7, "publication_status": "partial",
"logical_content_hash": "sha256:…", "record_count": 214, "failed_source_count": 3 },
"watermark": { "sequence": 12, "status": "ok", "note": null },
"retention": { "inbox_days": 180, "dead_letter_days": 90, "feed_replay_days": 30,
"derivation": "max(feed replay window, dead-letter retention) x 2, floor 30 days" },
"inbox": [ { "sequence": 12, "event_type": "knowledge.revision.published", "revision_id": "krev_…",
"publication_sequence": 7 } ],
"exports": [ { "sequence": 13, "revision_id": "krev_…", "export_id": "exp_…",
"export_format": "anthill_package", "package_digest": "sha256:…" } ],
"package_agreement": "agreed", "package_note": null,
"actions": [ { "revision_id": "krev_…", "action_type": "study_revision", "outcome": "queued" } ] }
exports is what arrived on the feed as knowledge.export.completed, newest first, each naming the
revision it was cut from. An export claims no action — the effect key has no export_id in it,
so three exports of one revision are three inbox rows and zero further actions — and until v0.4.0
that meant an export envelope was recorded, acknowledged, and then invisible. This is a read:
the envelope was kept verbatim, and export_id, export_format and package_digest are read back
out of it. No table, no column, no second effect key; the console's Exports section lists them,
each with the update it was made from. A row whose stored envelope cannot be mined still appears, with those three fields
null — the row is the fact that an export arrived, and dropping it would hide the arrival itself.
An inbox row's sequence is the feed's message number, and it counts everything FORAGER sends on
the stream — an export is a message too. A publication's row also carries publication_sequence, the
update's own number, which counts updates and nothing else; it is read back out of the envelope the
inbox keeps, and is null for an export and for an envelope that does not carry it. The console names
an update by the second ("update 2") and a message by the first, and says how far the colony has read
as the newest update at or below the watermark.
package_agreement and package_note repeat what /knowledge/status says about the ANTHILL
package version, because this is the card where packages are listed and a digest is worth nothing if
the format it names is one this build cannot open.
A revision's publication_status is what the engine stated — complete or partial — and the
console draws a partial one as partial, naming how many sources contributed nothing. A partial
revision is never presented as complete (A07).
What a revision causes is what knowledge_revision_study allows and nothing more (v0.4.0,
W3-05 part two; knowledge_auto_study governed this until then, and still does wherever the new
key is left at its inherit default): under off the feed is recorded and acknowledged; under
suggest the same per-source pass POST /knowledge/seed runs with queueing off, so the findings
above list what changed; under on it runs with queueing on. sync obeys the revision setting
the same way — it drives this consumer and nothing else — and answers with the revision_study it
ran under, so acted: 0 reads as the setting working rather than the sync failing. An engine from
before the ledger declares no feed (change_feed: false on /knowledge/status) and the colony
polls its sources for that one exactly as before, every receipt labelled synthesized — under
knowledge_auto_study, because the poll is the other lane.
off stops the action, never the consumption. The feed is read, the inbox recorded, the
receipts sent (available, detail recorded; knowledge_revision_study is off), the watermark
advanced and the retention sweep run, exactly as under on — though that sweep removes nothing
recorded this way, because the fourth condition below is an action row that was never claimed, and
the inbox row is then the only record the envelope arrived. What does not happen is the claim and
the study: no knowledge_actions row exists for that revision, so nothing was done and nothing
claims to have been. A colony that stopped reading the feed would stop knowing what had been
published and would let its watermark fall behind until the replay window closed under it, which no
mode offers.
The two lanes are independent, and the study pass says so. With the poll off and revisions on,
the six-hourly pass still runs — it consumes the feed and does not poll — and its sentence ends
The six-hourly poll studied nothing: knowledge_auto_study is 'off'. With revisions off and the
poll on, it ends Revisions were recorded and acknowledged and not studied: knowledge_revision_study resolves to 'off'. Only when neither lane would act does the pass refuse outright, naming both
settings. Nothing is appended when both lanes are live.
knowledge_revision_study |
the change feed (revisions) | the six-hourly poll |
|---|---|---|
inherit (default) |
whatever knowledge_auto_study says |
knowledge_auto_study |
off |
recorded, acknowledged, watermarked; nothing claimed or studied | unchanged |
suggest |
per-source pass, queueing off — findings only | unchanged |
on |
per-source pass, queueing on | unchanged |
KnowledgeFeedConsumer.PolicyVersion did not move for this. It versions what the code does
with a revision, not what the operator asked for: the action is the same pass, under the same
per-document receipts, claimed under the same key. Bumping it would re-claim and re-study every
revision already acted on because somebody changed a preference, and a preference is not a finding
that the earlier work was wrong.
Recovery follows the contract: a gap in the sequence stops the pass short of it and marks the
stream degraded; a cursor_expired, cursor_ahead or generation_changed answer from the engine
does the same; and in every case the colony reconciles against the engine's source list — the
polling pass — before the watermark is marked ok again. A new producer generation starts its own
watermark at 0 and leaves the old row where the console can see it.
Inbox retention (v0.4.0, W1-09 §2.4, F53)
The inbox is a window, not a history. It is the transport-dedupe layer and an optimisation;
knowledge_actions is the guarantee, and it is never pruned — not by this, not by anything. So an
envelope may be forgotten once forgetting it costs nothing: a redelivery after the window is
recorded again, claims nothing, and the effect key is what stopped the double effect all along.
The window is 180 days, and it is derived rather than typed: §2.4 sizes the inbox at
max(retry horizon, DLQ retention) x 2, floor 30 days, and F53 sets the dead-letter retention at 90
days and the feed's replay window at 30 — "the inbox is derived from the DLQ rather than stored
separately". Moving the dead-letter number moves this one with it, which is the drift the decision
exists to prevent. GET /knowledge/feed reports all three numbers and the derivation, so the value
is auditable from the console.
A row is removed only when all four are true, and each one is there to make pruning free:
| Condition | Why |
|---|---|
received_at older than the window |
the 90-day dead-letter retention, doubled |
sequence at or below the durable watermark |
never prune ahead of the cursor — above it is an envelope the colony has not finished with, and the feed is the only other copy |
the envelope is a knowledge.revision.published |
an export's inbox row is the only record that the export arrived, and exports above is read back out of it |
a knowledge_actions row exists under the full effect key |
the effect is already durable, so the envelope's dedupe value is redundant |
Which means nothing recorded under knowledge_revision_study: off is ever pruned, because no action
was claimed for it and the row is then the only record that it arrived; nor is any export envelope.
The prune runs at the end of each feed pass — the one place that holds the producer identity, the
stream and the watermark together — and a prune that fails is logged and does not fail the pass.
POST /knowledge/feed/sync answers with pruned and retention_days, and the pass's own sentence
names both when anything went.
One honest degradation: the restore-then-republish flag (§2.2) is raised by looking for an earlier
publication with the same logical_content_hash in this table, so content republished more than
180 days after its original is no longer flagged at claim time. A flag already raised is on the
action row and is never overwritten. §2.4 calls this shape degraded, not broken.
Dead letters (E-19, W1-09 §5.4, §5.5, §6.3; F53)
Every envelope is checked before any field of it is believed, in §6.3's order: size (at most
16 KiB), a JSON object, its protocol version, and its header against the consumer view of
envelope.schema.json. What fails is the sender's fault and goes straight to the dead-letter
queue — knowledge_feed_dead_letters — verbatim, never into the inbox, never retried; the
producer gets a failed receipt naming the class when the envelope's event id can name it.
| Class | What the stream does |
|---|---|
header_invalid (including an element that is not a JSON object) |
moves past it when its sequence can be read; one that cannot be placed leaves the gap the next envelope reports |
oversize |
its header is not read; its event id and sequence key and place it, and the stream moves past it |
protocol_unsupported (any protocol_version but the integer 1) |
stops: the watermark does not move, nothing after it is read, the stream shows stopped, and the producer is acknowledged only up to the envelope before it — until a build that reads it arrives, or an operator discards it, after which the stream passes over it |
digest_mismatch |
in the vocabulary; nothing in the colony fetches an artifact, so nothing files one |
A revision's action that fails for a reason §5.4 retries — the knowledge base unreachable, too
slow, answering with an error of its own (a 429 rate limit is one), or the pass interrupted — is not
marked failed: the action reads retrying, the envelope is recorded and the watermark moves
(acknowledged means recorded), and later passes for the project that claimed it try again under
the contract's backoff (1 s doubling, full jitter, capped at 5 minutes — on a running colony the
six-hourly pass is what spaces them). Two projects bound to one knowledge base share one cursor, but a
retry is an effect: it queues its missions in the project that claimed it, so only a pass for that
project runs it — never another project's. (One claimed through the default binding, by a Sync with no
project, is run by the next such Sync; the six-hourly timer runs passes for bound projects only.) A
retry that succeeds was never a dead letter. One still failing 24 hours after its first attempt becomes one (retries_exhausted,
every attempt its log) and the action reads dead_lettered — filed by the queue's upkeep, which runs
first in every pass and on the six-hourly timer and needs nothing from the engine, so it happens on
time whether the knowledge base is down, both study lanes are off, or the project was unbound since.
The producer is owed its failed receipt, and the next pass that can read the feed sends it (and says
so). A failure retrying cannot mend is failed exactly as before.
Every entry carries what §5.5 lists: the envelope verbatim, every attempt (time, class, error text,
consumer build — identical consecutive attempts are one record with a count), the consumer's schema
and registry version, the resolved scope (knowledge base and bound project), and first-seen and
last-attempt times. An entry is unresolved until an operator replays, replays after a fix
(recording which change) or discards it — see What an operator does with one below.
Retention (W1-10 §4.6). An unresolved entry is never touched, at any age. A resolved one keeps its
envelope for 90 days after it was resolved — the same DeadLetterRetentionDays the inbox window
is derived from — and is then reduced to its header and disposition: the envelope keeps only its
header fields when the colony had validated that header (an entry that exhausted its retries, or whose
artifact failed its digest), and none of itself otherwise; attempts keep their class, fingerprint and
build and lose their text. No row is removed. The sweep runs first in each feed pass for that
knowledge base — before the engine is asked anything, so an unreachable one is still swept — and
under the inbox prune's guard: a sweep that fails is logged and the pass goes on. It reads through an
index of the resolved rows not yet reduced, and commits in batches of 200. POST /knowledge/feed/sync
answers with dead_lettered, retried, retrying (this project's retries still waiting),
dead_letters_scrubbed and dead_letters_escalated, and the pass's sentence names each when it is
not zero. A pass that stops before reading the feed — the knowledge base down — still says what the
upkeep did.
Escalation (F65, D-priv-11; W1-10 §4.6 (1)). An unresolved entry that has been a dead letter for
180 days — counted from when it became one, so a retry's 24 hours do not count — is escalated,
never purged: the entry is stamped once (escalated_at), a knowledge_dead_letter_escalated event
names it (one event per knowledge base per upkeep, ids and counts only, never an envelope or an
error's text), the host's log says so, and the console draws it as escalated with a banner over the
card. Nothing else about it changes, and no sweep selects it. The escalation runs where the sweep
runs — first in every feed pass — and the six-hourly study timer runs both over every knowledge
base whatever knowledge_auto_study and knowledge_revision_study say, because a six-month-old dead
letter is a defect report whether or not the colony is studying anything. Surfacing does not wait on
either: the list and /knowledge/status count an unresolved entry past the line as escalated at the
moment they are read. Resolving it ends the escalation; the stamp stays as history.
What an operator does with one (E-19 build 2, W1-09 §5.5)
GET /knowledge/feed/dead-letters?project=&state=unresolved -> the queue for that knowledge base (read_knowledge)
GET /knowledge/feed/dead-letters?installation=true -> every knowledge base's queue (manage_knowledge)
GET /knowledge/feed/dead-letters/{id}?project= -> one entry whole (read_knowledge)
POST /knowledge/feed/dead-letters/{id}/replay -> {project, reason, after_fix, change} (manage_knowledge)
POST /knowledge/feed/dead-letters/{id}/discard -> {project, reason} (manage_knowledge)
POST /knowledge/feed/dead-letters/{id}/discard -> {knowledge_base, reason}: the installation's (manage_knowledge)
The list is the queue itself, read — F53's "the inbox view returns the DLQ's contents" — with its
counts (unresolved, escalated, retrying, resolved), the two numbers that govern it
(retention_days 90, escalation_days 180) and the reasons each action takes. A row carries what an
operator decides with: class, event type and id, sequence, age, attempts (with the last one's class
and fingerprint, never its text), whether it is escalated, whether it has stopped its stream, whether
it is commercial, and what was done with it. GET …/{id} adds the envelope as kept and every
attempt's text — inside the customer's colony, the one place W1-10 §4.6 (3) lets that text go. A retry
is not a dead letter and is in neither. An id in another knowledge base is not_found.
An entry is read whole and acted on in the project it was filed under. Two projects may bind one
knowledge base, so the list — which carries no envelope and no error text — shows every entry on it;
but GET …/{id}, replay and discard each resolve the entry's own project again from this colony's
binding, with the caller's standing in that project, and answer 409 conflict when it does not
resolve to the entry's knowledge base for them. An entry whose project no longer reaches its knowledge
base — unbound, or bound elsewhere — is therefore on no project's list, and is the installation's:
?installation=true lists every knowledge base's entries, each with knowledge_base and reachable
(whether its project still reaches it), and a discard naming the entry's knowledge_base instead of a
project discards one that is not reachable — with a named actor, a typed reason, R5.5 and the
contract's event, like any discard — and refuses one that is, which is its project's to decide. There
is no installation-level replay: a replay acts in the scope the entry was filed under, and that scope
is what is gone; repair the binding and replay after that fix.
Three actions, and exactly three, each with a named actor — the caller's own name; a colony with API authentication off cannot name anyone, and the action is refused rather than recorded under a stand-in — and a typed reason: a code from the action's own list, not free text.
| Action | Reasons |
|---|---|
| replay | retried_by_hand, cause_resolved |
replay after a fix (after_fix: true, change required) |
consumer_upgraded, binding_repaired, configuration_changed |
| discard | producer_defect, duplicate, superseded, not_needed, test_event |
Replay re-enters the consumer with the envelope the colony kept, read again the way the feed read it when it arrived, under the same event id:
- The door again (§6.3 checks 2–4). An envelope this build still refuses is not replayed:
409 refused_at_the_door, the refused try is added to the entry's attempts, and the entry stays unresolved — the bytes have not changed and neither will the answer until a build that reads them is installed. That is what replay after a fix is for. - Recorded in the inbox (a second copy is a no-op), resolved, claimed, and acknowledged
received. The resolution, the claim and the run's place in the retry lane are one transaction: of two operators replaying at once, one is told who won and nothing else of their replay happens. - A stream the entry stopped (
protocol_unsupported) moves past it when its watermark stands just before it, and the producer is acknowledged up to it. - The effect, deduplicated by the effect key like any redelivery (§2.1): a revision whose action
already stands runs nothing; one whose retries ran out (
dead_lettered) has that action re-armed and studied — never a second action for the revision; one never acted on is claimed now. The study runs through the retry lane's own runner, so a failure that is retried goes back into the lane (and returns to the queue as a new entry if it is still failing 24 hours later), one that is not readsfailed, and an interrupted run is picked up by a pass once a backoff cap has passed. Underoffthe replay records and acknowledges and claims nothing, as the pass does.
The replay runs in the scope the entry was filed under, resolved again from this colony's own binding
and the caller's standing in that project; a binding that has moved since is refused (409), and the
way through is to repair it and replay after that fix, or discard. It studies under
knowledge_revision_study, exactly as Sync does.
Discard resolves the entry with who and why and leaves platform.deadletter.discarded in the event
log — the contract's own registered name, with W1-05 §6.K's payload: event_id, event_type,
record_class, actor_id, reason_code, reconciliation_forced. A discarded envelope that had
stopped its stream is passed over by the next pass. A commercial dead letter's discard is not a
discard (R5.5): an entry whose event type is commerce.* or billing.* and whose record class is
operational (or could not be read) forces a reconciliation first — on this colony, a read of this
installation's entitlement from the account service (§3.5: the read is the reconciliation). A read
that completes, or finds no commercial state in question (not enrolled, placed by hand, retired), lets
the discard through and is recorded on the entry and the event; one that does not is 409 reconciliation_failed and the entry stays, still saying the state is in question.
Every action is refused before it does anything when it cannot be recorded as asked. Replays are
logged as knowledge_dead_letter_replayed. All three events go to the colony's event log, which is
where an operator's act is recorded today; the audit trail W1-05 designs is not built (roadmap E-12).
None of them carries an error's text: a replay whose study failed is logged with the failure's class
and its text's fingerprint, and the text itself stays in the entry's attempts and the answer to the
request — the event log is not swept with this queue, and W1-10 §4.6 (3) keeps the full string in the
store. The console's Colony section shows the queue in a Dead letters card after What
FORAGER has published, with the entry's details and the three actions; its badge counts that
knowledge base, it names what waits in other knowledge bases, and a manager can open the
installation's list there.
Every POST the host makes to FORAGER carries an Idempotency-Key: the console's own, forwarded
from the request that started an ingestion, or one minted per call. A reply lost on the wire is
retried as the same intent and answered with the same job.
Knowledge changes (v0.3.9.3, A4)
What has changed in a bound knowledge base since the colony last studied it, and the two things an operator can do about it.
GET /knowledge/changes?status=open -> findings (read_knowledge). status=all for every status
POST /knowledge/changes/scan -> run the analysis NOW and queue nothing (read_knowledge)
POST /knowledge/changes/{id}/queue -> turn one finding into a mission (manage_knowledge)
POST /knowledge/changes/{id}/dismiss -> record that it needs none (manage_knowledge)
{ "id": "b2c1…", "project_ref": "falcon", "source_id": "src_91",
"source_name": "onboarding.md", "kind": "changed",
"previous_hash": "ab12…", "current_hash": "cd34…",
"status": "open", "mission_id": null, "detected_at": "2026-09-10T14:02:11Z" }
A finding is not a mission. kind is new — never studied — or changed — studied at a
different content hash. status is open, queued or dismissed; a decided finding stays in the
table, because the record that somebody looked is the thing that stops the next pass asking again.
The comparison is a read of the seed receipts POST /knowledge/seed has been writing since
v0.3.8.154: logical_content_hash per studied source was already there and nothing had ever asked
for it. The watermark is derived from the newest finding rather than stored beside the rows, so
there is no second fact that can disagree with them.
scan and the timer run the same pass as POST /knowledge/seed with queueing off — see
knowledge_auto_study in docs/CONFIGURATION.md. Queueing carries manage_knowledge for the same
reason seeding does: it spends model calls, and deciding the colony should go and work is an
operator action, never an agent's.
The console sends scan, seed and feed/sync for the project picked in its bar, and offers none of
them while the console default is picked. What they write is keyed by the knowledge base — a finding
and a seed receipt by SeedActionKey, the feed's watermark by its stream — so a pass with no project
files and claims what a project reading the same knowledge base needs, and the missions it queues have
no project, which Queen.ResolveKnowledgeScope gives no knowledge. The routes still take no project,
for a caller that means it.
Agent tools
The same capability, reached by an agent instead of a browser. Always registered — with
knowledge_enabled off they refuse at call time rather than being absent, so role readiness does
not depend on a feature flag — and dispatchable only by roles whose contract lists them.
| Tool | Does |
|---|---|
knowledge_search |
ranked candidates |
knowledge_retrieve |
evidence-backed context |
knowledge_get |
one item |
knowledge_evidence |
the sources behind an item |
knowledge_entity |
look a name up |
knowledge_review |
propose a review decision — applies nothing |
No tool takes a project or scope argument. Tool arguments are chosen by a model; a project_id
parameter would make retrieval scope a model's choice. Scope arrives ambiently through
KnowledgeScopeContext, entered by the core at mission intake, and there is no supported way for a
tool call to widen it.
OpenAPI
ANTHILL does not generate OpenAPI today, so these routes are documented here, in the console's own
conventions. FORAGER does: GET http://127.0.0.1:8790/api/openapi.json describes the upstream
contract (OpenAPI 3.1, 35 paths, 23 schemas) if you need to see what ANTHILL is talking to.
