Skip to content

fix(ingest): classify block-mode ingest writes like /api/store - #69

Merged
GottZ merged 1 commit into
rootfrom
fix/61-ingest-classify
Nov 3, 2026
Merged

GottZ merged 1 commit into
rootfrom
fix/61-ingest-classify

Conversation

@DamieMoon

@DamieMoon DamieMoon commented Nov 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #61

What

POST /api/ingest in block mode (no source_id) now runs
store.ClassifyBlockAfterUpsert after its UpsertBlock, with the caller's
tenant-resolved block-type set (SnapshotForRequest), on the block's title
and metadata — the same hook POST /api/store, MCP store and the update paths
run. NewIngestHandler takes the block-type registry; server.go passes the
same blocktypeReg the store handler gets.

Why

Block-mode ingest wrote through UpsertBlock and returned without the hook,
so an ingested block kept the row default (knowledge, auto) even when its
title or metadata matched a registry classify rule. The shipped ctx ingest
client sends no source_id, so all of its writes took this path.

Behaviour kept as on every other hook site: type_source='manual' rows keep
their type, derived types are not granted (I7), and a failed classify is
logged at WARN while the chunk still reports updated and the block keeps
the 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/ingest row in docs/api.md, the classify-site list in
docs/architecture.md and the ClassifyBlockAfterUpsert comment (now all nine
call sites from grep -rn 'ClassifyBlockAfterUpsert(' --include='*.go').

Tests

  • internal/handler TestIngestBlockModeClassifies: 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/ctxd TestIngestMountClassifies_Integration through NewRouter:
    a _global rule and a rule only the key's own tenant scope has
    (covers the server.go wiring and SnapshotForRequest).
    Both probes assert type_name and type_source; type_source alone cannot
    show the bug.

Red proof

Each mutation of the fix alone (handler probe / mount probe exit):

mutant handler mount
hook call removed (base behaviour) 1 1
nil set at the call 1 1
nil registry in server.go 0 1
nil metadata at the call 1 0
Snapshot() instead of SnapshotForRequest 0 1
hook also after InsertChunk 1 0
classify error fails the chunk 1 0
On base 7f17de2 the mount probe compiles and fails (both subtests read
(knowledge, auto)); the handler probe does not compile there (new
NewIngestHandler parameter).

Gates (go1.27.3)

  • go build ./..., go vet ./..., go vet -tags=integration ./...: exit 0
  • gofmt -l on changed files: empty
  • golangci-lint run --timeout=10m (v2.15.0): 0 issues
  • go test -short -race ./...: 65/65 packages ok
  • go test -tags=integration -count=1 -p 1 ./internal/handler/... ./cmd/ctxd/...: exit 0
  • bash deadcode.sh, bash deadcode.sh testonly: ok
  • CGO_ENABLED=0 build + vet for windows/amd64 and darwin/arm64: exit 0

Out of scope (follow-ups)

  • StoreHandler.classifySet has the same untested Snapshot() vs
    SnapshotForRequest gap on /api/store.
  • Tenant block types are written at scope = ar.TenantID
    (types_write.go typeWriteScope) but resolved by ar.HomeScope
    (RequestTenantScope → buildTenantSet), so API-created tenant types are
    not seen at request time.

🤖 Generated with Claude Code

https://claude.ai/code/session_01S3bwgEeYiQ8cXWgF7FN9J1

Summary by CodeRabbit

  • New Features

    • Block-mode ingestion now automatically classifies blocks using their titles and metadata. Manually assigned types remain unchanged; chunk-mode ingestion retains its existing type behavior.
    • Classification issues do not prevent an otherwise successful ingest.
  • Documentation

    • Updated API and architecture documentation to describe block-type classification across supported write paths.

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
@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Walkthrough

Block-mode /api/ingest now attempts automatic type classification after a successful upsert. The handler receives a block-type registry. Integration tests cover classification behavior, and the API and architecture documentation describe the paths.

Changes: Block-mode ingest classification

Layer / File(s) Summary
Registry wiring and classification flow
go/internal/handler/ingest.go, go/cmd/ctxd/server.go, go/internal/handler/derived_write_lock*_integration_test.go, go/internal/store/classify.go, docs/api.md, docs/architecture.md
The handler uses a registry snapshot to classify block-mode upserts. Classification errors are logged without changing the successful write result. Chunk-mode inserts do not enter the classification path. The router supplies the registry, and the documentation describes the classification behavior and paths.
Classification integration coverage
go/internal/handler/ingest_classify_integration_test.go, go/internal/handler/ingest_scope_integration_test.go, go/cmd/ctxd/ingest_classify_integration_test.go
Integration tests cover title and metadata rules, tenant rules, manual types, chunk-mode writes, unmatched rules, derived types, classification failures, and the production router.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Possibly related issues

  • Issue 61: Block-mode /api/ingest now calls ClassifyBlockAfterUpsert and tests the resulting type source.

Suggested reviewers: gottz

Merge Risk: Minimal · up to c94ee

