Skip to content

OTEL telemetry attach replaces proxy capture for Claude Code (LLP 0262) - #818

Merged
philcunliffe merged 46 commits into
masterfrom
otel-attach-0245
Aug 18, 2026
Merged

OTEL telemetry attach replaces proxy capture for Claude Code (LLP 0262)#818
philcunliffe merged 46 commits into
masterfrom
otel-attach-0245

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Claude Code now reports to HypAware through its own OpenTelemetry export instead of through a man-in-the-middle proxy: hyp attach claude writes a telemetry env block into the client's settings file (behind a version floor), the @hypaware/claude plugin hosts an OTLP http/json listener that ingests those events, a capped and swept body spool fills the projection gaps the events themselves do not carry, and behavioral events land in a new claude_telemetry_events dataset. The privacy seams follow the traffic: the folder usage policy and session ignore both decide at ingest on the OTEL path, and hyp purge and hyp detach empty the raw-body spool. An attached machine migrates from proxy to OTEL in one command, hyp status names the attach mode and renders a capture-health line, and the docs and a release-gate shape check describe the attach Claude Code actually gets. Design record: llp/0262-otel-attach-replaces-proxy.rfc.md (renumbered from 0245 on 2026-08-17; the number was ceded to PR #815's design doc) plus decisions LLP 0252-0256, LLP 0258, and spec LLP 0257 - all Accepted by the author on 2026-08-17.

Tickets

  • OTEL attach LLP groundwork: accept RFC 0245, mint decision and spec LLPs #801 OTEL attach LLP groundwork: accept RFC 0245, mint decision and spec LLPs - done: the human gates closed 2026-08-17 (renumber + acceptance, see the last three commits)
  • Prefactor: shared OTLP http/json server machinery #802 Prefactor: shared OTLP http/json server machinery - done, verified
    • bf8b0c97 The OTLP http/json listener becomes shared core machinery (LLP 0257)
  • Tracer bullet: claude OTLP listener captures a session into ai_gateway_messages #803 Tracer bullet: claude OTLP listener captures a session into ai_gateway_messages - done, verified
    • 922ccbd2 A live producer that is not the proxy records through the gateway (LLP 0252)
    • 7cbfca81 The claude plugin hosts a listener for Claude Code's own telemetry (LLP 0257)
    • ac545045 A hermetic smoke drives the telemetry listener end to end (LLP 0257)
  • Body spool: system text, tools, untruncated args, cap and eviction #804 Body spool: system text, tools, untruncated args, cap and eviction - done, verified
    • 2beae929 Spooled body files fill the projection gaps events never carry (LLP 0252)
    • 9b00bb11 The daemon caps the body spool and the listener consumes it (LLP 0253)
    • 4ea1bd1f The telemetry smoke joins body fixtures and recovers an evicted session (LLP 0257)
  • claude_telemetry_events dataset for behavioral events #805 claude_telemetry_events dataset for behavioral events - done, verified
    • 41392189 Behavioral events land in their own claude_telemetry_events dataset (LLP 0255)
    • 1f7eb092 The telemetry smoke reads behavioral events back from claude_telemetry_events (LLP 0255)
  • OTEL attach and detach mode for the claude client, with version floor #806 OTEL attach and detach mode for the claude client, with version floor - done, verified
    • 2e2686fd The claude settings writer gains an otel mode behind a version floor (LLP 0258)
    • 3282bf5d hyp attach claude writes the telemetry env block in otel mode (LLP 0258)
  • Proxy-to-OTEL migration for attached machines #807 Proxy-to-OTEL migration for attached machines - done, verified
    • 6149abcd hyp attach claude migrates a proxy attach to otel in one command (LLP 0245)
    • 3fdb53a9 hyp status names the attach mode on the text surface (LLP 0245)
    • 918d0f1a The migration overlap window collapses to one row set (LLP 0245)
    • 48152ab7 The migration's CA offer stops claiming a trust it never verified (LLP 0245)
    • b13c9814 hyp status proves the migrated mode through the real collector (LLP 0245)
  • Privacy seams on the OTEL path: inline policy drop, spool deletion, purge and detach sweep #808 Privacy seams on the OTEL path: inline policy drop, spool deletion, purge and detach sweep - done, verified
    • 83d75c4d hyp purge and detach empty the raw-body spool (LLP 0253)
    • 854764b9 The folder usage policy decides at ingest on the OTEL path (LLP 0254)
    • 06127359 A privacy smoke drives .hypignore through the telemetry listener (LLP 0257 S25)
  • Session ignore control route on the claude listener #809 Session ignore control route on the claude listener - done, verified
    • a812d366 The session-ignore control handler becomes shared core machinery (LLP 0256)
    • 154e134b The claude listener hosts session ignore and drops the session at ingest (LLP 0256)
    • f4dc24ca hyp session ignore posts to every recorder that offers the route (LLP 0256)
    • f574b3f4 A privacy smoke drives session ignore through both recorders (LLP 0257 S25)
  • Capture-health line in hyp status #810 Capture-health line in hyp status - done, verified
    • 57a63cb7 hyp status renders a capture-health line for otel-attached clients (LLP 0257 S17)
    • 70e77f2b A capture-health smoke drives hyp status through lockstep, gap, and detached (LLP 0257 S17)
  • Docs and release gate for OTEL attach #811 Docs and release gate for OTEL attach - done, verified
    • 0356d959 The glossary stops saying attach rewrites a base URL (LLP 0258)
    • 512b176f A release gate checks the OTEL shapes against the installed Claude Code
    • 721bb7b8 The README describes the attach Claude Code actually gets (LLP 0245)
    • ba60e709 The shape check waits out an export interval before calling it broken

Verification

