Skip to content

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

Draft
philcunliffe wants to merge 44 commits into
masterfrom
otel-attach-0245
Draft

OTEL telemetry attach replaces proxy capture for Claude Code (LLP 0262)#818
philcunliffe wants to merge 44 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 and others added 11 commits August 17, 2026 19:36
…245)

The migration tests probed the marker directly and the renderer tests fed
themselves fabricated client rows, so nothing joined the two: the claim
that 'hyp status reflects the new attach mode' held only if the collector
in between happened to carry `mode` through. The rig now writes the
install config a real machine has, and one test drives
collectHypAwareStatus plus renderStatusText over the same temp home before
and after the one command: 'attached (proxy)' becomes 'attached (otel)'.

The three non-migrating attach shapes (floor refusal, re-attach, base-URL
switch) also assert no launchd line, which is what the residue unwind
prints on darwin when it runs at all - the negative half of "the unwind
happens on a proxy migration and nowhere else".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The spool holds raw request and response bodies no row has been made from yet,
so the two verbs that exist to get rid of captured data have to reach it: a
purge that leaves it lets the next received batch write back rows the user just
deleted, and a detach that leaves it strands raw prompts in our own directory.

Core learns the root (`<hyp-home>/spool`) and the claude plugin's directory
becomes a child of it, so `hyp purge` empties every client's spool without
naming a plugin. Detach takes the path off the attach marker instead, because
the config that produced it is gone by then - which makes it settings-file
input, so it is honored only when it is a direct child of this install's spool
root. Without that gate, "empty the directory the marker names" would be a
recursive delete pointed anywhere a hand edit chose.

Every purge target sweeps, targeted ones included: a spooled body has not been
read, so nothing about it says which directory or session it belongs to.
Neither sweep can fail its caller - the destructive work has already landed by
the time it runs, so an unreadable entry is a reported count, not a thrown
error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
.hypignore and the machine-local list are resolved per session from the cwd the
SessionStart hook recorded, before the spool is read and before either dataset
is written. An ignored session's events project nothing and its spooled bodies
are deleted unread, so the transport keeps working and the content goes.

The machine-local list is threaded from the SHARED state root, the same path
the export and query seams read. The per-plugin state directory has no list
file, so a resolver built from it silently sees `.hypignore` dotfiles only, and
a `--private` directory would have recorded here after being dropped everywhere
else.

A session with no hook record has no cwd and so no verdict. It is withheld
rather than recorded: writing first and resolving later is the fail-open window
LLP 0085 exists to patch, and this path has no flush-time late drop to patch it
with. The content is still in the Claude Code transcript, which is what the
signal names as the recovery path, and the withhold is counted in status so a
machine whose hook is missing reads as blind rather than idle.

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

The OTEL-path analog of hypignore_capture_drop, with four sessions posted at a
live listener: one clean, one under a `.hypignore`, one on the machine-local
list with no dotfile anywhere near it, and one with no SessionStart record at
all. Only the clean session's rows land, in either dataset, every suppressed
session's staged body is gone from the spool, and each drop signal names what
governed it.

The machine-local case is the one that catches a wiring regression nothing else
would: its verdict is only reachable if the listener reads the list from the
shared state root. The hookless case pins the fail-open window closed.

PRIVACY.md gains the spool as a named surface, since it is a directory of raw
prompts under the user's own home: what it is for, that a dropped session's
bodies are deleted unread, that it is capped, and that purge and detach empty
it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The client-source gloss described one attach mechanism as if it were the
only one; it has been wrong since proxy mode shipped and is wrong twice
over now that the claude client attaches by telemetry. The gloss now says
what attach means and defers the how to a new Attach entry that names the
three modes and what each one costs the user.

Also records the distinction the OTEL path invites a reader to get wrong:
`@hypaware/claude` running an OTLP listener does not make claude an `otel`
source. It stays a client source, autodetected and configured for the
user; the `otel` picker source is what someone turns on for their own app.

