One gold note through the real schemas. The writeback maps all three labels. It dropped one of them until a contract check found the difference.
The article text and all three predicted labels are real. They come from
gold row g002 in the classifier's eval set. That prediction
matches the hand label and the LLM judge on every axis. The note's title,
id, and timestamps are illustrative placeholders.
Plate 01
Create, classify, write three tags, search. The writeback maps every
field the classifier returns. Step 3 below records the one it
dropped, and the check that closed the gap.
A client creates a note
POST /notes takes a title and content. It
returns 201 immediately and schedules the enrichment as a
background task, so tags is still empty at this point.
POST /notes
{
"title": "USS Ronald Reagan first ordnance drop",
"content": "F/A-18E Hornets assigned to the \"Eagles\" of Strike Fighter Attack Squadron 115 ... in support of Operation Iraqi Freedom."
}
A forced tool call keeps the classifier in schema
The background task POSTs the text to the classifier's
/classify endpoint. One claude-sonnet-5 call
pins tool_choice to the classify_article
tool. That forces the model to answer only as an enum-validated tool
call. The endpoint returns the call as
{category, operational_domain, region}. There is
no confidence score, by design.
// forced tool_use emitted by the model
{
"name": "classify_article",
"input": {
"category": "operations",
"operational_domain": "air",
"region": "middle-east"
}
}
Labels become namespaced tags
notes-api owns the writeback. It maps the fields it
knows to prefixed tags. Note the operational_domain
→ domain: rename. It applies the tags with replace
semantics, so a second run converges and writes no duplicate. It keeps
the user's own tags.
The background task writes through the ORM
in-process, and it does not call the service's own HTTP endpoint. The
equivalent PUT /notes/{id}/tags exists for
external callers, and it carries the same replace semantics.
This step dropped region, and
that is what the audit found. The classifier grew a third
field in v3. This consumer read two fields, and it ignored the rest.
It did not break, because the writeback pulls the fields out by name
and does not assume the response shape. A drop that breaks nothing is
the drop that no build reports.
The frozen contract for this seam requires a provider
change and a coordinated consumer update together. The classifier
shipped region, and that update did not land with it. No
build went red, because each repo's “contract test”
asserted against its own copy of the shape rather than a shared one.
Nothing broke here, and nothing would have noticed if it had. The
SYS-004 amendment
records it.
That gap is closed.SYS-018
landed on 2026-07-18, and the same change maps region.
The writeback writes three tags now, and the payloads on this page
are the current ones. The classifier now publishes
contracts/classify-response.schema.json, generated from
its live response model, and this consumer fetches that file in CI
and fails when its own field set diverges. A fetch failure still
warns and exits 0, so a provider outage reads as a pass.
The full decision
→
Plate 02Tolerance of an unknown field is consumer
hygiene, not a guard. The writeback reads the fields it knows by
name, so a new field costs it nothing. A shared check now fails
the build when the two shapes drift apart. A fetch failure still
exits 0, so a provider outage reads as a pass.It does not show the seam that let the drift
through. Each repository asserted against its own copy of the
shape, and The System shows that
seam. The notes above keep the reasoning and the record
link.
A GET returns the labels in its flat
tags list, and it returns enrichment_status
beside them. The status starts at pending. The background
task sets it to done after a writeback, and to
failed after a classifier error. It stays
pending when CLASSIFIER_URL is unset, so a
local run never reads as a failure. The tags show the labels, and the
status shows whether the task that wrote them finished.
The kb-agent's search_notes tool filters by
tag over the same HTTP seam, so the freshly-tagged note is now retrievable
for grounding. The result follows the system-wide observation contract
(SYS-003): a status, a summary, the payload, and a cited source.