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.review and project.export are the project's administrator and operator, never its viewer); its viewer, and an owner of its organization who holds no role, are answered 403 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 404 in 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 (with act: 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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 reads failed, and an interrupted run is picked up by a pass once a backoff cap has passed. Under off the 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.