Block-mode ingest gains classification without changing identical-content re-ingests or chunk-mode writes. No merge-blocking issue was identified.

Security architecture risk: Low · up to c94ee

The change reuses existing classification rules without widening the inspected write permissions or tenant scope. Manual type choices and protected derived types remain guarded. Residual risk concerns classification being best-effort rather than atomic with the write.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • observed — The inspected path requires an authenticated caller and writes using the caller's home scope, with block identity determined by category, title, and scope. The new classifier receives the ID returned by that upsert rather than a client-selected classification target.

Security Findings and Attack Paths

  • observed — The concrete title-to-derived-type authority path is blocked: the classifier refuses derived names before issuing SQL. It also predicates its UPDATE on type_source='auto', protecting manual assignments even when classification follows a concurrent manual change.

Trust Boundaries and Controls

  • observed — Caller-controlled title and metadata feed rule matching, but tenant policy selection comes from authenticated request context. Production wiring installs the request-scope resolver, and the ingest handler uses SnapshotForRequest rather than accepting a policy scope from the body.

Resilience and Maintainability Implications

  • inferred — Classification failure preserves write availability but does not guarantee the intended type policy has taken effect. The inspected manual and derived authority controls remain effective independently; automatic classification should not be treated as a transactional security barrier.
Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: block-mode ingest writes now classify like /api/store.
Linked Issues check Passed Issue #61 requires classification after block-mode UpsertBlock, or documentation of intentional non-classification, plus a test for type_source. processChunk now calls `store.ClassifyBlockAfterU…
Out of Scope Changes check Passed The changed files support Issue #61. The handler and server changes implement and wire classification. The handler and mount tests verify the behavior and request-scoped registry wiring. The documenta…
Full details: Linked Issues check

Explanation

Issue #61 requires classification after block-mode UpsertBlock, or documentation of intentional non-classification, plus a test for type_source. processChunk now calls store.ClassifyBlockAfterUpsert after a successful block-mode upsert. It passes h.classifySet(ctx), block.ID, block.Title, and block.Metadata. classifySet uses SnapshotForRequest. The handler logs classification errors and keeps the write result. Chunk mode does not call the hook. server.go passes the shared blocktypeReg to NewIngestHandler. The added handler and mount integration tests assert classified type_name and type_source, including manual-type preservation and chunk-mode behavior.

Full details: Out of Scope Changes check

Explanation

The changed files support Issue #61. The handler and server changes implement and wire classification. The handler and mount tests verify the behavior and request-scoped registry wiring. The documentation and classifier comment describe the new ingest behavior and call site. Constructor updates keep existing tests and helpers valid. No demonstrated unrelated implementation change is present.

Full details: Docstring Coverage

Explanation

Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 7 functions across 8 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate docstrings
  • Commit to this branch
  • Create a new PR
Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.
Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 79af832a-7005-4e0e-a91f-ab0dfdc44f54
Commits

Reviewing files that changed from the base of the PR and between 7f17de2 and c94ee7a.

Files selected for processing: 10
  • docs/api.md
  • docs/architecture.md
  • go/cmd/ctxd/ingest_classify_integration_test.go
  • go/cmd/ctxd/server.go
  • go/internal/handler/derived_write_lock_integration_test.go
  • go/internal/handler/derived_write_lock_r2_integration_test.go
  • go/internal/handler/ingest.go
  • go/internal/handler/ingest_classify_integration_test.go
  • go/internal/handler/ingest_scope_integration_test.go
  • go/internal/store/classify.go

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

@GottZ GottZ left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 path go/internal/blocktype/registry.go:175-206
  • Entry point: POST /api/ingest with up to maxIngestChunks = 200 block-mode chunks.
  • Before → after: on a cache hit SnapshotForRequest is 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/store produces 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 in HandleIngest before the chunk loop and pass it into processChunk (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 existing InsertChunk row.
  • 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/store before 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 == 0 passes 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 ingest always writes in block mode (cli/ingest.go:204-211 sends no source_id), so every vault note now goes through the title/metadata classifier. With the built-in rules a note whose title contains session, performance, reset, baseline, … becomes audit-trail (damped in retrieval); a title starting with "Compaction source" / "Compaction checkpoint head" becomes checkpoint (retrieval-excluded). That is the point of #61 and the same policy /api/store applies; 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.

  1. Hoist classifySet before the chunk loop.
  2. Reword the chunk-row sentence in docs/api.md; add ingest to the classify-site list in #55.
  3. 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 the type-* manage API produces a row the same resolution path sees was not checked (the body calls it out of scope).

@GottZ
GottZ merged commit 16fe643 into root Nov 3, 2026
22 checks passed
@GottZ
GottZ deleted the fix/61-ingest-classify branch November 3, 2026 07:38
GottZ added a commit that referenced this pull request Nov 3, 2026
…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 -->
W4-NERF pushed a commit to W4-NERF/ctx that referenced this pull request Nov 3, 2026
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>
GottZ added a commit that referenced this pull request Nov 4, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(ingest): block-mode ingest never classifies the upserted block (no ClassifyBlockAfterUpsert call)

2 participants