Head is now b8ea9813; the three commits after ba60e709 touch only LLP docs and code comments (@ref targets), and the unit suite was re-run green at b8ea9813 (4376 pass, 0 fail, 2 skipped). The battery below ran at ba60e709:

  • Unit suite green: 4376 pass, 0 fail, 2 skipped (HYP_HOME="$(mktemp -d)" mise exec node@22.22.0 -- npm test).
  • npm run typecheck clean.
  • Release smoke battery green, all 14: package_bin_boot, cli_bundled_plugins_activated, daemon_foreground_start_stop, daemon_install_render, walkthrough_picker_to_first_query, client_attach_idempotent, gateway_claude_capture, gateway_codex_capture, hypignore_capture_drop, local_only_export_withhold, source_optout_export_withhold, otel_loopback_capture, local_parquet_export, status_diagnostics.
  • Smokes added on this branch green: claude_telemetry_capture, claude_telemetry_hypignore_drop, claude_telemetry_session_ignore, status_capture_health.
  • Adjacent flows the diff touches, also green: claude_attach_detach, client_attach_on_join, otel_listener_writes_rows, purge_removes_cached_rows, session_optout_capture_drop, cache_spool_batching.

Known, not ours

  • local_only_query_withhold and walkthrough_to_first_query fail on this branch, and they fail identically on origin/master (checked in a throwaway worktree at master: same two assertions, cli: hyp ignore --local-only confirms the added directory and config: golden v2 shape matches the preset output). Neither flow nor the code under its failing assertion is touched here, and neither is in the release battery. Pre-existing red, worth its own bug.
  • Test isolation leak on master: several tests pass no env, so ai-gateway's launchListener resolves its state root from the real ~/.hyp. On a machine with a local CA from a past proxy attach that reds 6 tests in test/core/status-gateway-idle.test.js and test/plugins/ai-gateway-source.test.js that have nothing to do with this change. Running with a hermetic HYP_HOME makes the suite green. Real defect, but not this change set's ticket, so it is untouched here and deserves its own bug.

Outstanding human gates

Spec: #798

🤖 Generated with Claude Code

philcunliffe and others added 8 commits August 17, 2026 14:16
The deliberation record for the change set tracked by #798.
Committed unchanged as the branch's design record; status stays Draft because
acceptance is the author's call, not a pipeline step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One narrow decision per settled choice, so implementation code can cite the
choice it realizes instead of restating the reasoning: the settings env block
as the injection surface, the events-first plus bodies-for-gaps split, the
spool's cap and sweep duties, ingest-time settlement in place of the flush-time
pass, the claude_telemetry_events dataset, and the session-ignore control route
on the listener.

All six are Draft. RFC 0245 gains a Spawns line naming them; nothing here flips
a status, because acceptance is the author's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The requirements the listener implementation and its tests answer to: what the
source registers, what its endpoint accepts, how events and body files combine
at ingest, the two datasets it writes, its status and capture-health duties,
its failure modes, and the seams its tests use. Composes decisions 0251-0256
and cites them rather than repeating why.

Draft, like the RFC that spawns it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical forward refs only, on the parts the claude client's mode switch
displaces: the RFC (0231), the proxy attach keys (0232), the CA lifecycle and
trust (0235, 0237, 0238), the launchd environment delivery (0239), and the
fresh-install and migration path (0242, 0243, 0244). Each is phrased "on
acceptance of 0245" so it stays honest while 0245 is Draft, and each says what
survives for the clients still routed through the proxy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The header Spawns line already listed LLP 0251 to 0256 plus 0257, but the
prose "On acceptance" section still enumerated only five choices and left out
session-ignore transport (LLP 0256), the choice open question 4 in the same
file resolves. Name each decision next to its number so the two lists agree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The seven new docs each carried a Spawned-by header field that appears nowhere
else in the corpus. The corpus already has a way to say this: LLP 0168, spawned
by RFC 0167, glosses its parent inside Related ("LLP 0167 (the accepted RFC
this decision realizes)"), and the parent RFC carries the one-way Spawns line.
Fold the same gloss into Related here and drop the field. LLP 0256 keeps the
"settled in #798" fact on a Tracker line, which LLP 0245 and LLP 0257 already
use.

No information is lost: every doc already listed LLP 0245 in Related, and the
Spawns line on 0245 records that all seven stay Draft until it is accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The number was taken on origin/integration/proxy-mode-default-attach
(llp/0251-proxy-mode-default-attach.design.md, Status Active), so whichever
branch merged second would have gone red on the uniqueness check. 0258 is the
next number free across master and every remote branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A second plugin is about to host an OTLP listener of its own, so the
transport half of @hypaware/otel's receiver moves to src/core/otlp:
signal routing, json-only content-type enforcement, gzip and deflate
decoding, the per-signal partialSuccess envelopes, and bind-and-resolve.
The shared server never looks inside `data`, so payload interpretation
stays with whichever plugin hosts the listener.

Pure prefactor. @hypaware/otel keeps its banner text, its bind error
prefix, its config keys, its wire responses and its self-telemetry loop
guard, which never left the collector. The one new option, a signal
subset, defaults to all three signals, so today's listener is unchanged.

The transport contract is now pinned by test/core/otlp-json-server.test.js
rather than only by the two otel smokes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe and others added 8 commits August 17, 2026 15:38
…P 0252)

`ai_gateway_messages` is about to have a third producer, so the gateway
capability grows the one call such a producer needs:
`recordProjectedExchange`. It takes a finished
`AiGatewayProjectedExchange` and owns everything after it - the shared
row expansion, `part_id` identity, the schema strip, the table path -
so an OTEL-projected row cannot drift from the proxy's row for the same
content.

The write is preceded by a pre-write `part_id` dedupe seeded from both
committed partitions and the spool, restricted to the batch in hand.
That is what makes producer overlap harmless: a part the proxy or a
backfill run already stored is skipped, not appended a second time. The
spool scan is the same one backfill does and stays forbidden on the
flush path, where the rows being tested ARE the spool.

