Repository navigation
fix(ingest): classify block-mode ingest writes like /api/store - #69
Conversation
POST /api/ingest in block mode wrote through store.UpsertBlock and
returned without store.ClassifyBlockAfterUpsert, the hook POST
/api/store and the MCP store and update paths run after their write. An
ingested block kept the row default (knowledge, auto) even when its
title or metadata matched a registry classify rule.
The ingest handler now takes the block-type registry, as the store
handler does (server.go passes the same blocktypeReg), and runs the
hook after a block-mode upsert, on the block's title and metadata, with
the caller's tenant-resolved set (SnapshotForRequest). Error handling
matches context_store.go: a failed classify is logged at WARN, the
chunk still reports "updated", and the written block keeps the type it
already has (the default on a new row). type_source='manual' rows and
the I7 refusal of derived types behave as on every other hook site,
because both live in ClassifyBlockAfterUpsert.
Chunk mode is unchanged. InsertChunk rows (lifecycle_state='chunk')
have never gone through the hook, and InsertChunk has no caller besides
this handler. The shipped `ctx ingest` client sends no source_id, so
its writes take the block-mode path and are classified now. A re-ingest
of identical content is a hash no-op and is not classified, so a block
ingested before this fix keeps its type until it is written again with
changed content or its title or metadata is updated.
docs/api.md (the /api/ingest row) says what block and chunk mode do
with the type. docs/architecture.md and the ClassifyBlockAfterUpsert
comment list every site that calls the hook (grep -rn
'ClassifyBlockAfterUpsert(' outside tests): /api/store, MCP store,
confirmed staged writes, block-mode ingest, the digest topic map and
its stub, the root map, the dream daily report and the update
afterwork (REST manage update, MCP update, confirmed updates).
Tests: internal/handler TestIngestBlockModeClassifies ingests through
HandleIngest with a booted registry and reads type_name and type_source
back: an audit-pattern title lands on (audit-trail, auto); a title that
matches no rule with metadata {"is_meta": true} lands on (system-meta,
auto), a type only the metadata branch of the classifier can produce;
an upsert onto a block typed 'reference' by hand stays (reference,
manual); a chunk-mode write with an audit-pattern title, against a
context_sources row created out of band, stays (knowledge, auto); an
unmatched title stays (knowledge, auto); a catalog-pattern title is not
given the derived type; with no registry wired the classify fails and
the chunk still reports "updated" with success true. cmd/ctxd
TestIngestMountClassifies_Integration posts through NewRouter, so the
server.go wiring and the request-scope hook are covered: a _global
audit-pattern title lands on (audit-trail, auto), and a title that only
a rule of the key's own tenant scope matches (a 'reference' row in
context_block_types for scope 'private') lands on (reference, auto).
type_source alone cannot show the bug, because the hook writes only
type_name and keeps 'auto', so the probes assert both columns.
Red proof (docker 29.9.2, timescale/timescaledb-ha:pg18, go1.27.3),
each mutation of the fix alone, both probes run each time:
- M0 call removed from processChunk (the base behaviour): both probes
exit 1; title and metadata subtests and both mount subtests read
(knowledge, auto).
- M1 nil set at the call: the same.
- M2 nil registry in server.go: the mount probe exits 1 (both
subtests), the handler probe passes.
- M4 nil metadata at the call: block_mode_metadata_rule_classifies
fails with (knowledge, auto), want (system-meta, auto).
- M5 Snapshot() instead of SnapshotForRequest in classifySet:
tenant_rule_classifies fails with (knowledge, auto), want
(reference, auto).
- M6 the hook also run after InsertChunk: chunk_mode_does_not_classify
fails with (audit-trail, auto), want (knowledge, auto).
- X7 a classify error fails the chunk:
block_mode_classify_failure_keeps_write fails with success false.
On the unmodified base 7f17de2 the mount probe compiles and exits 1
(both subtests read (knowledge, auto)); the handler probe does not
compile there, because NewIngestHandler takes the registry now.
Gates (go1.27.3): go build ./... and go vet ./... (also
-tags=integration) exit 0; gofmt -l on the changed files is empty;
golangci-lint run --timeout=10m (v2.15.0) reports 0 issues; go test
-short -race ./... passes all 65 packages; go test -tags=integration
-count=1 -p 1 ./internal/handler/... ./cmd/ctxd/... exits 0;
deadcode.sh and deadcode.sh testonly report ok; CGO_ENABLED=0 build
and vet for windows/amd64 and darwin/arm64 exit 0.
Fixes #61
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S3bwgEeYiQ8cXWgF7FN9J1
GottZ
left a comment
There was a problem hiding this comment.
Review — fix(ingest): classify block-mode ingest writes like /api/store (#69)
Reviewed: root @ 7f17de21 → fix/61-ingest-classify @ c94ee7a4 (1 commit, 10 files, +410/−16). Merge base is the current root tip.
Executed: in a worktree at c94ee7a4: go build ./..., go vet -tags=integration ./..., golangci-lint run with and without --build-tags=integration (0 issues each), go test -tags=integration -p 1 -run 'TestIngest|TestDerivedWriteLock' ./internal/handler/ ./cmd/ctxd/ (ok; the 7 + 2 new subtests all ran, none skipped). Red-proof via go test -overlay without the hook call: handler probe red at ingest_classify_integration_test.go:97 (("knowledge","auto"), want (audit-trail, auto)) and :116, mount probe red at :134/:139; with the base server.go (nil registry) the mount probe is red as well. Mutation sweep over 7 further variants (details on request).
Read: handler/ingest.go end to end, store/classify.go, blocktype/registry.go (snapshot path), blocktype/builtin.go (rules), cli/ingest.go (payload), all NewIngestHandler callers, the other eight ClassifyBlockAfterUpsert sites, both docs hunks, migrations/113_baseline.sql:700-706.
Body vs head: matches.
1. What the change does
Block-mode POST /api/ingest now runs store.ClassifyBlockAfterUpsert after UpsertBlock with the caller's tenant-resolved set (SnapshotForRequest), argument-for-argument the same call as /api/store (context_store.go:196), MCP store and the confirm path; NewIngestHandler takes the registry and server.go passes the one the store handler gets. Chunk mode stays unclassified. A failed classify is logged at WARN and the chunk still reports updated. The doc comment on the hook and docs/architecture.md now list all nine call sites (my own grep agrees: nine non-test sites). Diff and body agree.
2. Overlap and duplicates
Search scope: gh search prs on "ingest classify" (open and closed), "ClassifyBlockAfterUpsert", "#61"; file-path intersection with open #55/#57/#63/#67.
| PR | Status | Class | Consequence |
|---|---|---|---|
| #55 | open | related, distinct (no shared files) | docs/ctx-as-agent-memory.md:14 (at 6dc5215b) lists where the classifier runs and does not name ingest; once this lands that sentence is one site short — worth a word in #55 (same author). |
| #63 | open | textual neighbour only | no shared files; no ordering constraint. |
No open or closed PR covers this fix.
3. Findings (ranked)
Low — the registry snapshot is resolved per chunk, not per request [read]
- Category: cost
- Where:
go/internal/handler/ingest.go:322→:357-367; miss pathgo/internal/blocktype/registry.go:175-206 - Entry point:
POST /api/ingestwith up tomaxIngestChunks = 200block-mode chunks. - Before → after: on a cache hit
SnapshotForRequestis one atomic load, so the steady state costs nothing measurable. On the miss path (tenant set build fails) the registry caches nothing and logs a WARN per call, so one request becomes up to 200 rebuild attempts and 200 WARN lines where/api/storeproduces one. A reload landing mid-batch also classifies the chunks of one request against two rule generations. - Who pays: a tenant whose type overlay fails to load; frequency rare; magnitude not measured.
- Fix: resolve
set := h.classifySet(ctx)once inHandleIngestbefore the chunk loop and pass it intoprocessChunk(the store handler resolves once per request too). Fine as a follow-up.
Low — the docs sentence about chunk rows is one edge short [read]
- Category: claims
- Where:
docs/api.md:46("A chunk-mode row is not classified; it keeps the type it has") vs.go/migrations/113_baseline.sql:704-705 - Entry point: a block-mode write (no
source_id) whose(category, title, scope)equals an existingInsertChunkrow. - Before → after: the unique index has no lifecycle filter, so the block-mode upsert lands on the chunk row and the hook classifies it. This was already reachable through
/api/storebefore this PR; what is new is the sentence that promises chunk rows stay unclassified. - Fix: "Chunk-mode writes do not run the classifier" (a statement about the write path, not about the row).
Low — every probe sends a single chunk [executed]
- Category: tests
- Where: handler probe
:66, mount probe:97 - Evidence: a mutation that classifies only
index == 0passes all nine subtests. - Fix: one request with two or three block chunks (and one mixed with a chunk-mode chunk), asserting the type of each — pins the loop, not just the call.
4. Declared behaviour changes (not defects)
ctx ingestalways writes in block mode (cli/ingest.go:204-211sends nosource_id), so every vault note now goes through the title/metadata classifier. With the built-in rules a note whose title containssession,performance,reset,baseline, … becomesaudit-trail(damped in retrieval); a title starting with "Compaction source" / "Compaction checkpoint head" becomescheckpoint(retrieval-excluded). That is the point of #61 and the same policy/api/storeapplies; stating it here so nobody reads it as a regression later.- No backfill: identical content is a hash no-op before the hook (
ingest.go:262-264), as on/api/store. Blocks ingested before this fix keep their type until a content change or the classify job.
5. Checked and found in order
Hook arguments identical to context_store.go:196, mcp.go:642, confirm_core.go:247 (metadata from the store return) · manual type kept: the UPDATE carries AND type_source = 'auto' (classify.go:90-93) and the upsert without a type does not touch type_source · nil metadata becomes {} (blocks.go:534-536) · derived types declined by title (I7, classify.go:84-88), so no new I7 door · S3/provenance refusal runs inside the upsert transaction before the hook; the chunk ends before the hook on every error arm · tenant resolution uses the key's home scope, the same scope the write goes to · all seven NewIngestHandler callers adjusted · the only auto-typed writer left without the hook is InsertChunk, declared in code and docs · helpers (ingestKey, postIngest, firstResult, seedSource, wlCatalogTitle, applyEnv, loadFromEnv) exist · probes write through the production path (HandleIngest, NewRouter) · "type_source alone cannot show the bug" holds (the hook writes only type_name) · CI runs both new tests (handler shard ci.yml:654-655, cmd/ctxd in the rest complement) · one signed commit, author = committer, conventional title, base = current root tip.
6. Existing review coverage
- CodeRabbit walkthrough (2026-11-03T01:18Z): no actionable comments.
- No human review before this one. All three findings and the two declared changes are new here.
7. Verdict
Approve. The fix is the /api/store hook at the one write path that lacked it, the red-proof is executed, nothing is above Low. The three Lows (per-request snapshot, docs wording, multi-chunk probe) and the #55 sentence are follow-ups, not merge conditions.
- Hoist
classifySetbefore the chunk loop. - Reword the chunk-row sentence in
docs/api.md; add ingest to the classify-site list in #55. - Add a multi-chunk probe.
8. Not verified / questions for the author
- Cost of the hook UPDATE itself (not HOT, seven indexes on
type_name, two triggers) is a property of the existing hook on all nine sites, not measured here. [hypothesis]the mount probe seeds its tenant rule by raw SQL at the home scope; whether thetype-*manage API produces a row the same resolution path sees was not checked (the body calls it out of scope).
…top_grace_period (#71) Wave W06-1 of the OpenAI-compatible API plan (RC-A, telemetry foundation; design/06 §7 block A). No `/v1` surface, no migration. ## What this changes The `context_llm_log` write path started one goroutine and one pool connection per row, logged failures at DEBUG only, and ctxd closed its pool on shutdown without waiting for pending inserts; the `ctx` compose service had no `stop_grace_period`, so Docker's 10 s default could SIGKILL the daemon inside its 15 s HTTP drain. - `internal/llmlog`: per-pool bounded writer queue — at most 4 inserts run at once, at most 4 096 rows wait; beyond that `Record` drops and counts the row. Failed inserts are counted; one WARN per minute at most, carrying the totals since start (the per-failure DEBUG line stays). New readers `InsertFailures()` / `InsertDropped()`. `Record` stays non-blocking; `Settle` now also waits for queued rows that have not started. - `cmd/ctxd`: the teardown is `shutdownSequence`: `srv.Shutdown` (15 s) → `llmlog.Settle` (5 s) → `scheduler.Wait` → `llmlog.Settle` (5 s) → pool close. Settling twice is deliberate: a running dream cycle does not stop on SIGTERM (own context, `dream.cycle_timeout`, 700 s by default), so a settle only after `Wait` would hand the HTTP rows to the SIGKILL that ends an over-long wait. - `internal/handler`: server-admin-only `/api/status` section `"llmlog": {"insert_failures", "insert_dropped"}`, present at 0/0, absent on the tenant path and on the SSE frame. - `docker-compose.yml`: `stop_grace_period: 30s` on `ctx`, pinned ≥ 25 s by `internal/config` `TestComposeCtxStopGracePeriod` (scanner follows the top-level section, so only `services.ctx` counts). - `docs/operations.md`: shutdown / write-path section and an upgrading note for operators with their own compose file; `docs/api.md`: the new status section. ## Behaviour changes (intended) - A burst of LLM calls no longer takes the whole 20-connection pool for its telemetry rows (before: 20 of 20 under a 200-row burst; now 4). - Rows beyond 4 096 queued are dropped and counted instead of queued without bound. - Shutdown waits up to 2 × 5 s longer; rows still pending after that are lost and logged as such. - Operators running ctx from their own compose file should set `stop_grace_period: 30s` (upgrading note). ## Consumers of the changed contracts | Changed symbol | Callers / readers | Covered by | |---|---|---| | `llmlog.Record` (same signature, queued) | every pipeline that writes telemetry (`chat/engine.go` `recordEntry`, `llm/*`, `dream/*`, `digest`, `events/audit.go`, `events/scheduler.go` backfill, handlers) | `TestRecordInsertConcurrencyBounded`, `TestRecordQueueBoundDropsAndCounts`, `TestRecordInsertSlotsBounded`, `TestRecordAfterSettleAndClose`, existing `TestChatQuota`, `TestDistillExtract` | | `llmlog.Settle` (now waits for queued rows too) | `cmd/ctxd` shutdown (new), `chat_quota_integration_test.go`, `distill_extract_integration_test.go` | `TestSettle`, `TestSettleLLMLogRespectsTimeout` | | `llmlog.InsertFailures/InsertDropped` (new) | `handler/status.go`, `cmd/ctxd/main.go` log line | `TestInsertFailureCountedAndWarnThrottled`, `TestStatusLLMLogSection`, `TestStatusCollectorWiresLLMLogCounters` | | `statusResponse.LLMLog` (new key) | `/api/status` admin path; golden key set | `TestStatusGoldenKeys`, `TestStatusPerTenantView`, `TestStatusEventCarriesEveryDualPathField` | | `shutdownSequence` (extracted) | `main.go` | `TestShutdownWithoutSettleLosesRows`, `TestShutdownSequenceSettlesLLMLog`, `TestShutdownSettlesHTTPRowsBeforeSchedulerWait` | ## Overlap search Searched open PRs #55/#57/#63/#67 for shared paths on 2026-11-03: none (`docs/operations.md` is touched by #63 in a different section — whichever lands second rebases trivially). #69 (merged `16fe6436`) touched ingest only; this branch is rebased on it. ## Cost Per LLM call: one mutex lock on enqueue instead of one goroutine spawn; inserts run on ≤ 4 connections instead of ≤ 20. Measured by the reviewer's throughput probe: 4 096 real rows in 327 ms (0.32 ms per row and slot), so a 5 s settle covers a full queue on a healthy database; the break-even is 4.9 ms per insert. Status: the two counters are process memory, read per call, no I/O. ## Verification Gates, each red on `7f17de21` first: - (a) failure counter + throttled WARN + status field: red = compile errors (`InsertFailures`, `LLMLog` undefined; failures were DEBUG only) → `DROP TABLE context_llm_log` makes `InsertFailures()` +1; 100 failures → exactly 1 WARN, the next after the interval with total 101; `llmlog.insert_failures` 1 on the admin path, absent for tenants. - (b) 200 simultaneous `Record` against a 50 ms insert delay (pgx tracer, MaxConns 20): peak concurrent inserts 20 → 4, rows 200/200. - (c) 50 `Record` + slow inserts, then the shutdown sequence: old order lands 0/50; `shutdownSequence` lands 50/50 plus 10/10 written during `scheduler.Wait`. Hardening: HTTP rows landed at `Wait` entry 0/50 → 50/50. - (d) compose pin: red "declares no stop_grace_period" → 30s; scanner pin 3/6 → 6/6 on the shapes that fooled the first version. `go/deadcode-testonly-allow.txt`: the `llmlog.Settle` line is removed — it recorded Settle as reachable from tests only, and this PR gives it its production caller; `deadcode.sh testonly` was red on the first push for exactly that line and is green without it (third commit). Suites on the rebased head: `go build ./...`, `go vet -tags=integration ./...`, `golangci-lint run` with and without `--build-tags=integration` (0), `go test -short -race ./...` (65 ok), integration `-p 1` for `cmd/ctxd`, `internal/config`, `internal/llmlog`, `internal/handler` (full handler suite on the first commit). Independent review by a fresh agent: stress test with `-race` at GOMAXPROCS 1/4/16 (50 × 1 000 `Record`, 10 % injected failures, 3 concurrent `Settle` callers): landed + failed + dropped == sent in all three runs, `pending` 0 after `Settle`, `running` ≤ 4, queue ≤ 4 096; lost-wakeup probe 160 000/160 000 rows, 0 settle timeouts. ## Not verified - Frequency of shutdowns that coincide with a dream cycle longer than the grace period on the live host (the second settle is lost in that case; the first still lands the HTTP rows). - `scheduler.Wait` covers the dream workers only; `runSensitivityAudit` (`events/audit.go:124`) and the dream-loop backfill (`scheduler.go:1832`) can still `Record` after `Wait` returns — those rows are covered by the second settle only if they are queued before it runs. Candidate for the W06-13 force-settle wave. - `ctxServiceEnvValues` in `compose_surface_test.go` scans the same way the first version of this gate did (no `services:` check); left as is, noted for a later sweep. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Server-admin status now reports LLM log insert failures and dropped entries, including when both counts are zero. Tenant status does not include these process-wide counters. * **Improvements** * LLM log writes are queued with bounded capacity and concurrency; failures and dropped entries are counted. * Shutdown allows pending LLM log entries additional time to finish saving. The Docker shutdown grace period is set to 30 seconds. * **Documentation** * Added guidance on LLM log handling during shutdown and upgrades, including the new status counters. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Plan corpus of the OpenAI-API build: LOOP.md STATE through W06-6, briefings W06-1…W06-8, build reports W06-1…W06-6, the RC-A tag annotation draft and the two review dossiers (GottZ#69, hermes-agent #147997) of 2026-11-03. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…batch loop (#130) Fixes #70 (follow-ups from #69). HandleIngest resolves the block-type snapshot once per request, after the batch gates and the rate limit, and passes it into processChunk — on the registry's miss path one WARN per request instead of up to 200, and one rule generation per batch. docs/api.md: "Chunk-mode writes do not run the classifier" (a block-mode write with new content onto a chunk row's identity lands on that row and classifies it; probed); docs/ctx-as-agent-memory.md lists block-mode ingest. Tests: TestIngestClassifySnapshotOncePerRequest (undecodable tenant row forces the miss path; 3 chunks → 1 WARN, still 1 after a 429), multi-chunk and mixed block/chunk-mode probes in internal/handler and cmd/ctxd (the index == 0 mutant passed all nine old subtests and fails the four new ones). Red: 3 WARN want 1; green: handler 9/9 + snapshot test, ctxd 4/4; 9/9 mutants red; suites: -race 70 ok, lint 0/0, deadcode ok, full integration handler + ctxd ok. Review (Opus): approve, F1/F4–F6 applied, F3 declined with reason. Follow-up: /api/store resolves the set twice per request (#131). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixes #61
What
POST /api/ingest in block mode (no
source_id) now runsstore.ClassifyBlockAfterUpsertafter itsUpsertBlock, with the caller'stenant-resolved block-type set (
SnapshotForRequest), on the block's titleand metadata — the same hook POST /api/store, MCP store and the update paths
run.
NewIngestHandlertakes the block-type registry;server.gopasses thesame
blocktypeRegthe store handler gets.Why
Block-mode ingest wrote through
UpsertBlockand returned without the hook,so an ingested block kept the row default
(knowledge, auto)even when itstitle or metadata matched a registry classify rule. The shipped
ctx ingestclient sends no
source_id, so all of its writes took this path.Behaviour kept as on every other hook site:
type_source='manual'rows keeptheir type, derived types are not granted (I7), and a failed classify is
logged at WARN while the chunk still reports
updatedand the block keepsthe type it already has. Chunk mode (
InsertChunk,lifecycle_state='chunk')is unchanged and not classified. A re-ingest of identical content is a hash
no-op, so blocks ingested before this fix are not reclassified by it (no
backfill).
Docs: the
/api/ingestrow in docs/api.md, the classify-site list indocs/architecture.md and the
ClassifyBlockAfterUpsertcomment (now all ninecall sites from
grep -rn 'ClassifyBlockAfterUpsert(' --include='*.go').Tests
internal/handlerTestIngestBlockModeClassifies: title rule →(audit-trail, auto); metadata-only rule
{"is_meta": true}→(system-meta, auto); manual type kept; chunk mode stays (knowledge, auto);
no rule keeps the default; derived type not granted; classify failure keeps
the write (
success: true,updated).cmd/ctxdTestIngestMountClassifies_IntegrationthroughNewRouter:a
_globalrule and a rule only the key's own tenant scope has(covers the
server.gowiring andSnapshotForRequest).Both probes assert
type_nameandtype_source;type_sourcealone cannotshow the bug.
Red proof
Each mutation of the fix alone (handler probe / mount probe exit):
Snapshot()instead ofSnapshotForRequestInsertChunkNewIngestHandlerparameter).Gates (go1.27.3)
go build ./...,go vet ./...,go vet -tags=integration ./...: exit 0gofmt -lon changed files: emptygolangci-lint run --timeout=10m(v2.15.0): 0 issuesgo test -short -race ./...: 65/65 packages okgo test -tags=integration -count=1 -p 1 ./internal/handler/... ./cmd/ctxd/...: exit 0bash deadcode.sh,bash deadcode.sh testonly: okCGO_ENABLED=0build + vet for windows/amd64 and darwin/arm64: exit 0Out of scope (follow-ups)
StoreHandler.classifySethas the same untestedSnapshot()vsSnapshotForRequestgap on /api/store.scope = ar.TenantID(types_write.go
typeWriteScope) but resolved byar.HomeScope(
RequestTenantScope→buildTenantSet), so API-created tenant types arenot seen at request time.
🤖 Generated with Claude Code
https://claude.ai/code/session_01S3bwgEeYiQ8cXWgF7FN9J1
Summary by CodeRabbit
New Features
Documentation