SYS-006: Freeze the GET /notes read contract between kb-agent and notes-api¶
Status: Accepted Date: 2026-06-23 Deciders: San Lee
Context¶
kb-agent gained a second cross-repo tool, search_notes, that reads the user's live
notes from notes-api over HTTP — the agent's GET /notes consumer. This is the
read sibling of SYS-004 (which froze the
synchronous /classify seam between kb-agent and the classifier) and it closes the
last unbuilt edge in the program dependency map: kb-agent → notes-api.
Like SYS-004, this is a contract that spans two repos with separate release cycles, so
it is owned by neither and is recorded at the system tier (system/SYS-001). The
risk is the same one R8/SYS-004 named: the provider (notes-api) could rename a field
or change the response shape, and the consumer (kb-agent) would silently mis-read it
at runtime. This ADR freezes the read shape and ties the versioning rule to it.
Source of truth (read from the code, not invented here):
- Provider — notes-api
src/notes_api/router.py(list_notes,GET /noteswith optional?q=and?tag=) delegating tosrc/notes_api/service.py(NoteService.get_all, which runs the case-insensitive title/content search and tag filter), and theNoteResponseschema insrc/notes_api/schemas.py(the response model). - Consumer —
kb-agentagent/tools.py(search_notes, which GETs{base}/noteswithq/tagparams and readsid/title/content/tagsout of each element).
Decision¶
Adopt the following as the frozen GET /notes read contract. Both repos are bound
to it; neither may change it unilaterally (see Versioning rule).
Endpoint. GET {base}/notes, where {base} is the notes-api base URL supplied to
kb-agent via the notes-api entry in projects.yaml (config, not code).
Query (optional).
q— free-text substring matched in title/content.tag— exact tag to require.- With neither, returns all notes. Blank values are treated as absent.
Success response — HTTP 200 (JSON). A JSON array of note objects; each carries at least:
{ "id": <number>, "title": "<string>", "content": "<string>", "tags": ["<string>", …] }
- The body is an array (possibly empty), not an object — the load-bearing clause the
consumer relies on.
notes-apialso returnscreatedAt/updatedAt;kb-agentreads onlyid/title/content/tagsand ignores the rest, so adding response fields is non-breaking.
Errors / empties. A non-200, a non-JSON body, or a non-array 200 is a service-side
problem; kb-agent's search_notes surfaces each as a SYS-003 error observation (never
a raised exception), and an empty array as a SYS-003 warning.
Versioning rule (mirrors SYS-004). Removing or renaming id/title/content/tags,
or changing the 200 from an array to some other shape, is a breaking change to this
contract, requiring a coordinated change across both repos landed together plus an
update to this ADR. Adding a new response field, or a new optional query param, is
non-breaking (the consumer ignores unknown fields).
Both sides carry tests for their half. kb-agent has a search_notes contract suite
(happy path + every failure mode → SYS-003 observation, all through the _obs() grader);
notes-api has router/schema tests pinning the GET /notes shape (FastAPI TestClient +
SQLAlchemy in-memory SQLite). (Unlike a single process, there is no cross-repo live
contract test on this read seam — each side tests its own end; a live round-trip is the
same residual the event-loop seam tracks in SYS-005.)
Relationship to SYS-003 and SYS-004. SYS-003 governs the agent-facing observation
envelope search_notes returns to the model; SYS-006 governs the HTTP wire shape
underneath it — exactly the two-layer split SYS-004 drew for the classify seam.
search_notes is where they meet, translating a GET /notes response into a SYS-003
observation.
Consequences¶
- The dependency map is now fully wired. Both
kb-agenttool seams (→ classifier,→ notes-api) exist and are contract-frozen; the agent reads the knowledge base through the service that owns it, not only through static stubs. - Drift is loud, not silent — the same guarantee as SYS-004, now on the read seam.
- The repos stay decoupled — HTTP + config, not a shared import or a shared DB.
- What we'll revisit. The read is unauthenticated and unpaginated:
GET /notesreturns all matches, sosearch_notescan return a large payload for a broad query. Acceptable at personal-KB scale; revisit with alimit/pagination param (a non-breaking add under this contract) if note volume grows. Auth is a notes-api-wide concern, tracked there.
Alternatives Considered¶
| Option | Reason Not Chosen |
|---|---|
| Index notes-api's content into kb-agent's ChromaDB instead of a live read | Stale by construction and re-introduces a copy of the data; the point of the seam is to read the owning service live. The static stub (kb/projects/notes-api.md) already covers "describe the project" |
| Share a database or a library between kb-agent and notes-api | Re-couples two independently-released repos into one runtime — the opposite of the deliberate HTTP decoupling SYS-004 also rejected |
| Leave the read shape implicit (status quo before this ADR) | The "silent drift" risk: notes-api could change GET /notes and kb-agent would mis-read it with nothing failing until runtime |
| Record this as a kb-agent-local or notes-api-local ADR | The contract binds two repos and is owned by neither — system/SYS-001 puts that at the system tier (same call as SYS-004) |
Source of truth: provider — notes-api src/notes_api/router.py (list_notes) →
src/notes_api/service.py (NoteService.get_all) and src/notes_api/schemas.py
(NoteResponse); consumer — kb-agent agent/tools.py (search_notes). Siblings:
SYS-004 (the synchronous classify seam, same
versioning discipline), SYS-003 (the observation
envelope search_notes returns), SYS-005 (the async
event-loop seam).