Purely additive on the capability surface, so every adapter's `^2.0.0`
requirement still resolves and the proxy recorder's path is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…LP 0257)

`@hypaware/claude` contributes a `claude-telemetry` source through the
kernel source registry: an OTLP http/json listener on loopback, on its
own port (default 4319, config `claude.telemetry.listen_port`, `0` for
dynamic), built on the shared core server so the transport is not
copied. Only the logs and metrics routes are served; metrics are
accepted and dropped so the exporter is not left retrying a 404, and a
non-json content type is refused exactly as the otel receiver refuses
it.

Payload interpretation is claude-owned. `user_prompt` and
`assistant_response` each carry their own `message.uuid`, so they
project once, with native identity, and no settlement enricher has
anything to repair. `api_request` carries no content and no uuid: it is
the usage record for the `request_id` an assistant response names, and
is folded onto that message's `attributes.usage` in the same shape the
proxy path writes. The index that holds it spans batches, because the
exporter flushes on a timer, and evicts oldest-first at a cap.

Rows go out through the gateway's `recordProjectedExchange`, so OTEL is
a third producer of `ai_gateway_messages`, not a new table. cwd and git
identity still come from the SessionStart hook record; `query_source`
and `agent.name` carry attribution, and parent_uuid,
logical_parent_uuid, user_type and permission_mode read null by design.

The daemon's own exports are dropped by resource marker, and another
exporter that finds the port is ignored by scope rather than
half-parsed. Registration is feature-detected against the capability, so
an older gateway degrades to "no listener" instead of failing boot, and
the source is registered rather than auto-started, so a CLI activation
never binds a port.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Content in at the HTTP endpoint, rows out of `hyp query sql`: the smoke
activates the gateway and the claude plugin in a temp HypAware home,
drives the SessionStart hook, starts the listener on a dynamic port, and
POSTs one real-shaped Claude Code batch (the three content events plus
two behavioral ones the listener does not model yet, so skipping them is
proven rather than assumed).

It then asserts the rows: native uuid identity, prompt and response
text, model, the usage the `api_request` event carried, the cwd the hook
recorded, the OTEL producer marker, and the transcript-only columns
reading null. A replayed batch adds nothing. `hyp backfill claude` over
a transcript carrying the same uuids also adds nothing, which is the
proxy-to-OTEL overlap window in miniature. A `text/plain` POST is
refused with 415. The capture spans and the batch log carry the counts
that prove the intended path ran.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(LLP 0258)

A third attach mode beside base_url and proxy: `otel` merges the LLP 0258
#env-keys telemetry block into the settings env block and writes nothing
that routes traffic, so the endpoint stays first party and Remote Control
needs no override keys. The mode reuses the whole marker machinery
unchanged (managed env, prev_env backups, atomic mtime-gated writes, JSONC
refusal, releaseUnmanagedKeys for mode switches), records the spool
directory on the marker for detach and purge to sweep (LLP 0258
#marker-and-spool, LLP 0253), and refuses below Claude Code 2.1.193 with a
`claude update` hint, leaving any existing attach byte-identical (LLP 0258
#version-floor, no fallback to any other mode).

claude_version.js owns the floor: numeric triple parse and compare
("2.1.193" must not sort below "2.1.9"), unknown-is-not-old, a best-effort
`claude --version` probe, and the HYP_CLAUDE_CODE_VERSION override that
keeps hermetic smokes independent of whatever binary the machine carries.
telemetry/spool.js owns the spool path contract: <hyp-home>/spool/
claude-bodies, created 0700 (LLP 0253 #spool-location).

Adopted from the crash-debris-806 stash after review; the malformed mode
doc block in types.d.ts is repaired.

Part of #806 (spec #798, RFC LLP 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The adapter's attach handler switches its non-proxy branch from base_url
to otel: with no local CA (no proxy-mode gateway) it probes the Claude
Code version, resolves the listener port, and hands the writer the otel
mode. The spool directory is created owner-only right after the settings
write. A CA-bearing install still attaches by proxy; migrating it to otel
is ticket #807.

The endpoint's port resolves in trust order: the live daemon's bound port
from status.json (pid-gated, via the new core
resolveLiveSourceListenPortFromStatus, the generic sibling of the gateway
endpoint resolver), then a configured fixed telemetry.listen_port, then
the well-known default. That is the promise that makes the listener's
default-port bind fallback safe. The marker keeps recording the gateway
port, so the attach-drift check is unchanged.

Smokes pin the new surface: claude_attach_detach golden-compares the
exact nine-key env block, the absence of every routing key (the Remote
Control predicate as absences), the marker's mode and spool_dir, and the
0700 spool; client_attach_idempotent adds a below-floor refusal leg
(exit 1, `claude update` hint, settings byte-identical) beside the
attach/detach cycles; client_attach_on_join proves the daemon's org-driven
attach delivers the same block and that drift now shows in the marker
port; the walkthrough asserts init's real attach lands otel. All four pin
HYP_CLAUDE_CODE_VERSION so no flow depends on the machine's own claude.

Closes #806 (spec #798, RFC LLP 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…252)

Body events (api_request_body / api_response_body) join the projection
through their body_ref: the request body supplies system_text, the tools
list, and canonical message ordering; gap blocks (tool_use with
untruncated args, tool_result, thinking with its signature) become their
own projected messages with a minimized pointer-only raw_frame, matching
the proxy path's excerpt policy. Text blocks are never re-projected: the
events already delivered them once under native uuid identity. A ref
outside the spool is refused (the listener deletes what it reads, so an
uncontained ref would be a read-and-delete primitive over the whole
filesystem), a missing body counts as evicted, and an unparseable one is
deleted rather than retried forever. Session-level body facts carry
across exporter batches, bounded oldest-first.

Ticket: #804 (spec #798, RFC 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The listener repairs the spool to owner-only on every start, enforces
the byte cap (telemetry.spool_max_bytes, default 512 MB) at start and on
a timer, and evicts strictly oldest-first (mtime, then name) when the
cap is exceeded: the sweep exists exactly for the window where Claude
Code writes bodies and nothing consumes them. At ingest the referenced
bodies are read, projected, and deleted only after the dataset write
succeeded, so a failed write becomes an HTTP error the exporter retries
against the same files. Status details gain the spool byte size and the
projected / evicted / missing body counts, and eviction is logged with a
count so a machine routinely losing detail to backfill is visible.

Ticket: #804 (spec #798, RFC 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on (LLP 0257)

The hermetic flow now drops real-shaped request and response body files
into the spool and proves the join end to end: system_text and the tools
list on every row, the full 600-char tool args the event-side clip would
have truncated, the tool result, the thinking signature, a pointer-only
raw_frame, and both files DELETED after projection. A pre-staged
over-cap body is evicted by the startup sweep (config-driven cap,
oldest-first, visible in status and logs) and its session still
completes: the events land at ingest and transcript backfill recovers
exactly the tool rows the evicted body held, under native identity. The
spool directory a client created loose is tightened to 0700.

Ticket: #804 (spec #798, RFC 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
philcunliffe and others added 12 commits August 17, 2026 17:58
…LLP 0255)

The first dataset @hypaware/claude owns: a manifest contributes.datasets
entry plus a registration at activation, with the claude_telemetry
source signal so central forwarding never falls back to the dataset
name. One row per event, hot fields typed (event name, timestamp,
session id, tool name, decision, source, cost), every remaining
attribute preserved in a JSON column; a hot key whose value does not
fit its typed column stays in the JSON rather than vanishing, and an
event name we do not model still lands (LLP 0257 S20).

The split is behavioral-vs-conversation: user_prompt and
assistant_response stay in ai_gateway_messages, the body-pointer events
are transport, and everything else becomes a row - including the
metrics half of the exporter config, which the listener previously
dropped: claude-scope metric data points (cost, lines of code, active
time) flatten into the same event shape, one row per data point.

The listener writes the behavioral rows only after the message write
succeeded, so an exporter retry after a failure re-attempts a write
that never happened; there is deliberately no pre-write dedupe (single
producer, one POST per batch), and the lost-success-response window
produces byte-identical rows compaction's content-hash layer collapses.

No localOnlyContentColumns declaration: the LLP 0105 wrapper would null
the attributes column for every ordinary caller (no row here carries a
cwd to prove itself with); this dataset's privacy seam is the inline
ingest drop of LLP 0254 #policy-inline, ticketed as #808.

The activation-context test stubs gain the query registry the real
kernel always wires in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y_events (LLP 0255)

The batch grows tool_decision (reject, with its source) and the hook
execution pair beside the existing permission_mode_changed and
tool_result, then hyp query sql asserts the behavioral half: one row
per event in timestamp order, content and body events absent, hot
fields typed and lifted out of the attributes JSON, the unpromoted
attributes (from_mode/to_mode, hook identity and outcome, token counts)
readable through JSON_VALUE. A /v1/metrics POST lands its two data
points as rows named by their metrics, value and unit joined.

The registration surfaces are asserted too: hyp query status enumerates
the dataset beside ai_gateway_messages, and the registration carries
the claude_telemetry source signal. The replayed batch's behavioral
rows double by design (no pre-write dedupe; compaction's content-hash
layer owns the collapse), asserted explicitly so a future dedupe is a
conscious change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 0256)

A second recorder is about to host the same /_hypaware/ignore/session
route the gateway proxy hosts, and LLP 0256 wants one route shape with
one set of tests rather than a copy per plugin. So the handler (and the
reserved-prefix recognizer) moves from ai-gateway/src/control.js to
src/core/control/session_ignore.js, the same way LLP 0257 S2 moved the
OTLP http/json server into core. The mutation log's event name and
identity fields become caller-supplied so each host keeps its own signal
shape; the defaults keep the gateway's exactly, and no behavior changes.

The route test moves to test/core with it, per LLP 0256's consequence
that the route's tests live at the shared shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…est (LLP 0256)

The shared OTLP http/json server gains the same reserved-prefix seam the
gateway proxy has: an optional control handler owns /_hypaware/* before
any OTLP routing. The claude telemetry listener registers the shared
session-ignore handler over its own in-memory set (nothing on disk, dies
with the process), so /_hypaware/ignore/session answers identically on
both recorders.

Ingest enforces the set on both signals: events whose session.id is in
the set are partitioned out before the spool is read, so nothing of the
session reaches ai_gateway_messages or claude_telemetry_events, and the
dropped session's spooled bodies are DELETED unread under the same
spool-containment rule as the read path (LLP 0253 delete-on-drop). Each
drop emits the usage_policy_drop signal with policy_source
session_opt_out, and the source status reports the live set size plus
the drop counters.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 0256)

A recorder that hosts the session-ignore route now says so itself: the
claude listener advertises control_routes in its status details, and a
new core resolver reads every such advertisement out of a LIVE daemon
snapshot. The mutations address the gateway (its own two-rung
resolution, unchanged) plus every advertised recorder, so the
client-agnostic verb never carries a list of client plugins, and a
listener that is not running is simply not addressed - it records
nothing, so its absence is not a failure.

Each outcome is reported: the receipt keeps its legacy top-level fields
(the gateway's answer) and gains a recorders array naming every write;
the human output prints one line and one responder-trust disclosure per
addressed endpoint. An addressed recorder that refuses makes the verb
report partial and exit unknown - the refusing recorder is the one still
recording, so the write must not read as done. Single-recorder installs
keep byte-identical receipts apart from the new array.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…7 S25)

claude_telemetry_session_ignore boots the real daemon, ignores a session
through hyp session ignore (asserting the receipt names the gateway and
the claude listener, and that each control route confirms membership
directly, so the gateway's own route is proven undisturbed), then posts
the ignored session's events, bodies, and metrics: zero rows land in
either dataset, the spooled body is deleted unread, and the drop signal
fires with policy_source session_opt_out. unignore restores capture for
the same session, whose resumed body is projected then deleted normally.
A clean session lands beside the drop throughout, isolating the effect.

PRIVACY.md's pausing-a-session passage now names both recorders and the
listener's delete-not-skip duty for spooled bodies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…LP 0257 S17)

On the otel path capture is best-effort, so a stale endpoint, a down
daemon, and upstream event drift all fail into the same silence while
every other status line stays healthy. This is RFC 0245 open question
1's duty: the gap becomes a line, and past a threshold a diagnostic.

The comparison has two sides. The listener source now publishes
last_event_at unconditionally (null before the first event), so its
presence marks the snapshot as the telemetry listener's, the same
self-advertisement pattern as control_routes; status reads it from
status.json without a liveness gate (the LLP 0164 argument: last seen
at T survives its daemon, and the dead-daemon window is precisely the
gap to surface). The client's own side is a new manifest-declared
activity_probe (dir + file suffix, the settings_file home-relative
contract) that core stats fresh for the newest transcript mtime, so a
daemon that was down while the user worked cannot hide the loss.

assessCaptureHealth is pure: the baseline is the newer of the last
event and the marker's attached_at, so months of pre-attach
transcripts prove nothing right after a migration, and a listener
that never saw an event is measured from the attach. Fifteen minutes
past baseline is a warning; two hours escalates to an error that
degrades overall, because an attached machine silently losing
sessions is an outage, not an unfinished setup. The attach probe also
reads the marker's mode and attached_at, gating the section to
otel-attached configured clients and carrying mode into --json.

Part of #810 (spec #798, RFC LLP 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etached (LLP 0257 S17)

Modeled on status_diagnostics: one otel-attached claude install (marker
plus transcript trail under a fake $HOME, listener detail under
status.json, no daemon alive - the comparison must survive its daemon)
driven through three states. Lockstep renders the line and stays
healthy; transcripts hours past the last event turn --json's
capture_health entry to gap, fire the capture_gap diagnostic at error
severity with the attach repair hint, and degrade overall while the
text surface tags the line; removing the marker empties the array and
silences the section. Span assertions pin the healthy and degraded
status.render runs.

Part of #810 (spec #798, RFC LLP 0245).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… 0245)

The adapter stops choosing proxy mode off the CA on disk: otel is the
claude client's only attach mode now, so an attach on a proxy-attached
machine IS the migration. The settings write releases the proxy keys
through the ordinary mode-switch rule and reports the prior marker mode;
on 'proxy' the adapter unwinds the launchd environment (darwin,
best-effort, mirroring the detach undo's release) and prints the
migration story, ending with the one residue that stays the user's call:
the CA trust, offered as 'hyp detach claude --purge' and never run for
them. Below the version floor the refusal still fires before any I/O, so
a too-old client keeps its working proxy attach byte for byte.

The proxy-only attach machinery this retires from the adapter
(ensureDarwinProxyTrust, the CA preflight read, the Cmd-Q relaunch note)
goes with it; detach's own launchd release and the purge path are
untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
--json has carried client_attach[].mode since the marker grew one; the
text line a human reads still said only 'attached', so a machine the
migration just moved from proxy to otel looked unchanged from the surface
that matters. The mode now rides the attached state ('attached (otel)');
markers that predate modes keep the bare word, and the not-attached and
probe-error shapes are untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sessions started before the mode flip keep proxying while new events
arrive over OTEL, so for a while both producers capture the same session.
These tests pin what makes that harmless: the proxy projector's native
transcript identity and the telemetry projection's message.uuid identity
yield the same part_ids for the same content, and the OTEL producer's
pre-write dedupe therefore drops every part the proxy already stored,
whole or half. Either arrival order collapses, because the proxy's
flush-time dedupe asks the same committed-part_id membership question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…P 0245)

A proxy attach whose keychain dialog was refused still ran and still left
the CA on disk, so the migration telling that user their CA "is still
trusted in your login keychain" is the one false line in an otherwise
honest story. The offer now names the CA and any trust it was granted,
which is exactly what 'hyp detach claude --purge' removes either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage at 9df31a01: the blocker is closed, the PR can merge safely

Neutral re-triaged after the author's decision comment and push. The one true blocker from the earlier stuck report - the body spool's byte cap enforced only while the daemon runs - is verified fixed at head 9df31a01, not taken on faith:

  • hyp claude-hook session-context now runs enforceClaudeBodySpoolCap on every invocation, last, after both session-context appends, with the sweep and the recording half each in their own try so the hook still exits 0 whatever happens (hypaware-core/plugins-workspace/claude/src/hook_command.js).
  • It is reachable with no daemon anywhere: the hook is a registered CLI command dispatched in-process, resolves HYP_HOME via readObservabilityEnv(ctx.env), and reads the operator's cap out of the @hypaware/claude slice of the v2 config with the same positive-integer validation as the listener's readSpoolConfig, so lowering telemetry.spool_max_bytes binds both enforcers. CommandRunContext.config is the full HypAwareV2Config (plugins: PluginConfigInstance[]), so the hook's config walk matches the real shape, not just the tests' fixture.
  • It creates nothing: a missing spool directory returns on enforceClaudeBodySpoolCap's ENOENT arm, pinned by a test asserting the directory is not created. Round 1's attach-mints/daemon-repairs/hook-creates-nothing division is preserved.
  • The suite pins it: test/plugins/claude-hook-spool-cap.test.js drives the hook with no daemon; removing the sweep call from runClaudeSessionContextHook fails 5 of its 9 tests (mutant-checked during triage, then restored).
  • At head: npm test 4442 pass / 0 fail / 1 skipped with a hermetic HYP_HOME, npm run typecheck clean.
  • LLP hygiene holds: LLP 0253's diff is the two-line Extended-by: LLP 0263 forward-ref only, nothing settled was edited; LLP 0263 carries the decision with the anchors the code @refs.

Every remaining finding from review rounds 1-2 was re-verified against the tree at 9df31a01 and every one is still present and still non-blocking. They are recorded, with file:line evidence, in follow-up issue #843 so they are not lost at merge.

Two items stay with the author, unchanged by triage:

  • LLP 0263 is Status: Draft - the status call is author-owned. A Draft decision doc on the branch changes no runtime behavior, so it is a hold-item, not a blocker.
  • The real-machine gate docs/ACCEPTANCE.md#claude_otel_shape_check remains the check that matters most before this leaves draft; the hermetic smokes cannot substitute for it.

Not merged, not marked ready: the PR is a draft and that call is the author's.

The last Draft in this change set joins LLP 0252-0258 and 0262 at Accepted,
so every doc PR #818 lands is approved for implementation and the code
already on the branch cites settled decisions rather than a draft.

Accepted, not Active: the lifecycle reserves Active for built AND merged,
and #818 is still an unmerged draft. All nine flip together at merge.

Status only. Nothing 0263 settles was edited, and it carries no [inferred]
claims to retire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe
philcunliffe marked this pull request as ready for review August 18, 2026 19:15
@philcunliffe
philcunliffe merged commit 8f740a3 into master Aug 18, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the otel-attach-0245 branch August 18, 2026 19:15
philcunliffe pushed a commit that referenced this pull request Aug 18, 2026
Conflict was AGENTS.md's "Written acceptance procedures" list. #818 (LLP
0262, the OTEL telemetry attach change set) already added an
`openclaw_capture` bullet there, plus a new `claude_otel_shape_check`
bullet. This branch's Finding 1 was the same addition, so master's already
covers it. Took master's two bullets and dropped this branch's duplicate
rather than listing `openclaw_capture` twice.

`hypaware-core/plugins-workspace/ai-gateway/src/source.js` merged textually
but conflicted in meaning. The branch's Finding 5 fix argued the stale-CA
warning must not say "re-attach", because attach read its mode off the CA
and so re-selected proxy mode while the file was on disk. After #818 that
reason is gone: `hyp attach claude` is OTEL-only and explicitly never
re-attaches by proxy (LLP 0258 #version-floor, LLP 0262 #migration), and
LLP 0232's proxy-attach preflight is superseded for this client. The
conclusion still holds for a different reason, so the remedy text is kept
and the reasoning is restated on the ground that survives: attach leaves
the CA on disk on purpose, offering the trust back rather than taking it,
so a re-attach cannot clear this state. `hyp detach claude --purge` is the
remedy merged master's own migration notice prints. The `@ref` moves from
LLP 0232#proxy-attach-preflight to LLP 0262#migration in both the source
and the test that pins the message, since that is the decision now
constraining the behavior.
philcunliffe pushed a commit that referenced this pull request Aug 18, 2026
… the usage a failed batch consumed

Six of the ten findings deferred from PR #818, the ones that are a bug with a
reproducing test rather than a design call.

- A batch whose message write fails no longer loses the usage it claimed. The
  usage index outlives one POST because an `api_request` and the
  `assistant_response` that names its `request_id` routinely arrive in
  different flushes; projection claimed the entry, the write then failed, and
  the exporter's retry re-projected against a drained index and wrote the same
  rows with no `attributes.usage` and no `claude.cost_usd`. The catch puts
  back only what the batch consumed.
- `client_attach_stale` no longer fires at an `otel`-attached client. The
  marker records the gateway port that mode never uses, so a gateway rebind
  printed a re-attach warning at a client the rebind did not touch;
  `client_telemetry_stale` already watches the port that decides whether
  anything is captured.
- A refused `body_ref` is logged as a 12-hex digest, not as the raw
  wire-supplied path (LLP 0257 S23).
- `deleteSpooledBodies` counts what it removed. `fs.rm(..., { force: true })`
  succeeds on a missing path, so `bodies_deleted` and `bodies_dropped`
  over-reported every already-evicted ref.
- A policy drop brings `spool_bytes` down with the files it deleted, instead
  of leaving the gauge high until the next sweep restates it.
- `last_event_at` is maxed by instant, not by string. Mixed `Z` / `.000Z` /
  offset shapes let the capture-gap baseline run backwards.

The other six findings stay open on the issue: the picker's proxy-mode
contradiction between two Accepted docs, the `dedupeStoredPartIds` scan cost,
`hyp session status` reading only the gateway, the `lastEventAt` stamping
order, the telemetry-endpoint prevention half, and the two shapes in finding
10 - each needs a decision this PR is not the place to make.

Refs #843

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
philcunliffe pushed a commit that referenced this pull request Aug 18, 2026
Two conflicts, both from #818 (LLP 0262) moving Claude Code off proxy
capture onto OTEL telemetry attach.

hypaware-core/plugins-workspace/claude/src/index.js: resolved to master.
This branch's only change there sanitized the permitted-host list in
`ensureDarwinProxyTrust`'s keychain dialog, and #818 deleted both that
function and its call site - `hyp attach claude` no longer mints or trusts
a CA, so the dialog it hardened does not exist. The shared half of that
work survives: `displayableCaHosts` still lives in src/core/tls/ca.js and
`hyp status` still routes the hosts through it.

README.md: kept master's client-generic rewrite of the proxy-mode section
and folded this branch's two additions back into it. `hyp status` naming
"every host the CA is permitted to vouch for" is still true and still this
branch's change (LLP 0238 Consequences, extended but not superseded by
0262, and master's collectProxyTrust still returned no hosts). The launchd
residue bullet is kept but re-scoped to `<client>` and to the attach that
trusted the CA, since no attach path installs the variable now; the removal
paths it documents (detach, --purge, uninstall, the #818 migration unwind)
all still exist, as does the `launchd env:` status line.

docs/PRIVACY.md, src/core/daemon/status.js and src/core/daemon/types.d.ts
merged cleanly; the new proxy-trust `hosts` field does not collide with
#818's `client_telemetry_stale` diagnostic or #777's status.json cleanup.
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
LLP 0262 (#818) updated hyp session ignore to post to every local
recorder (claude listener + gateway proxy), not just the gateway, and
updated the activate() registration summary accordingly, but left the
hypaware.plugin.json manifest summary describing the old gateway-only
behavior. The new manifest/activate() agreement test caught the drift;
this brings the manifest summary in line with the runtime text.
philcunliffe added a commit that referenced this pull request Aug 19, 2026
…sage a failed batch consumed (#851)

* Deferred #843 findings: the listener's counters, its refusal log, and the usage a failed batch consumed

Six of the ten findings deferred from PR #818, the ones that are a bug with a
reproducing test rather than a design call.

- A batch whose message write fails no longer loses the usage it claimed. The
  usage index outlives one POST because an `api_request` and the
  `assistant_response` that names its `request_id` routinely arrive in
  different flushes; projection claimed the entry, the write then failed, and
  the exporter's retry re-projected against a drained index and wrote the same
  rows with no `attributes.usage` and no `claude.cost_usd`. The catch puts
  back only what the batch consumed.
- `client_attach_stale` no longer fires at an `otel`-attached client. The
  marker records the gateway port that mode never uses, so a gateway rebind
  printed a re-attach warning at a client the rebind did not touch;
  `client_telemetry_stale` already watches the port that decides whether
  anything is captured.
- A refused `body_ref` is logged as a 12-hex digest, not as the raw
  wire-supplied path (LLP 0257 S23).
- `deleteSpooledBodies` counts what it removed. `fs.rm(..., { force: true })`
  succeeds on a missing path, so `bodies_deleted` and `bodies_dropped`
  over-reported every already-evicted ref.
- A policy drop brings `spool_bytes` down with the files it deleted, instead
  of leaving the gauge high until the next sweep restates it.
- `last_event_at` is maxed by instant, not by string. Mixed `Z` / `.000Z` /
  offset shapes let the capture-gap baseline run backwards.

The other six findings stay open on the issue: the picker's proxy-mode
contradiction between two Accepted docs, the `dedupeStoredPartIds` scan cost,
`hyp session status` reading only the gateway, the `lastEventAt` stamping
order, the telemetry-endpoint prevention half, and the two shapes in finding
10 - each needs a decision this PR is not the place to make.

Refs #843

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Delete two committed tool transcripts holding this branch red

x/npm-test.log and x/typecheck.log are tracked leftovers from the
git add -A sweep in #785 (pre-dating the repo-scratch-hygiene test
added by #786). master already carries this fix via #853; this
branch forked before that landed, so it inherited the red test
separately. Same fix, scoped to the same two paths.

* Review fixes: a malformed stamp cannot pin last_event_at, and spool_bytes counts what left the disk

Two follow-ups from the review of this branch, both in the code it added.

`newerEventTimestamp` fell back to the string compare whenever EITHER side
failed to parse. `event.timestamp` is read off the wire unvalidated
(`telemetry/events.js` takes whatever string the attribute carried), so a
producer that stamps a non-date wins that compare outright - nothing an ISO
stamp can begin with sorts above `u` in `unknown` - and because the fallback
is symmetric it then beats every genuinely newer event that follows. The
baseline is pinned for the life of the daemon, `hyp status` parses it to
undefined, falls back to `listener_started_at`, and raises `capture_gap`
against a listener that is capturing fine. A value that names an instant now
beats one that names none, whichever side it arrives on; the string compare
survives only for the pair where neither parses.

The projected-body arm still subtracted `spooled.consumedBytes`, the bytes
READ, while the PR added `bytesRemoved` precisely so the gauge tracks what
left the disk. A body whose unlink fails (EPERM, a read-only spool) is still
occupying the cap, and deducting it under-reported `spool_bytes` until the
next sweep restated it: the drop arm's bug in the other direction.

The new listener case fails on 40e40f9 and passes here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Review fixes: a failed batch's usage restore respects the index cap, and a ref stops citing S18 for something S18 does not say

The catch-block restore reinstated every key missing from the pre-projection
snapshot, which includes the ones USAGE_INDEX_LIMIT evicted during the batch,
not only the ones projection claimed. During a sustained dataset outage nothing
is ever claimed and nothing ever shrinks the index, so each failed batch added
its `api_request` entries on top of a map that could no longer trim itself and
the 512 cap stopped bounding anything.

The restore is now `restoreUnclaimedUsage`, which puts the snapshot back and
re-applies the cap, with a unit test that drives 40 failed batches through the
real projector and asserts the index settles at the cap instead of growing past
it (it reaches 576 by round 8 without the trim).

The `@ref LLP 0257#failure-modes` glosses on the source and on the accounting
test cited "S18 - a retried batch is re-projected from the same inputs, so its
inputs have to survive". S18 says delivery is best effort and that a down
daemon's behavioral-event loss is accepted; it says nothing about retry inputs
surviving. 0257 is Accepted, so the mis-citation is removed rather than the
spec edited, and the prose above each already carries the reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: neutral <neutral@hyperparam.app>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
philcunliffe pushed a commit that referenced this pull request Aug 19, 2026
LLP 0262 (#818) updated hyp session ignore to post to every local
recorder (claude listener + gateway proxy), not just the gateway, and
updated the activate() registration summary accordingly, but left the
hypaware.plugin.json manifest summary describing the old gateway-only
behavior. The new manifest/activate() agreement test caught the drift;
this brings the manifest summary in line with the runtime text.
philcunliffe added a commit that referenced this pull request Aug 19, 2026
* Enforce plugin manifest and runtime command agreement (#837)

Plugin command help has two sources that nothing compared: `hyp --help`
renders before boot and reads `contributes.commands` out of the manifest,
while group and leaf help read the registry `activate()` filled.
`@hypaware/context-graph-enrich` shipped two of its commands with different
summaries at the two levels.

- `hyp plugin doctor` gains `command_help_drift`: a declared command whose
  manifest summary and registered summary differ, a declared command
  registered `hidden`, or a group description no declared command sits under.
  Verb-projected commands are covered for free, since a verb registers its
  CLI command into the same registry.
- A new bundled contract test runs that diff over every plugin in the
  workspace, so the bundled set is held to the check a plugin author runs.
- A `hidden` command no longer trips `contribution_undeclared`: omitting it
  from the manifest is how it stays out of pre-boot help.
- The doctor's dry run stops running sources (`@hypaware/otel` bound a real
  port from `activate()`) and stops handing back a capability stub that
  cannot be converted to a string.
- The dispatch miss path distinguishes a plugin the config selects but the
  boot did not get, which previously fell through to "unknown command".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix ai-gateway manifest/activate() drift on session ignore summary

LLP 0262 (#818) updated hyp session ignore to post to every local
recorder (claude listener + gateway proxy), not just the gateway, and
updated the activate() registration summary accordingly, but left the
hypaware.plugin.json manifest summary describing the old gateway-only
behavior. The new manifest/activate() agreement test caught the drift;
this brings the manifest summary in line with the runtime text.

* Read boot's own unavailable-plugins list for the selected-unavailable dispatch miss

The fourth miss state (LLP 0267 #d5) derived "selected but this run did
not get it" by subtracting `activePlugins` from the config-selected
manifests. `dispatch` only fills `activePlugins` when it boots the
kernel itself; a caller that injects one (`opts.kernel`, forwarded by
the integration API's `run()`) leaves it empty, so every config-selected
plugin looked like a failed activation and a plain typo answered with
"your config selects but this run could not activate".

`bootKernel` already publishes that set as `unavailablePlugins`: the
same four routes D5 names, and empty when dispatch did not boot. Read it
instead of re-deriving it, and cover the injected-kernel case with a
test. Also fold the seam's duplicated head-token match into
`declaresCommandHead`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Doctor: name the blank-summary case, match multi-word groups, harden the bundled gate

Three follow-ups on the new command_help_drift check.

- `summary` is optional on a manifest command entry, so an entry without
  one is the shape authors most often land on. It is still drift (top
  level help lists the command with no description), but reporting it as
  "two different summaries: the manifest says ''" sends the author
  looking for a second wording that does not exist. Name the blank and
  hand back the exact line to paste.
- A group prefix is not always one token: `resolveGroupHelp` walks every
  leading prefix, so `ctx.commands.registerGroup({ name: 'query cache' })`
  is correct and renders for `hyp query cache --help`. Comparing only the
  head token of each declared command warned about it forever. Match the
  whole prefix.
- The bundled contract test treated `activate_threw` as non-fatal, so a
  bundled plugin that throws before registering anything and declares no
  commands passed the gate vacuously - exactly what LLP 0267 #d2 says it
  must not do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Review fixes: keep the doctor dry run out of the real HYP_HOME, and off two false reports

Three defects found reviewing #849.

- `dryRunActivate` left `ctx.env` defaulting to `process.env`, so a plugin
  that reads `HYP_HOME` during `activate()` was pointed at the caller's real
  install. `@hypaware/local-fs` mkdirs `<HYP_HOME>/exports` from `activate()`,
  so merely diagnosing it wrote into the home directory the function's own
  contract promises not to touch, and the new bundled agreement test did it
  for the whole workspace on every `npm test`. `HYP_HOME` now points at the
  throwaway root the rest of the dry run already uses.

- The inert source registry returned a `StartedSource` the underlying registry
  never recorded, so a plugin that starts one of its own sources from
  `activate()` and then reloads it got `source 'x' is not started` and the
  doctor reported `activate_threw` against a plugin that works. The no-op is
  now swapped in at `register()` time and routed through the real lifecycle,
  which keeps the bookkeeping intact and still runs nothing. A malformed
  contribution passes through untouched so `register()` still rejects it.

- The `registerGroup` warning contradicted LLP 0267 #d3: a group whose
  registered commands are all hidden is correctly absent from the manifest,
  yet was warned as describing a group nothing lists, and the bundled gate
  counts warnings as failures. Such a group is now exempt; a group with a
  visible command under it still warns.

Also drops the "its manifest would not load" route from the
`findInactivePluginForCommand` doc: boot names an unloadable plugin by its
rootDir, not its plugin name, and an unreadable manifest declares no command
to match against, so that route is not (and cannot be) served here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Reconcile command agreement with hidden manifests

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: test <test@test.com>
Co-authored-by: neutral <neutral@hyperparam.app>
Co-authored-by: Phillip Cunliffe <filco@Macmini.localdomain>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:adopt Foreign PR adopted into neutral's reconcile scope neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OTEL attach LLP groundwork: accept RFC 0245, mint decision and spec LLPs

1 participant