Discharges the glossary duty in the RFC's implementation spec
(#798, #811).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
LLP 0245 open question 5 leaves flag stability detected two ways: the
`hyp status` capture-health line in production, and a shape assertion
against the real client at release time (LLP 0257 S21). The second one
had no written form until now.

`claude_otel_shape_check` walks a human through it: assert the nine-key
env block on disk, take a raw body sample with the daemon deliberately
stopped (the only way to read a file the listener otherwise deletes on
sight), assert the body fields the projector fills its column gaps from,
then hold a real conversation and read the event names, the event
attributes, the message columns, and the capture-health line back out.

The event-name query doubles as the drift detector, because an event the
listener does not model is recorded rather than dropped: the step asks
for the whole list in the release notes, not a verdict, so the next
release has something to diff against.

The release checklist now names it for any release touching the claude
adapter, and says why the hermetic smoke cannot stand in for it: a smoke
POSTs a fixture we wrote, so it agrees with itself no matter what
upstream did. Also lists `openclaw_capture`, which has had a written
procedure for a while without appearing here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The proxy-mode section opened by telling the reader that `hyp attach
claude` points ANTHROPIC_BASE_URL at the gateway and that proxy mode is
the opt-in that saves Remote Control. Neither is true on this branch:
claude has one attach mode, `otel`, and Remote Control survives it for
free because no base URL and no proxy are written at all.

Claude Code gets its own section covering what attach writes, where the
raw bodies go and when they are deleted, the version floor and its
refusal, and the proxy-to-OTEL migration. The proxy-mode section keeps
everything true of a client that still proxies (the CA, the decryption
aperture, the corporate-proxy chain) with the claude-specific framing
taken out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude Code batches its OTLP exports, and the metrics exporter runs on a
longer interval than the logs one. Checking the spool or the event list
immediately reads "not yet" as "the listener is not consuming", which is
the one wrong answer a release gate must not give. The two steps now wait
and say why.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phil accepted the RFC and its spawned decisions and spec on 2026-08-17; the
conditional forward-ref phrasing in the displaced proxy-attach docs is firmed
up to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@philcunliffe philcunliffe changed the title OTEL telemetry attach replaces proxy capture for Claude Code (LLP 0245) OTEL telemetry attach replaces proxy capture for Claude Code (LLP 0262) Aug 18, 2026
@philcunliffe philcunliffe added the neutral:adopt Foreign PR adopted into neutral's reconcile scope label Aug 18, 2026
@philcunliffe philcunliffe added the neutral:adopted Adoption completion record: merged while carrying neutral:adopt (LLP 0031) label Aug 18, 2026
test and others added 2 commits August 18, 2026 04:47
# Conflicts:
#	llp/0232-claude-attaches-by-proxy.decision.md
#	src/core/commands/status.js
…tops printing a raw marker mode

Three review findings on the OTEL attach path, all narrow:

- The telemetry listener called `ensureClaudeBodySpool` on every source
  start, so a daemon created `<hyp-home>/spool/claude-bodies` on machines
  that never attached the claude client, against whatever HYP_HOME the
  activation context resolved. `activatePlugins` does not thread the
  daemon's env, so ctx.env is `process.env`: running `npm test` without
  `HYP_HOME` demonstrably created the directory in the developer's real
  `~/.hyp`. Attach is what mints the spool (the same write tells Claude
  Code where to put bodies), so the daemon now repairs a directory it
  finds (`tightenClaudeBodySpool`) and creates none.

- `hyp attach claude` ran the spool mkdir inside the same try as the
  settings write, so an unwritable spool root reported a failed attach
  after the settings file had already been rewritten, and swallowed the
  migration notes with it - including the `hyp detach claude --purge`
  line a migrated machine needs. It is now its own try; the failure rides
  the existing warnings list, counted apart from
  `malformed_blocks_repaired` so that count keeps meaning one thing.

- `hyp status` interpolated the attach marker's `mode` into the clients
  row raw. The mode is read back off the client's own settings file, so
  it is a captured label, not an in-process constant; it now goes through
  the same `printable` every other disk-derived label on that surface
  uses (LLP 0225).

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

Copy link
Copy Markdown
Contributor Author

Review round 1 (neutral, adopted PR)

Verdict: changes requested, but nothing here blocks the shape of the design.
The capture path is sound and unusually well argued: policy and session-ignore
both decide at ingest before any read of the spool, dropped sessions get their
bodies deleted rather than skipped, body_ref is contained to the spool on both
the read and the delete arm, the projection reads bodies only for the gap block
types and never re-projects text, and the write order (message rows to
behavioural rows to body deletion) is right against exporter retries. Every new
@ref anchor resolves, and every Accepted doc this PR touched received a header
forward-ref and nothing else (verified: zero deleted lines across LLP 0085,
0104, 0231, 0232, 0235, 0237, 0238, 0239, 0242, 0243, 0244).

Baseline at 746ff92c: npm test 4418 pass / 0 fail / 1 skipped, npm run typecheck clean, and claude_telemetry_capture,
claude_telemetry_session_ignore, claude_telemetry_hypignore_drop,
claude_attach_detach, status_capture_health, status_diagnostics all green.

The conflict resolution: both halves check out

src/core/commands/status.js nesting: correct. All four combinations
render as intended, and I added a test for the two that had none (probe-less
with a mode on its marker, and the sanitizing case below):

probe attach marker mode renders
absent (attachable === false) either either attach n/a (master's gate wins, as #544 intends)
present attached present attached (otel)
present attached absent attached
present not attached n/a not attached

Sanitizing: the resolution was right about c.client, wrong about c.mode.
c.client in the capture-health block is the descriptor's own name, the same
trust level as c.name, which master already prints raw. But c.mode is not
in-process manifest data: it is read back out of the client's settings file
by probeClientAttachFromDescriptor, so it is a captured label that a hand edit
reaches, and it was the first marker-derived string to reach the text surface
(version and port are --json only). Fixed below.

LLP 0232's self-contradiction does not reach this PR. hyp attach claude is
now unconditionally MODE_OTEL with no CA branch at all
(hypaware-core/plugins-workspace/claude/src/index.js:206, :333), so it does
not rely on either reading, and it is consistent with PR #822's LLP 0259, which
governs only clients still routed through the proxy.


Findings

1. The Claude picker row still composes proxy mode, contradicting this PR's own forward-refs - medium, left for a human

hypaware-core/plugins-workspace/claude/hypaware.plugin.json:58 still declares
"gateway_proxy_mode": true, and the claude-and-otel-local preset still
writes proxy_mode: true
(hypaware-core/plugins-workspace/claude/src/index.js:520). This PR's own
forward-ref says the opposite:
llp/0243-picker-composes-proxy-mode.decision.md:11 - "the Claude row stops
declaring compose.gateway_proxy_mode, because the client it composes for is no
longer captured by proxy" - and llp/0242-fresh-installs-attach-base-url.issue.md:9
says "proxy mode stops being what fresh installs compose for it".

Two live consequences:

  • A Claude-only hyp init still turns the gateway into a forward proxy and
    mints a machine-local interception CA for a client that no longer routes
    through either. That is exactly the apparatus LLP 0262 opens by saying attach
    "needs no CA".
  • maybeOfferProxyModeMigration (src/core/commands/clients.js:832, untouched
    by this PR) gates purely on that manifest key, so on any install whose gateway
    lacks proxy_mode - a hand-written config, or one predating LLP 0243 -
    hyp attach claude still prints "this install attaches claude by base URL …
    run hyp attach claude in an interactive terminal"
    and offers a proxy-mode
    migration, immediately after performing an OTEL attach. The claim is false on
    both counts and the offer points at the mode this PR removed for this client.
    Pinned by test/core/attach-proxy-migration.test.js:256, also untouched.

I did not fix this. llp/0243#composed-default is Accepted and settles the
opposite (test/core/init-proxy-mode-default.test.js:56 carries a [tests] ref
to it), and LLP 0262 narrows the proxy's client list without ever saying the
picker stops composing proxy mode for claude. Two Accepted docs now disagree,
and picking between them is a design call, not a review fix. Either extend 0262
(or mint a decision) and do the code change plus the two tests, or soften the
0242/0243 forward-refs to describe what actually shipped. Please don't leave it
as-is: right now the corpus asserts something the code does not do.

2. dedupeStoredPartIds scans the whole dataset on every live write - medium, left

hypaware-core/plugins-workspace/ai-gateway/src/dataset.js:531 calls
scanExistingPartIds(storage, batchKeys) and then scanSpooledPartIds.
restrictTo bounds memory, not I/O: the only early exit is
dataset.js:731, if (seen.size >= restrictTo.size) return seen, which fires
only once every key of the batch has been found - and for genuinely new rows,
which is the normal case, it never fires. So every row of every committed
ai_gateway_messages partition is read, then the entire spool is iterated.
exchange_writer.js:60 runs this once per projection per POST, i.e. every few
seconds per active session. The JSDoc's "restricted to the keys of the batch in
hand so a per-exchange call stays O(batch)" is true of the set, not of the scan.
On a machine with a real history this makes the listener's write path O(total
dataset) per batch and will back the exporter up. The proxy path pays this only
at flush time and only for fallback-identity rows (dataset.js:346). Left
because the fix is a partition-pruning or index decision, not a local edit.

3. The telemetry endpoint can outlive the port the listener bound, and it carries prompt text - medium, left

resolveAttachTelemetryPort (.../claude/src/telemetry/source.js:839) writes
DEFAULT_TELEMETRY_PORT (4319) into env.OTEL_EXPORTER_OTLP_ENDPOINT whenever
no live daemon publishes a port (:857). bindWithFallback (:805) binds an
ephemeral port whenever 4319 is taken, and nothing rewrites the settings file
afterwards: the attach marker records only the gateway port
(.../claude/src/settings.js:531), so LLP 0086 re-attach-on-drift never sees
the telemetry port drift. Attach with the daemon down, plus anything else
already on 4319, and Claude Code POSTs to that process with
OTEL_LOG_USER_PROMPTS=1 and OTEL_LOG_ASSISTANT_RESPONSES=1 set - full prompt
and response text to an unrelated local listener, with the capture-health line
as the only detector. Relatedly, a configured telemetry.listen_port: 0 plus a
daemon-down attach also resolves to 4319, which that config will never bind, so
capture is permanently zero. Worth either recording telemetry_port on the
marker and extending the drift check, or refusing to write an endpoint the
daemon has not confirmed.

4. hyp session status was not extended to the second recorder - medium, left

runMutation now posts to every recorder advertising the route
(.../ai-gateway/src/session_command.js:339 onward) and reports partial
honestly. runSessionStatus (:218) was not touched: :256 still resolves
only resolveGatewayEndpointForCli. So hyp session ignore can succeed against
the claude telemetry listener alone (printing "gateway not addressed: …") and
the very next hyp session status answers status: unknown / ignored: null,
unable to see the write that actually stopped the recording. docs/PRIVACY.md
in this PR says status "reports which state the session is in right now", which
is now only true of the gateway. Left rather than fixed because open PRs #777
and #800 are in this file and the fix changes SessionStatusReport's shape.

5. Capture health cannot see a fully-withheld capture - medium, left

state.lastEventAt is advanced from allEvents
(.../claude/src/telemetry/source.js:536 for metrics, :579 for logs) before
both policy gates run. On a machine whose SessionStart hook never landed, every
event is withheld as undetermined (policy.js:21) and zero rows reach either
dataset, yet last_event_at keeps advancing, so assessCaptureHealth reports
ok and no capture_gap diagnostic fires. That is precisely the silent-loss
case LLP 0262 open question 1 asked this line to surface. events_undetermined
is counted in the source's status details but reaches no hyp status surface.
Left deliberately: simply moving the stamp after the gates would make a machine
that .hypignored everything report a permanent capture gap, so the right shape
is a separate signal (a hyp status line or diagnostic off
events_undetermined), which is a design call.

6. hyp status stats every transcript on the machine - low, left

probeClientActivityFromDescriptor (src/core/daemon/status.js:1869) walks
~/.claude/projects to depth 5 through newestMtimeMs (:1896), issuing a
sequential fsp.stat per matching file, subagents/ trees included. On a
long-standing install that is tens of thousands of serial stats added to an
interactive command. Directory mtimes could prune most of the walk.


Fixed and pushed

Head is now f65d4529 ("Review round 1: the daemon stops minting the body
spool, and status stops printing a raw marker mode"). All three verified in the
committed tree; suite re-run at f65d4529: 4421 pass / 0 fail / 1 skipped,
typecheck clean, and the six smokes above still green.

a. The daemon minted the body spool in the developer's real ~/.hyp.
The listener called ensureClaudeBodySpool on every source start
(.../claude/src/telemetry/source.js:142 at 746ff92c), resolving the path
through readObservabilityEnv(ctx.env). activatePlugins never threads the
daemon's env, so ctx.env is process.env (src/core/runtime/activation.js:136,
env ?? process.env), and I confirmed empirically that
npm test with HYP_HOME unset creates ~/.hyp/spool/claude-bodies in the
developer's home - traced to startClaudeTelemetrySource via
test/core/daemon-reconcile.test.js, which does pass HYP_HOME to
runDaemon. This answers the "test-isolation leak" note in the PR body: this
change set adds a new instance of it, and the new one writes a directory
rather than only reading one. Attach is what mints the spool (it is the same
write that tells Claude Code where to put bodies), so the daemon now repairs a
directory it finds and creates none: new tightenClaudeBodySpool in
.../claude/src/telemetry/spool.js, used at source start. The leak is gone and
a machine that never attached claude no longer grows a raw-prompt directory.

b. A spool failure reported the attach as failed after it had succeeded.
await ensureClaudeBodySpool(spoolDir) sat inside the same try as the
settings write (.../claude/src/index.js:241 at 746ff92c), so an unwritable
spool root threw status: failed with the settings file already rewritten, and
swallowed the migration notes with it - including the hyp detach claude --purge line a machine that just migrated off the proxy needs. Now its own
try; the failure rides the existing warnings list, counted apart from
malformed_blocks_repaired so that count keeps meaning one thing.

c. hyp status printed the attach marker's mode raw. Now
printable(c.mode), the same sanitizer every other disk-derived label on that
surface already used (LLP 0225). A mode that sanitizes away entirely falls back
to the bare attached rather than an empty parenthesis. Three new renderer
tests in test/core/status-attach-mode.test.js, one new spool test in
test/plugins/claude-telemetry-spool.test.js.

Privacy read

Nothing in the new code logs prompt text, credentials, or hidden reasoning. The
suppression signals carry governed_by / declared and counts, never the cwd
or a filename; purge.result logs counts and bytes with an explicit note that
a spooled filename is the client's; the body frame carries path.basename and
ids only; the spool is 0700 and swept by purge and detach. Two things a reader
should be aware of rather than surprised by: the attach block deliberately sets
OTEL_LOG_USER_PROMPTS=1, OTEL_LOG_ASSISTANT_RESPONSES=1,
OTEL_LOG_TOOL_DETAILS=1 and OTEL_LOG_RAW_API_BODIES
(.../claude/src/settings.js:144-152) - that is the product, and
docs/PRIVACY.md now documents the spool well - and finding 3 above is the one
path by which that content can leave the intended listener.

Needs a real machine

docs/ACCEPTANCE.md#claude_otel_shape_check is the gate for this change set and
cannot be simulated: claude_telemetry_capture POSTs a fixture we wrote, so it
agrees with itself whatever upstream did. It needs a real Claude Code >= 2.1.214
and a real daemon, and it is the only thing that can catch a renamed event, a
dropped flag, or a changed raw-body format - whose failure mode is null columns,
not an error. Please run it (and record claude --version plus the observed
event-name list) before this leaves draft.

Not merged, not marked ready: it is a draft and that call is yours.

test and others added 3 commits August 18, 2026 06:51
…e the listener listens

An `otel` attach writes one `OTEL_EXPORTER_OTLP_ENDPOINT` into the client's
settings and nothing ever rewrites it. Two ordinary sequences move the
listener out from under it:

- attach ran with no live daemon, so `resolveAttachTelemetryPort` could only
  write the well-known default, and the daemon later found that port taken
  and fell back to an ephemeral bind (LLP 0114 #ephemeral-fallback);
- `telemetry.listen_port: 0` plus a daemon-down attach, where the default is
  written for a port the config guarantees will never be bound.

Either way Claude Code keeps POSTing at the port it was told about, with
`OTEL_LOG_USER_PROMPTS` and `OTEL_LOG_ASSISTANT_RESPONSES` on, and every
other line in `hyp status` stays healthy. `client_attach_stale` does not
cover it: that comparison watches the gateway, which this mode never talks
to. The capture-health gap line eventually notices the silence, but only
after fifteen minutes of transcript activity and without naming the cause.

Both ports are already on disk. The probe now lifts the marker's managed
`OTEL_EXPORTER_OTLP_ENDPOINT` port (the live value the client is using, not
a parallel field that could disagree with it), and the capture-health block
compares it against the listener's live `listen_port`, raising a
non-degrading `client_telemetry_stale` naming both ports and the repair.
Liveness-gated, unlike `last_event_at` beside it: a dead daemon's snapshot
cannot say where anything is bound now, and a restart is what moves the port
back. A marker with no endpoint, or one that does not parse to an in-range
port, makes no claim at all.

This is LLP 0114 #fallback-is-visible applied to the new listener - the half
of that decision the listener had adopted the fallback without - and LLP 0257
gains S17b to record it.

Does not prevent the exposure, only makes it loud: whether attach should
refuse to write an endpoint no daemon has confirmed is a design call, left
for a human.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re gap, and a failed listener start owns nothing

Two defects in the new listener, both found by re-reading the start path.

The capture-health baseline could not survive a daemon restart.
`state.lastEventAt` lives only in the listener's process, so every restart
republishes `last_event_at: null` however long capture has been healthy.
`assessCaptureHealth` then fell back to the attach timestamp, which on a
machine attached a month ago and used an hour ago yields a month-long gap:
severity `error`, degrading `overall`, on an install that is capturing
perfectly. The first repair the diagnostic prints is `hyp daemon restart`,
so the advice reproduced the finding. The listener now publishes
`listener_started_at`, and the gap is measured from the newest of the last
event, the attach, and a LIVE listener's start - live only, because on a
dead daemon the last one's start bounds nothing and the growing gap is
exactly what the line exists to surface. The null-events message stops
saying "after the attach", which is no longer always the baseline it used.
`listener_started_at` rides the capture-health report and `--json` so the
reason a gap is or is not claimed is readable.

The spool sweep timer was armed before the bind. `stop()` is the only thing
that clears it, and a `start()` that throws never returns a handle to call
`stop()` on, so an explicit-port bind failure (LLP 0114
#explicit-listen-fails-loudly) left an interval scanning the spool every
minute for the life of the daemon on behalf of a source that does not
exist. `unref()` kept it from holding the process open, which is why it
would never have been noticed. The one-shot sweep still runs before the
bind - bodies already on disk are over the cap either way - and only the
repeating one waits for a listener to be behind it.

LLP 0257 gains S17c for the restart baseline. Tests: five in
test/core/status-capture-health.test.js covering the restart, a listener up
long enough for the gap to be real, an event outranking the listener start,
the live-daemon and dead-daemon halves end to end; and a new
test/plugins/claude-telemetry-start-failure.test.js that counts `setInterval`
arming across a failed start, because an unref'd timer does not appear in
process.getActiveResourcesInfo() - verified to fail against the old order.

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

In the OTLP environment-variable contract a per-signal key beats the generic
one, so `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` decides where log records go
whatever `OTEL_EXPORTER_OTLP_ENDPOINT` says. Nothing in the tree mentioned
those keys.

That leaves one shape worth saying out loud: a machine already exporting to
its own collector through a per-signal key gets `OTEL_LOG_USER_PROMPTS`,
`OTEL_LOG_ASSISTANT_RESPONSES` and `OTEL_LOG_TOOL_DETAILS` switched on by
this attach, and its prompts and assistant responses start flowing THERE,
while `hyp status` reports `attached (otel)` and the listener sees nothing.
`OTEL_EXPORTER_OTLP_HEADERS` is the same hazard from the other side: it
carries a collector's credentials and would now ride requests aimed at our
loopback listener.

The managed set is untouched - LLP 0258 #env-keys is "exactly these keys,
and only these", and it is Accepted. Attach now warns per offending key
instead, on the same warnings list every other attach notice rides. A
warning rather than a refusal because attach cannot see a key exported from
the user's shell, so refusing on the half it can see would buy a false sense
of completeness. Values are never echoed: an endpoint or a headers value is
exactly where a collector token lives, and this string is printed, logged,
and serialised into `--json`.

Two tests in test/plugins/claude-settings-otel-attach.test.js: the warning
fires per key, echoes neither the collector nor the credential, and leaves
the keys themselves untouched and unmanaged; and an ordinary attach still
warns about nothing.

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

Copy link
Copy Markdown
Contributor Author

Review round 2 (neutral, adopted PR) - last round of the two-round cap

Verdict: findings remain, none of them blocking the design. Round 1's three
fixes hold, the home-directory write leak this change set introduced is gone and
stayed gone under a deliberate re-test, and I fixed four more things. What is
left is argued below with a round-2 verdict on each, so triage has the reasoning
rather than only the list.

Baseline at f65d4529: npm test 4421 pass / 0 fail / 1 skipped,
npm run typecheck clean, six adjacent smokes green.
Head is now 487bf2d5: npm test 4433 pass / 0 fail / 1 skipped,
typecheck clean, and 18 smokes green - the full release battery plus
claude_telemetry_capture, claude_telemetry_hypignore_drop,
claude_telemetry_session_ignore, status_capture_health,
claude_attach_detach.


1. Round 1's fixes hold, and the home-directory leak is really gone

I re-tested the leak rather than reading the diff for it, because that is the
class that comes back silently. env -u HYP_HOME HOME=$(mktemp -d) npm test,
then walk the fake home:

this PR origin/master
~/.hyp/spool/claude-bodies absent absent
~/.hyp/exports present present

spool/claude-bodies is gone, and stayed gone when I re-ran the same check
after my own changes. ~/.hyp/exports is created identically on
origin/master (src/core/cli/walkthrough.js:1051), so it is the pre-existing
isolation leak the PR body already reports, not a new one. Nothing else appears
outside the temp HYP_HOME.

The fixes themselves check out in the committed tree, and I found nothing new
in any of them:

  • tightenClaudeBodySpool (.../claude/src/telemetry/spool.js:77, used at
    .../telemetry/source.js:148) stats first and returns false on anything
    that is not a directory, so an unattached install creates nothing and a
    non-directory at the path is not chmod'd.
  • The spool's own try (.../claude/src/index.js:246-267) leaves the
    migration notes intact - migrationNotes is built after it and still reaches
    writeAttachOutput, so the hyp detach claude --purge line survives an
    unwritable spool root. Splitting malformedWarnings from warnings keeps
    malformed_blocks_repaired (:334, :341) counting one thing while the user
    still sees both.
  • printable(c.mode) (src/core/commands/status.js:453-460), with the bare
    attached fallback when the mode sanitizes away entirely.

2. Verdict on the port desync specifically

A real defect, medium severity, not a merge blocker. Detection is fixed
below; prevention is left, deliberately.

Not a blocker: the wrong-listener case needs a foreign process holding
127.0.0.1:4319 at the moment the daemon binds
. 4319 is unassigned and nothing
in HypAware binds it (the otel plugin is 4318, .../otel/src/source.js:11).
And the failure is now detected twice - immediately and by name, and after
fifteen minutes by capture_gap - with a repair that works.

Not a preference either: when it does fire, what crosses to the squatting
process is full prompt and response text, because attach deliberately sets
OTEL_LOG_USER_PROMPTS=1 and OTEL_LOG_ASSISTANT_RESPONSES=1
(.../claude/src/settings.js:148-152). On a shared host, any local user who
binds loopback 4319 first collects another user's prompts. Detection is the
floor, not the ceiling.

What I did not do, deliberately: prevention. Refusing to write an endpoint
no live daemon has confirmed would break attach-before-first-daemon-start, which
resolveAttachTelemetryPort's third rung supports on purpose. Choosing between
"refuse", "write and repair on the next daemon start", and "keep writing the
default" is a design call on an Accepted decision's consequence. One sub-case is
indefensible on its own terms and could be settled cheaply: with
telemetry.listen_port: 0 and a daemon-down attach, the operator asked for a
dynamic port and attach writes 4319, an address that config guarantees will
never be bound.


3. Fixed and pushed

Head is now 487bf2d5, three commits. Each verified in the committed
tree with git show HEAD:<file>, not only in the worktree.

a. hyp status compares where Claude exports against where the listener listens

An otel attach writes one OTEL_EXPORTER_OTLP_ENDPOINT and nothing rewrites
it. Nothing covered the drift: client_attach_stale
(src/core/daemon/status.js:1158) compares the marker's port against the
gateway, which this mode never talks to, and the capture-health gap line
notices the silence only after fifteen minutes and without naming the cause.

Both ports are already on disk. probeClientAttachFromDescriptor now lifts the
port out of the marker's managed OTEL_EXPORTER_OTLP_ENDPOINT
(markerTelemetryPort, src/core/daemon/status.js:1833) - the live value the
client is using, not a second marker field that could disagree with it - and
the capture-health block raises a non-degrading client_telemetry_stale naming
both ports and the repair (:1231-1250). Liveness-gated: a dead daemon's
snapshot cannot say where anything is bound now. A marker with no endpoint, or
one that does not parse to an in-range port, makes no claim.

This is LLP 0114 §fallback-is-visible applied to the new listener. The listener
had adopted 0114's fallback (@ref LLP 0114#explicit-listen-fails-loudly at
.../telemetry/source.js:821) without the half of 0114 that makes a fallback
bind visible in status rather than only in a log line: it publishes
listen_fallback_from (:331) and no surface read it. LLP 0257 gains S17b.
Four tests in test/core/status-telemetry-endpoint-drift.test.js.

b. A restarted daemon stops reporting a month-long capture gap, and a failed listener start owns nothing

Two defects, both on the start path, both new this round.

The capture-health baseline could not survive a restart. state.lastEventAt
lives only in the listener's process (.../telemetry/source.js:112), so every
restart republishes last_event_at: null however long capture has been healthy.
assessCaptureHealth then fell back to the attach timestamp, which on a machine
attached a month ago and used an hour ago yields a month-long gap: severity
error, degrading overall, on an install that is capturing perfectly. The
first repair the diagnostic prints is hyp daemon restart, so the advice
reproduced the finding. The listener now publishes listener_started_at, and
the gap is measured from the newest of the last event, the attach, and a live
listener's start - live only, because on a dead daemon the last one's start
bounds nothing and the growing gap is exactly what the line exists to surface.
The null-events message stops saying "after the attach", which is no longer
always the baseline it used. LLP 0257 gains S17c. Five tests in
test/core/status-capture-health.test.js, including both halves of the
live/dead asymmetry end to end.

The spool sweep timer was armed before the bind. stop() is the only thing
that clears it, and a start() that throws never returns a handle to call
stop() on, so an explicit-port bind failure (LLP 0114
§explicit-listen-fails-loudly) left an interval scanning the spool every minute
for the life of the daemon on behalf of a source that does not exist. unref()
kept it from holding the process open, which is why it would never have been
noticed. The one-shot sweep still runs before the bind; only the repeating one
now waits for a listener behind it.
test/plugins/claude-telemetry-start-failure.test.js counts setInterval
arming across a failed start (an unref'd timer does not appear in
process.getActiveResourcesInfo(), so the arming itself is what is observed) -
verified to fail against the old order before I kept it.

c. otel attach names the OTLP keys that outrank the endpoint it wrote

In the OTLP environment-variable contract a per-signal key beats the generic
one, so OTEL_EXPORTER_OTLP_LOGS_ENDPOINT decides where log records go
whatever OTEL_EXPORTER_OTLP_ENDPOINT says. Nothing in the tree mentioned
those keys
- not src/, not hypaware-core/, not llp/, not docs/.

That leaves one shape worth saying out loud: a machine already exporting to its
own collector through a per-signal key gets OTEL_LOG_USER_PROMPTS,
OTEL_LOG_ASSISTANT_RESPONSES and OTEL_LOG_TOOL_DETAILS switched on by
this attach, and its prompts and assistant responses start flowing there,
while hyp status reports attached (otel) and the listener sees nothing.
OTEL_EXPORTER_OTLP_HEADERS is the same hazard from the other side: it carries
a collector's credential and would now ride requests aimed at our loopback
listener. releaseUnmanagedKeys cannot help - it walks only the prior marker's
managed keys.

The managed set is untouched: LLP 0258 §env-keys is "exactly these keys, and
only these", and it is Accepted. Attach now warns per offending key on the
existing warnings list. A warning rather than a refusal because attach cannot
see a key exported from the user's shell, so refusing on the half it can see
would buy a false sense of completeness - taking these keys over is the better
answer and is a design change for the author, not a reviewer. Values are never
echoed. Two tests in test/plugins/claude-settings-otel-attach.test.js.

Heads-up for whoever merges: open PRs #777 and #800 both touch
src/core/daemon/status.js and src/core/daemon/types.d.ts. My additions there
are localized (one helper, one block in the capture-health section, one union
member, one report field), but this PR was already the heavy editor of that file.


4. Left: the four round-1 findings, re-judged

4a. The Claude picker still composes proxy mode - round 1's reading confirmed; a human's call

Checked independently, same conclusion. hypaware.plugin.json:58 still declares
"gateway_proxy_mode": true; the preset still writes proxy_mode: true
(.../claude/src/index.js:520);
llp/0243-picker-composes-proxy-mode.decision.md:11 is Accepted and settles
that the picker composes proxy mode for claude, while
llp/0242-fresh-installs-attach-base-url.issue.md:9 and 0243's own new
forward-ref say the opposite. LLP 0262 narrows the proxy's client list without
ever overriding 0243's picker rule.

Two Accepted documents disagree. Neither this PR's code nor a reviewer can pick
between them without rewriting what one of them settled. It needs a new decision
(or an extension on 0262) naming 0243 explicitly, plus the code change and the
two pinned tests (test/core/init-proxy-mode-default.test.js:56,
test/core/attach-proxy-migration.test.js:256). I did not touch it, and I
recommend the next reviewer does not either.
The live cost meanwhile: a
Claude-only hyp init still mints an interception CA for a client that no
longer routes through it, and maybeOfferProxyModeMigration
(src/core/commands/clients.js:832) can still offer a proxy migration
immediately after an OTEL attach.

4b. dedupeStoredPartIds scans the whole dataset per live write - medium; the one I would fix first

Mechanism verified. dataset.js:531 calls scanExistingPartIds(storage, batchKeys), whose only early exit is dataset.js:731, if (seen.size >= restrictTo.size) return seen - which fires only once every key of the batch
has been found. For genuinely new rows, the normal case for a live listener, it
never fires, so every row of every committed ai_gateway_messages partition is
read and then the whole spool is iterated, once per projection per POST
(exchange_writer.js:60). The JSDoc's "restricted to the keys of the batch in
hand so a per-exchange call stays O(batch)" describes the set, not the scan.

Round-2 verdict: this is the finding that gets worse with use rather than
firing under a condition, so it will be discovered as "the listener got slow
after a month" rather than as a bug. Still left: bounding it means pruning
partitions (by date, or an index on part_id) and deciding what a
backfill-written old partition means for correctness - a design call on the
dedupe contract, not a local edit.

4c. hyp session status still asks only the gateway - medium, left

runSessionStatus (.../ai-gateway/src/session_command.js:255) resolves only
resolveGatewayEndpointForCli, while runMutation (:339 onward) posts to
every recorder advertising the route. So hyp session ignore can succeed against
the Claude telemetry listener alone and the next hyp session status answers
status: unknown / ignored: null. I confirmed #777 and #800 are both still
open
. Same call as round 1: left. It is a reporting inconsistency, not a
capture defect - the ignore itself lands.

4d. lastEventAt is stamped before both policy gates - medium, still a design call

state.lastEventAt advances from allEvents at .../telemetry/source.js:561
(metrics) and :604 (logs), before partitionIgnoredSessionEvents (:613) and
applyUsagePolicy (:625). A machine whose SessionStart hook never landed
withholds every event as undetermined (policy.js:21), writes zero rows, and
reports capture health ok.

Verdict unchanged, and worth recording why moving the stamp is wrong rather
than merely risky: last_event_at means "the last event this listener saw", and
that is what it records. The bug is in what assessCaptureHealth infers.
Moving the stamp past the gates would make a machine that .hypignored
everything report a permanent capture gap - a false alarm for a user who got
exactly what they asked for. The honest shape is a second signal off
events_undetermined, which the source already counts and publishes (:318)
and which no hyp status surface reads. New diagnostic to design; out of scope
for a review round. (My fix 3b is the sibling case: the baseline, not the
stamp, was what made a healthy machine look broken.)


5. New findings, left

5a. The spool's byte cap is only enforced while the daemon runs - medium

Every enforcement lives inside the listener source: await sweepSpool() at
start (.../telemetry/source.js:191) and a 60-second timer (:259, cleared
only on stop()). The only other sweepers are user-initiated: hyp purge
(src/core/commands/purge.js:108) and detach
(src/core/config/client_detach_disk.js:790).

So on a machine where the daemon is not running - failed install, crashed
service, a laptop where it was never started after hyp attach claude - Claude
Code keeps writing raw request and response bodies into
<hyp-home>/spool/claude-bodies with nothing bounding them and nothing
deleting them
(deletion happens on projection, also the daemon's). LLP 0253's
summary says the cap exists "so a down daemon can never fill the disk", and at
~145 KB per request (0253 §Context) that is gigabytes a day of raw prompts.
LLP 0262 accepted the spool's presence on the basis that it is transient; with
no daemon it is not transient, it is permanent.

I read §byte-cap carefully before calling this a finding: "the cap is enforced
by the daemon, not by hoping the reader keeps up: the window this exists for is
precisely the one where the reader is not running". The normative section is
defensible as written and the code matches it, and it is Accepted, so I have not
rewritten it. But the summary above it promises the down-daemon case, which has
no enforcement at all.

There is an obvious place for it that needs no new design vocabulary:
hyp claude-hook session-context is already wired to UserPromptSubmit
(.../claude/src/settings.js:64), so it runs in a separate process at exactly
the cadence bodies are written and already resolves HYP_HOME. A best-effort
enforceClaudeBodySpoolCap there would deliver 0253's stated guarantee. I did
not do it: whether a hook may delete data, and what latency budget a per-turn
hook has, are the author's calls. Either add the sweep or amend 0253's summary -
but the corpus should not keep claiming the stronger thing.

5b. A retried batch loses its usage and cost attributes - medium

messageFromEvent (.../telemetry/projection.js:248-252) and claimUsage
(.../telemetry/bodies.js:359-363) delete from usageByRequestId during
projection, and projection runs at .../telemetry/source.js:657, before any
write (:673). If recordProjectedExchange throws, the handler rethrows, the
POST 500s, and the exporter retries the same batch - by which time the map no
longer holds the request_id, so re-projected assistant rows carry no
attributes.usage and no claude.cost_usd. Rows that already landed dedupe on
part_id and keep theirs; anything the loop had not reached yet is written
without. This is exactly the cross-batch case the map exists for
(source.js:212-219), so it is not exotic. Fix is to consume the map only after
the writes succeed, or snapshot and restore on throw - small, but it is a change
to the projector's contract rather than a review edit.

5c. client_attach_stale fires against the gateway for an otel client - low

src/core/daemon/status.js:1139-1160 compares the marker's port (the
gateway's) against the live gateway port for every attached client,
mode-independently. An otel-attached claude writes no gateway reference into
its settings at all, so after a gateway rebind it gets "claude is attached at
port X but the gateway is now bound to port Y - run hyp attach --client claude
to re-point it"
, which is not true of anything in its settings file. Harmless
(the repair is idempotent, and now also refreshes the telemetry endpoint), but
it is the same "the drift check watches the wrong port for this mode" root as
the finding I fixed, and belongs in whatever settles that.

5d. body_ref is logged verbatim - low

.../telemetry/source.js:415 and :651 log body_ref: ref for refused
refs. A refused ref is by definition a path outside the spool: an
attacker-supplied absolute filesystem path, written into a warn line that an
operator's own sink may ship off the machine. It is inconsistent with the policy
the rest of this PR follows on purpose - bodyFrame uses path.basename only
(bodies.js:344), purge logs "counts and bytes only ... a spooled body's
filename is the client's" (purge.js:171-173), detach logs "counts, never
filenames" (client_detach_disk.js:797). A hash, or basename plus a reason
code, keeps it diagnosable. Judgement call, since the whole point of the line is
to say what was refused - hence low, and named rather than changed.

5e. Three counting nits - low

  • deleteSpooledBodies (bodies.js:133-145) uses fs.rm(file, { force: true }),
    which does not throw on ENOENT, so already-vanished files count as deleted.
    state.bodiesDeleted and the bodies_deleted span attribute over-report; the
    branch's own test asserts 2 for one real file and one that never existed
    (test/plugins/claude-telemetry-bodies.test.js:166-168).
  • suppressSession (source.js:400) deletes body files but never decrements
    state.spoolBytes, so spool_bytes in hyp status drifts high between
    sweeps on a machine with active ignores.
  • lastEventAt is maxed by string comparison (source.js:561, :604) over
    values from two producers with different shapes: Claude's raw
    event.timestamp attribute (events.js:175) and isoFromUnixNano's
    toISOString() (events.js:140). "...T12:00:00Z" sorts above
    "...T12:00:00.000Z" lexically, and a non-Z offset compares wrong outright.
    Parse to ms before comparing. (Harmless for display -
    formatEntrypointAge degrades to "at an unreadable time" - but it feeds the
    capture-health baseline.)

5f. Two smaller shapes worth a decision - low

  • A partially-written body file is deleted permanently
    (bodies.js:102-107): anything that does not parse as a plain object is
    fs.rm'd at once. If the api_request_body log record can reach the listener
    before Claude Code has finished flushing the file - nothing in this PR asserts
    an atomic rename upstream - truncated JSON is destroyed rather than retried. A
    one-batch grace (delete on second sighting, or on age) would avoid losing
    content to a write race; the sweep already bounds the downside.
  • hyp purge's consent text understates its scope: purge.js:66-68 asks
    "Permanently delete <target> from the local cache?" for what may be a single
    session, and purge.js:110 then empties the entire <hyp-home>/spool.
    Intentional per LLP 0253 and reasoned in the code comment ("a targeted purge
    cannot tell which of them belong to its target"), but the sentence the user
    agrees to does not say it.

Capture-path safety

Re-checked against CLAUDE.md's Log-Driven Development rule; it holds, with 5d
and 5a as the two exceptions above.

Both privacy gates run before the spool is read and before any write
(.../telemetry/source.js:613 then :625, with loadSpooledBodies only at
:635), so there is no window for a verdict to arrive after the data, and a
dropped session's bodies are deleted rather than skipped. The body_ref
containment is correct: path.resolve normalizes .. before the
startsWith(spoolRoot + path.sep) test, so ..-escapes, absolute paths outside
the spool, sibling directories sharing the prefix, and relative refs are all
refused, on both the read and the delete arm. sweepMarkerSpool
(client_detach_disk.js:777) reads its path from a hand-editable marker and
gates it through isCaptureSpoolDir, which requires the parent to be exactly
<hyp-home>/spool after resolution - depth-one containment, not "somewhere
under the root", so it cannot be walked into a nested tree. sweepCaptureSpool
uses lstat, not stat. Detach and purge log counts and bytes, never
filenames. The spool is 0700.

Two things a reader should know rather than discover: the attach block
deliberately turns on OTEL_LOG_USER_PROMPTS, OTEL_LOG_ASSISTANT_RESPONSES,
OTEL_LOG_TOOL_DETAILS and OTEL_LOG_RAW_API_BODIES
(.../claude/src/settings.js:144-152) - that is the product - and
sessionBodyFacts (projection.js:320-334) is capped at 64 entries, each
holding a full system prompt plus tool declarations, with no time-based
eviction, so the daemon can hold tens of MB of raw prompt text in memory for its
lifetime. Bounded, as designed; just larger than "64" suggests.

@ref and LLP hygiene

Clean. All 380 @ref LLP NNNN annotations across the 101 changed files
resolve, against every anchor form this corpus uses (explicit {#anchor} on a
heading, {#anchor} on a bold paragraph lead-in, <a id="...">, and slugified
heading text). No stray LLP 0245 / LLP 0251 survives the renumber. The 8
unresolvable refs repo-wide are illustrative strings in
.claude/skills/*/SKILL.md and deliberate fixtures in
test/core/llp-ref-hygiene.test.js:469,471, identical on origin/master. The
six anchors my own changes cite (0086 §status-drift-diagnostic, 0114
§fallback-is-visible and §explicit-listen-fails-loudly, 0225 §one-vocabulary,
0257 §status-and-health, 0258 §env-keys) all resolve.

All 11 modified llp/ docs are Accepted or Active on master, and the diff over
them is 38 insertions, 0 deletions - every hunk a **Extended-by:** or
**Superseded-by (in part):** header line after the existing Related: /
Spawns: line. No body prose touched, no Status: line touched. My own LLP
edits are confined to llp/0257, a doc this PR mints (S17b, S17c).

One nit: llp/0232-claude-attaches-by-proxy.decision.md:16 spells the key
**Superseded-by (in part):** rather than plain Superseded-by:, which strict
key-matching tooling would miss. The Status: Accepted it keeps is right, since
the supersession is partial.

Needs a real machine

Unchanged from round 1, and still the gate that matters most:
docs/ACCEPTANCE.md#claude_otel_shape_check. claude_telemetry_capture POSTs a
fixture we wrote, so it agrees with itself whatever upstream did. Only a real
Claude Code >= 2.1.214 against a real daemon can catch a renamed event, a
dropped flag, or a changed raw-body format - whose failure mode is null columns,
not an error. Please run it and record claude --version plus the observed
event-name list before this leaves draft. Two things worth confirming in the
same sitting: that OTEL_EXPORTER_OTLP_PROTOCOL=http/json
(.../claude/src/settings.js:147) is a value the installed client honours, and
whether it reads the per-signal keys of finding 3c from the settings env block
at all.

Still a draft, not merged, not marked ready: that call is yours.

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Aug 18, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage: one residual is a production blocker

Neutral triaged the residual findings from review rounds 1 and 2 at head 487bf2d5 (this PR has exhausted its 2-round review cap). Every finding was re-verified against the tree, not taken from the review records. One is a true blocker; the rest are deferrable and are listed below so they are not lost. This is a capture-path change, so privacy defects are weighed as production defects.

Blocker: the spool byte cap is enforced only while the daemon runs, so a down daemon accumulates raw prompt bodies without bound

Every enforcement of enforceClaudeBodySpoolCap lives inside the listener source: the one-shot sweep at start (hypaware-core/plugins-workspace/claude/src/telemetry/source.js:191) and the 60-second timer (source.js:259, cleared only on stop()). The only other sweeps are user-initiated: hyp purge (src/core/commands/purge.js:108) and detach (src/core/config/client_detach_disk.js:790). Nothing else touches the directory.

Meanwhile the attach writes OTEL_LOG_RAW_API_BODIES: file:<hyp-home>/spool/claude-bodies plus OTEL_LOG_USER_PROMPTS=1 and OTEL_LOG_ASSISTANT_RESPONSES=1 into the client's settings (hypaware-core/plugins-workspace/claude/src/settings.js:142-152), and Claude Code "keeps writing whether or not the daemon is reading" (LLP 0253, Context). So on any machine where the daemon is not running - crashed service, failed install, uninstalled without detach, or a laptop where it was simply never started - raw request and response bodies accumulate at roughly 145 KB per request with nothing bounding them and nothing deleting them.

Why this is a production defect and not a deferrable disk nit:

  • It voids the condition the design was accepted under. LLP 0253's summary promises the cap exists "so a down daemon can never fill the disk" (llp/0253:13), and its Consequences section asserts "Disk growth from capture is bounded by the cap" and "Nothing in the spool outlives the user's decision to remove it, which is what lets LLP 0262 accept transient presence at all" (llp/0253:71-79). LLP 0262 accepted the spool because it is transient; with no daemon it is permanent and unbounded. The normative section even names the window: "the window this exists for is precisely the one where the reader is not running" - and daemon-down is exactly that window, with zero enforcement.
  • The window includes a supported flow, not only failure states. resolveAttachTelemetryPort's third rung (source.js:864) deliberately supports attach before the first daemon start. In that supported state, capture of raw bodies to disk begins immediately and no enforcement exists at all until the daemon first runs.
  • It is a privacy-shaped defect. This PR deliberately switches on raw prompt and response export; its entire containment story is LLP 0253 (bounded, transient, swept, owner-only). Unbounded indefinite retention of raw prompts the user never agreed to keep - in a directory a non-excluding backup tool will happily ship - is a privacy exposure by the standard this repo's own Log-Driven Development rules set, on top of the eventual disk exhaustion.

The decision that unsticks this

A one-line choice from the author, then a small change:

  1. Enforce out-of-daemon. hyp claude-hook session-context is already wired to UserPromptSubmit (settings.js:64), runs in a separate process at exactly the cadence bodies are written, and already resolves HYP_HOME. A best-effort enforceClaudeBodySpoolCap call there delivers 0253's stated guarantee. Open sub-questions that make this the author's call: whether a hook may delete data, and what latency budget a per-turn hook has.
  2. Or enforce at another daemon-less touchpoint (attach itself, hyp status), accepting a coarser cadence.
  3. Or accept the weaker guarantee by minting a new LLP that supersedes/extends 0253's bounded-disk claim (0253 is Accepted, so the doc cannot simply be edited) and states the down-daemon behavior honestly.

Neutral did not pick: options 1 and 2 change who is allowed to delete captured data, and option 3 weakens an accepted privacy guarantee. Both are design calls above a reviewer's station.

Verified non-blockers, held for a follow-up once unstuck

All re-verified at head; none blocks on its own:

  • Picker still composes proxy mode (medium, design conflict): hypaware.plugin.json:58 still declares compose.gateway_proxy_mode: true while 0243's own Extended-by: LLP 0262 line says the Claude row stops declaring it; maybeOfferProxyModeMigration (src/core/commands/clients.js:822) can still offer a proxy migration right after an OTEL attach. Capture still works either way (OTEL attach is untouched; the CA mint sits behind its own consent gate), so this is a contradiction between two Accepted docs that needs a new decision naming LLP 0243, not a blocker. Left strictly alone per both review rounds.
  • dedupeStoredPartIds full-dataset plus spool scan per live write (medium): .../ai-gateway/src/dataset.js:539 scans every committed partition per projection per POST; the early exit (:731-732) never fires for genuinely new rows. Degrades with use ("the listener got slow after a month"); bounding it is a dedupe-contract design call.
  • hyp session status asks only the gateway (medium): .../ai-gateway/src/session_command.js:256 resolves one endpoint while runMutation (:339 on) posts to every advertising recorder, so an ignore that landed on the listener alone reads back status: unknown. Reporting inconsistency; the drop itself lands.
  • lastEventAt stamped before both policy gates (medium): source.js:560-563 and :602-606 advance it before partitionIgnoredSessionEvents and applyUsagePolicy, so a machine whose SessionStart hook never landed withholds everything as undetermined, writes zero rows, and reports capture health ok. Real, but the withhold itself is the deliberately fail-closed privacy gate working; moving the stamp would false-alarm on fully-hypignored machines. The honest fix is a new diagnostic reading the already-published events_undetermined counter - design work, deferrable, and the evidence is at least visible in status details today.
  • Retried batch loses usage and cost attributes (medium): projection.js:248-252 and bodies.js:359-363 consume usageByRequestId during projection, before the writes (source.js:657 vs :673), so a write failure plus exporter retry re-projects rows without attributes.usage or claude.cost_usd. Degraded rows in an already-failing path, not lost exchanges.
  • client_attach_stale fires against the gateway for an otel client (low): src/core/daemon/status.js:1144-1161 compares the marker's gateway port mode-independently; misleading but the repair is idempotent and the new client_telemetry_stale covers the port that matters.
  • body_ref logged verbatim (low): source.js:648-653 writes a refused (by definition out-of-spool, possibly attacker-supplied) path into a warn line an operator sink may ship; the rest of the PR logs basenames/counts only.
  • Counting nits (low): deleteSpooledBodies counts ENOENT as deleted (bodies.js:133-145, pinned by its own test); suppressSession (source.js:400) never decrements state.spoolBytes; lastEventAt is maxed by string comparison over mixed timestamp shapes (source.js:561, :604).
  • Two shapes worth a decision (low): an unparseable (possibly still-being-written) body file is deleted on first sighting (bodies.js:102-107); hyp purge's consent sentence (purge.js:66-68) does not say the whole spool is swept (purge.js:108).

Also carried for whoever picks this back up: the real-machine acceptance gate docs/ACCEPTANCE.md#claude_otel_shape_check is still the check that matters most before this leaves draft, and open PRs #777 and #800 both touch src/core/daemon/status.js and src/core/daemon/types.d.ts, which this PR edits heavily.

Reply with a comment on this PR (or push to the branch); neutral monitors this thread and will re-engage with your guidance on its next tick.

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) neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human

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