Skip to content

feat(http): POST /ingest — enqueue a review-gated proposal from posted content #320

Description

@plind-junior

the http transport already exposes /mcp, /rpc, /healthz, /health, and /capabilities (src/vouch/http_server.py), but every write path still requires a caller that can speak the mcp or jsonl envelope. a lot of upstream producers — ci jobs, cron scripts, form backends, log shippers — can only do one thing well: POST a chunk of text at a url. today they have no way to hand that content to a kb without embedding a full client.

add an authenticated POST /ingest that accepts a small json payload and files it as a proposal into the pending queue. it is a thin front door over the same proposals.propose_* path the other surfaces use — it never writes an approved artifact, it just enqueues review work that a human later walks with vouch review / kb.approve.

proposed surface

new route on the existing starlette app in make_app():

POST /ingest
authorization: bearer <token>
content-type: application/json

{
  "kind": "claim",                 // claim | page | entity | relation
  "text": "…",                     // claim text / page body per kind
  "evidence": ["source-id-or-url"],
  "claim_type": "observation",
  "confidence": 0.7,
  "tags": ["ci", "nightly"],
  "rationale": "posted by nightly-drift job",
  "slug_hint": "…"                  // optional
}

behaviour:

  • dispatches by kind to proposals.propose_claim / propose_page / propose_entity / propose_relation — the exact functions _h_propose_* in src/vouch/jsonl_server.py already call. no new business logic; /ingest is a payload adapter, not a second write path.
  • responds 202 with {"ok": true, "proposal_id": "…", "status": "pending", "kind": "…"} on success; 400 on a bad/unknown-kind payload and 413 on an oversize body (reuse MAX_BODY_BYTES and the _error_payload shape already in the file); 401 when the bearer gate rejects.
  • dry_run: true is honoured and returns the proposal shape without enqueuing, mirroring propose_claim(dry_run=...).
  • attribution: read X-Vouch-Agent for proposed_by exactly as _rpc does, so ingested proposals are traceable to their producer in the audit log.

config stays in the existing serve: block — no new keys. an operator who wants /ingest reachable off-box uses the same bearer_tokens accept-list and --allow-public already used for /rpc and /mcp.

review gate & scope

/ingest can only propose, never approve. it calls propose_* and stops; there is no code path from this endpoint to proposals.approve(). approval remains a separate, human-driven step through kb.approve (mcp/jsonl/cli). that keeps the load-bearing invariant intact: every write still goes through the review gate, and this background-driven front door never auto-writes or auto-approves.

the endpoint runs behind the same bearer middleware as /rpc and /mcp, and it sets the trust context to a remote authenticated subject before dispatching — trust_mod.with_auth_subject(trust_mod.JSONL_HTTP, bearer) inside trust_mod.trust_context(...), the same wiring _rpc uses (http_server.py around lines 203-216). so the remote=True boundary from #227 applies: an ingested payload is stamped as a remote, authenticated caller and is subject to every confinement a remote caller gets. combined with the endpoint physically lacking an approve path, this closes the door #168 warned about (a remote transport reaching an approval) rather than reopening it.

local-first is unchanged: default bind stays 127.0.0.1, make_server's bind policy still refuses a non-loopback host without both --allow-public and a token, and nothing here calls out to a network service.

since /ingest is not a new kb.* method — it is a transport-level convenience over existing propose_* methods — it does not add a kb.* verb and so does not touch the four registration sites (server.py, jsonl_server.py HANDLERS, capabilities.METHODS, cli.py). if review prefers to expose it as a first-class method instead, that becomes a kb.* addition and must register in all four plus a test; the transport-only framing here is deliberately narrower.

this is distinct from the adjacent issues: #176 shipped the http transport and its /mcp + /rpc + auth scaffolding but no bulk-ingest front door; #227 defines the remote trust boundary this endpoint consumes rather than defines; #168 is the cross-agent-approval hole this endpoint must stay clear of, not touch.

acceptance criteria

  • POST /ingest route added in make_app() (src/vouch/http_server.py), methods ["POST"].
  • payload dispatches by kind to the matching proposals.propose_* function; unknown kind400.
  • success returns 202 with proposal_id + status: pending; the proposal lands in the pending queue (kb.list_pending / vouch review shows it).
  • no code path from /ingest reaches proposals.approve(); approval still requires a separate human kb.approve.
  • endpoint sits behind the existing BearerMiddleware; unauthenticated request → 401; /ingest is not added to _PUBLIC_PATHS.
  • trust context set to a remote authenticated subject (JSONL_HTTP + with_auth_subject) for the dispatch, so remote=True confinement applies.
  • oversize body rejected with 413 and the existing _error_payload shape (reuse MAX_BODY_BYTES); malformed json → 400.
  • dry_run: true returns the proposal shape without enqueuing.
  • X-Vouch-Agent flows into proposed_by and appears in the audit log for the enqueued proposal.
  • default bind stays loopback; off-box exposure still requires --allow-public + a bearer token.
  • test coverage under tests/test_http_server.py (or a focused tests/test_ingest.py): happy-path enqueue, 401 without token, 400 on bad kind, and an assertion that no approved artifact is written.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmcpmcp, jsonl, and http surfacessize: M200-499 changed non-doc lines

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions