Skip to content

Forward Claude OTEL datasets to central - #997

Merged
philcunliffe merged 8 commits into
masterfrom
integration/claude-otel-central-forwarding
Aug 24, 2026
Merged

Forward Claude OTEL datasets to central#997
philcunliffe merged 8 commits into
masterfrom
integration/claude-otel-central-forwarding

Conversation

@philcunliffe

@philcunliffephilcunliffe commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • forward non-legacy plugin datasets through the central server arbitrary-dataset protocol
  • register dataset schemas once per sink lifetime before ingest
  • migrate same-port Claude proxy attachments to the current OTEL mode

Why

HypAware 1.25.0 captures claude_telemetry_events locally but the central sink rejects its claude_telemetry source signal. Existing proxy-mode markers can also short-circuit the proxy-to-OTEL migration when the gateway port is unchanged.

Verification

  • Hyperparam org live canary: exactly 23 fresh Claude 2.1.241 events arrived remotely
  • confirmed the 2,090 historical local OTEL events were not backfilled
  • npm test: 5,062 passed, 2 skipped
  • npm run typecheck
  • npm run smoke -- claude_telemetry_capture
  • npm run smoke -- client_attach_idempotent

No server change is required; the server already implements dataset registration and arbitrary-dataset ingest.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Dual-agent review — request_changes

  • Verdict:request_changes
  • Risk class:medium
  • Auto-merge advisory: 👎 thumbs down — verdict is request_changes; needs human-gated follow-up

Advisory only: no merge was attempted.

Risk capstone

Cross-reference: reviewer findings vs high-risk surfaces

SourceFinding (severity, evidence)Intersects
ClaudeGuard removal silently opts every derived dataset into central export (major, sink.js:176)Risk 1 (privacy/export seam); corroborated by Codex cat-6
Codex6) Security Surface: registration drops localOnlyContentColumns privacy contract (major, sink.js:582)Risk 1 (privacy/export seam); corroborated by Claude
Claudeproto.md wire-contract doc stale (major, central/proto.md:133)Risk 2 (cross-repo wire contract)
ClaudeNo test pins registration failure / once-only / 401 (minor, central-forward-chunking.test.js:289)Risk 3 (missing coverage)
Codex1) Behavioral Correctness: forwardingTarget throws outside per-partition catch (minor, sink.js:93)Risk 4 (lifecycle gap; driver-caught, effectively unreachable)
ClaudeMode-staleness defeats no-rerun-on-parse-miss guarantee (minor, action_attach.js:280)Risk 4 (reconciler settling)
ClaudeOpen-dataset forwarding decision has no LLP (minor, sink.js:169)Risk 2 (contract documented nowhere)
ClaudeStale "unknown signal" comment example (nit, sink.js:116)(comment hygiene; no risk surface)
Codex review

Fix Validations

Claude OTEL central forwarding

  • Status: correct
  • Evidence:sink.js, sink.js, central-forward-chunking.test.js
  • Assessment: Claude telemetry now resolves to arbitrary-dataset forwarding, registers claude_telemetry_events, and POSTs under the dataset name. The targeted test covers registration-before-ingest for that dataset.

Same-port Claude proxy-to-OTEL migration

Findings

6) Security Surface

  • Severity: major
  • Confidence: medium
  • Evidence:sink.js, hypaware-plugin-kernel-types.d.ts, datasets.js, sink.js
  • Why it matters: Arbitrary dataset registration sends only schema/source/timestamp metadata, so datasets like context graph that declare localOnlyContentColumns lose the privacy visibility contract when forwarded to central.
  • Suggested fix: Include the relevant DatasetRegistration privacy metadata in the registration payload and add a regression with a dataset declaring localOnlyContentColumns; if the central protocol cannot preserve it yet, skip or reject forwarding those datasets until it can.

1) Behavioral Correctness

  • Severity: minor
  • Confidence: high
  • Evidence:sink.js, sink.js, sink.js, driver.js
  • Why it matters: A missing local dataset registration now throws before the per-partition catch, so one bad partition becomes a whole-batch driver failure instead of the partition-granular retry path.
  • Suggested fix: Move forwardingTarget() inside the partition try, and log/retry only that partition when target resolution fails.

No Finding

  1. Contract & Interface Fidelity, 3) Change Impact / Blast Radius, 4) Concurrency, Ordering & State Safety, 5) Error Handling & Resilience, 7) Resource Lifecycle & Cleanup, 8) Release Safety, 9) Test Evidence Quality, 10) Architectural Consistency, 11) Debuggability & Operability.

Evidence Bundle

Claude review

Claude review

Guard removal silently opts every derived dataset into central export

  • Severity: major
  • Confidence: 85
  • Evidence: hypaware-core/plugins-workspace/central/src/sink.js:176
  • Why it matters: The removed KNOWN_SIGNALS guards were the only barrier keeping non-signal datasets off the wire: the sink driver hands the central sink partitions from every registered dataset (src/core/sinks/driver.js:164, no dataset scoping), so context-graph node/edge, context-graph-enrich, and gascity flip from never-exported (loud per-partition failure) to auto-registered-and-forwarded, and the export seam (readRowsSince, src/core/cache/storage.js:244) enforces only cwd/attribution withholding - localOnlyContentColumns suppression (LLP 0105) lives solely in the query seam, so graph content columns (label, props, source_keys, which "can carry session-derived text and paths" aggregated from local-only sessions, context-graph/src/datasets.js:75-78) ship to the org server unredacted.
  • Suggested fix: Gate forwardingTarget's open-dataset branch on an explicit opt-in (e.g. only forward datasets that declare a sourceSignal, which covers claude_telemetry_events via 'claude_telemetry' and matches the PR's stated scope) or apply localOnlyContentColumns nulling in readRowsSince before any cwd-less derived dataset is allowed onto the wire.

Wire-contract doc proto.md left stale by the new dataset protocol

  • Severity: major
  • Confidence: 90
  • Evidence: hypaware-core/plugins-workspace/central/proto.md:133
  • Why it matters: LLP 0014 (llp/0014-sinks.spec.md:97) names proto.md as the home of the client-server wire contract, and CLAUDE.md's living-docs rule says a design change lands its doc edit in the same commit; after this PR the doc is wrong on two points it states as contract: {signal} "is one of" exactly logs|traces|metrics|proxy (the sink now also PUTs /v1/datasets/{name} and POSTs /v1/ingest/{dataset} for everything else), and the signal "resolves from the dataset's sourceSignal" (a non-legacy dataset like claude_telemetry_events now ingests under its dataset name even though it declares sourceSignal: claude_telemetry).
  • Suggested fix: Update proto.md in this PR: document PUT /v1/datasets/{name} (body fields, idempotency, 401 refresh, once-per-sink-lifetime announcement) and amend the Ingest section so the four legacy signals are the special case and every other dataset POSTs under its dataset name after registration.

No test pins the dataset-registration failure or once-only contract

  • Severity: minor
  • Confidence: 85
  • Evidence: test/plugins/central-forward-chunking.test.js:289
  • Why it matters: The PR's new wire step has three behavioral claims its own comment makes (a failed PUT leaves the partition retryable, a 401 refreshes once, registration fires once per dataset per sink instance), and none is tested - the only registration tests are the two happy paths, so a regression (e.g. dropping the registeredDatasets memo, or moving ensureDatasetRegistered out of the try so a PUT failure escapes the partition-granular retry) would pass the suite silently.
  • Suggested fix: Add three tests: (1) responder fails the PUT (500) -> assert status: 'failed', retryPartitions.length === 1, no /v1/ingest/ POST; (2) two exportBatch calls on the same sink -> exactly one PUT total; (3) responder 401s the first PUT -> one identityClient.refresh() and a second PUT with the same body.

Mode-staleness check defeats the documented no-rerun-on-parse-miss guarantee

  • Severity: minor
  • Confidence: 80
  • Evidence: src/core/config/action_attach.js:280
  • Why it matters:parseAttachOutput's contract (action_attach.js:585-587) is that a payload parse miss "records done without detail rather than re-running a successful attach", but a detail-less claude marker now fails marker.mode !== 'otel' in isCurrent, so any claude attach whose report payload fails to parse re-runs the attach and rewrites the user's settings.json on every reconcile pass, never settling.
  • Suggested fix: In perform(), when client === 'claude' and the attach succeeded but parsed?.mode is absent, record detail.mode = 'otel' anyway (the claude adapter has exactly one attach mode by LLP 0258), and update the parseAttachOutput doc comment.

Open-dataset forwarding is a real design decision with no LLP

  • Severity: minor
  • Confidence: 80
  • Evidence: hypaware-core/plugins-workspace/central/src/sink.js:169
  • Why it matters: This is a settled chose-X-over-Y (register-then-ingest under the dataset name, rather than widening KNOWN_SIGNALS or minting a fixed claude_telemetry signal) that also removes a deliberately guarded invariant, and the nearest existing doc, LLP 0255's "the rows forward centrally by the same rules message rows follow", no longer describes what shipped, so per CLAUDE.md the choice is undocumented.
  • Suggested fix: Mint one small decision LLP (Systems: Sinks) settling the open-dataset forwarding protocol and the legacy-four split, add it to LLP 0255's forward-refs (Extended-by:), and anchor forwardingTarget/ensureDatasetRegistered with @ref annotations to it.

Stale "unknown signal" example in the failure-annotation comment

  • Severity: nit
  • Confidence: 82
  • Evidence: hypaware-core/plugins-workspace/central/src/sink.js:116
  • Why it matters: The comment explains undefined batch_id/chunks_sent "for pre-stream failures like an unknown signal", but this PR deleted the unknown-signal throw, so the named example is now impossible.
  • Suggested fix: Change the example to "pre-stream failures like a failed dataset registration".

Reports: /Users/phil/workspace/hypaware/.git/worktrees/hypaware-pr997-review.jGJRMs/dual-review/pr-997

… docs
Review round 1 on PR #997 at d20d807.
- `exportBatch` resolved the wire target OUTSIDE the per-partition try.
`forwardingTarget` now throws (unresolvable dataset), and that throw
escaped `exportBatch` into the driver's outer catch, which respools the
whole batch and reports zero exported, discarding partitions already
POSTed in the same loop. Resolve inside the try so one bad partition
costs one partition, which is the contract the loop documents.
- The registration path escaped the dataset name and the ingest path did
not, so with an arbitrary dataset name in `/v1/ingest/{signal}` the two
calls could name different resources (and `joinUrl`'s `new URL()` would
normalize a `..` segment out). Escape both the same way; the four legacy
signals are encode-invariant, so their URLs are unchanged.
- `TELEMETRY_EVENTS_SOURCE_SIGNAL`'s docstring still said rows POST to
`/v1/ingest/claude_telemetry` and that the declaration exists so
forwarding "never falls back to the dataset name", which is now exactly
what happens. Corrected; its LLP 0255 ref still holds.
- `proto.md` documented no `PUT /v1/datasets/{name}` and still said
`{signal}` is one of four. Document the announce call and the two ways a
signal now resolves, and mark the server side unverified from this repo.
- `isCurrent`'s JSDoc said two things can go stale; there are three now.
Document the mode axis, and give `ActionMarker.mode` a typed field
beside its `endpoint` / `assets_key` siblings instead of leaving it
`unknown` under the index signature.
- Tests pinning the claimed bounds rather than restating them: the
announce happens once per sink instance across two ticks; a rejected
announce fails the partition, never reaches ingest, and is NOT
remembered (so the next tick re-announces); an unresolvable dataset
fails only its own partition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 1: d20d8077

Verdict: changes requested. The attach-migration half is sound and correctly
annotated. The central-forwarding half has one high finding that is a scope
question only a human can settle, one claim in the PR description that the code
contradicts, and five smaller issues. Six items are fixed and pushed as
7efff0bc; five remain open below.

Method: the code-review skill at high effort plus an independent direct pass
over the diff and whole-file reads. Both passes are merged here.


Open findings (not fixed)

H1 (high) The sink forwards every registered dataset, not the Claude OTEL ones

hypaware-core/plugins-workspace/central/src/sink.js:169-182

Removing the KNOWN_SIGNALS guard turns the forward sink from a four-signal
allowlist into "forward every dataset the kernel registers". The driver hands
every dataset from queryRegistry.listDatasets() to every sink
(src/core/sinks/driver.js:163) and CentralSinkConfig has no dataset filter,
so the eligible set is the whole local catalog, not claude_telemetry_events.

The concrete consequence is a privacy one. @hypaware/context-graph is bundled
(src/core/runtime/bundled.js:38). Its context_graph_nodes / _edges rows
carry label, props, natural_key, and source_keys derived from prompt and
message content, and they carry no cwd column: that is exactly why
hypaware-core/plugins-workspace/context-graph/src/datasets.js:101 declares
localOnlyContentColumns. The LLP 0070 export-seam withhold at
src/core/cache/storage.js:310 keys on the row's own cwd, so it can never
fire on these rows. A user whose .hypignore keeps their ai_gateway_messages
rows local would still ship the derived graph rows built from that same content.
@hypaware/context-graph-enrich's prospect / resolution / committed tables are
in the same position.

Not fixed here, deliberately: narrowing the forwardable set (an allowlist, a
config filter, or a fail-closed guard on localOnlyContentColumns) is a design
decision, and the export seam has no equivalent of the query seam's
LLP 0105 suppression to extend. This is the question for the author.

M4 (medium) The "historical events were not backfilled" claim is contradicted by the code

The PR reports that a live canary forwarded exactly 23 fresh Claude 2.1.241
events and that 2,090 historical local OTEL events were NOT backfilled. I could
not find a mechanism that produces that, and the code says the opposite.

forwardPartition loads the (sink instance, partition) watermark; a dataset
that never forwarded has none, so since === undefined, which sets
includeLegacy = true (sink.js:315-320) and readRowsSince scans the
partition from the start (src/core/cache/storage.js:244). The file's own
comment says so: "a missing/unreadable watermark reads from the start". The
existing test a fresh partition (no watermark) reads from the start and advances pins that behaviour. Before this PR claude_telemetry_events threw
on the unknown signal before any watermark could be written, so on the first
tick after this change every historical row in it (and in every other
newly-eligible dataset) is forwarded.

Full-history-on-first-export may well be the intended at-least-once semantics,
but it is not what the PR says happened, and no test pins "not backfilled". This
matters because it is the first-tick volume and privacy surface of H1. Please
re-check the canary, or say which mechanism bounded it.

M5 (medium) marker.mode is a freshness key recorded only on a best-effort parse

src/core/config/action_attach.js:210 records detail.mode only when
parseAttachOutput returns an object. That function's own contract
(action_attach.js:583) is that an unparseable payload returns undefined "so
the caller records done without detail rather than re-running a successful
attach". isCurrent now reads marker.mode !== 'otel' as stale, so a claude
attach whose stdout does not parse writes a done marker with no mode and the
reconciler re-performs the claude attach on every pass, forever. There is no
self-heal, because the thing that would heal it is the same parse.

endpoint and assets_key are both recorded unconditionally from perform()'s
own knowledge. mode cannot be, since perform() is client-generic. Not fixed:
every repair changes what an absent mode means, and absent-means-stale is
load-bearing for the migration itself (no reconciler-written marker on any
existing machine carries a mode, so a check restricted to mode === 'proxy'
would migrate nothing). It needs the author's intent.

In practice this is latent, not live: the claude adapter's json: true path
writes exactly one JSON line and returns (claude/src/index.js:359,
writeAttachOutput), so nothing today trails prose after it.

L3 (low) "Once per sink lifetime" is not concurrency-safe

sink.js:590-596. registeredDatasets.has() is checked, then the PUT is
awaited, then .add() runs. The daemon schedules ticks with
setInterval(() => { void runTick() }) (src/core/daemon/runtime.js:659), so
ticks overlap whenever one runs long, and one chunk alone can park up to five
minutes on backpressure. Two overlapping exportBatch calls will both announce.
Harmless (the announce is idempotent server-side, and the same overlap already
double-streams partitions, which the batch-id ledger dedupes) but the bound is
"once per successful announce, per sink instance, absent overlap", not "once".

L4 (low) The announce runs before the missing-partition guard

sink.js:100-110 calls ensureDatasetRegistered before forwardPartition
reaches its !partition.tablePath || !storage.tableExists(...) check
(sink.js:228). A discovered-but-missing partition therefore still announces
its schema to central: a wasted round trip per tick, and it creates server-side
datasets that may never receive a row.


Fixed and pushed (7efff0bc)

M1 (medium) The wire target was resolved outside the per-partition try

forwardingTarget now throws (dataset '...' is not registered locally), and at
d20d8077 it was called at sink.js:93, outside the try. The old
signalForPartition could not throw, and the unknown-signal error was raised
inside forwardPartition, so it produced partial / failed with just that
partition in retryPartitions. As written the throw escaped exportBatch into
the driver's outer catch (src/core/sinks/driver.js:115), which respools the
whole batch, reports zero exported, and discards the bytesWritten /
partitionsExported of partitions already POSTed earlier in the same loop. Since
the driver rebuilds the same partition list each tick, every partition ordered
after the bad one would be starved permanently, not transiently.

Fixed: resolve inside the try, and read target?.ingestName ?? partition.dataset
in the failure log. Also corrected the catch comment, which still named "an
unknown signal" as the pre-stream failure mode after that error was deleted.

L1 (low) Registration escaped the dataset name, ingest did not

ensureDatasetRegistered built /v1/datasets/${encodeURIComponent(name)} while
postNdjson interpolated /v1/ingest/${signal} raw and passed it through
joinUrl's new URL(), which normalizes . and .. segments. With an
arbitrary dataset name now in that position (the registry validates only
non-empty, src/core/registry/datasets.js:48), the two calls could name
different or truncated resources. Fixed by escaping both identically; the four
legacy signals are encode-invariant, so their URLs are byte-identical to before.

M3 (medium) A docstring the change falsified, with a live @ref

hypaware-core/plugins-workspace/claude/src/telemetry/events_dataset.js:31-38
still read: "The ingest signal the central forward sink POSTs this dataset's
rows under (/v1/ingest/claude_telemetry). Declared so forwarding never falls
back to the dataset name, which is not a signal the server maps." After this
change the rows POST to /v1/ingest/claude_telemetry_events, which is precisely
the dataset-name fallback the comment said could not happen. Rewritten to
describe the announce-plus-dataset-name routing. Its @ref LLP 0255#owned-by-claude
still holds and is kept: registration does still set the source signal, it now
travels in the announce body instead of selecting the path.

M2 (medium) proto.md documented neither the new endpoint nor the new routing

hypaware-core/plugins-workspace/central/proto.md is this plugin's copy of the
wire contract and LLP 0014 defers to it by name. At d20d8077 it documented no
PUT /v1/datasets/{name} at all and still asserted {signal} is one of
logs|traces|metrics|proxy. Added the announce call (body shape, escaping,
idempotency expectation, failure handling) and rewrote the signal-resolution
paragraph to describe the legacy-four / open-dataset split.

One thing I could not settle and marked as such in the doc: whether the
deployed server serves PUT /v1/datasets/{name} at all.
If it does not, every
non-legacy dataset PUTs and fails on each tick, its partition respools, its rows
never drain, and the only symptom is a repeated central.forward.failed warn.
Nothing in this repo can answer that.

L2 (low) isCurrent documented two staleness axes; there are three

action_attach.js:230 opened "Two things can go stale". Added the mode axis to
the JSDoc in the same shape as the endpoint and asset-set paragraphs, and gave
ActionMarker.mode a documented field in src/core/config/types.d.ts beside its
endpoint / assets_key siblings. It was reaching the code as unknown through
the interface's index signature.

L5 (low) The tests restated the claimed bounds instead of pinning them

The three claims worth pinning had no coverage: announce-once, what a rejected
announce does, and per-partition isolation. Added to
test/plugins/central-forward-chunking.test.js:

  • a dataset schema announces once per sink instance, not once per tick: two
    exportBatch calls, exactly one PUT.
  • a rejected schema announce fails the partition and never reaches ingest: a
    500 on the PUT fails the partition, sends zero POSTs, and is not recorded
    as registered, so the next tick re-announces and then ingests.
  • an unresolvable dataset fails only its own partition: a two-partition batch
    where one dataset is missing locally returns partial with only that
    partition retried, and the healthy partition still ships. This is the
    regression guard for M1.

Reviewed and found correct

  • The attach-mode gate in clients.js:387.probe.mode is a real field
    (src/core/daemon/status.js:2395) fed by the marker the claude adapter writes
    (claude/src/settings.js:641), and writeAttachOutput puts mode on the
    --json payload, so perform() can record it. It cannot mis-migrate a
    not-actually-same-port attachment: a port mismatch already made the marker
    non-current before this change, so the mode gate only ever adds staleness to
    markers that were being treated as current. It also cannot push a claude
    attach into the no-endpoint give-up path, because modeCurrent is short
    circuited by liveEndpoint === undefined, which is exactly the branch that
    gives up. claude-desktop is a distinct client name and is unaffected.
  • @ref LLP 0262#migration resolves (LLP 0262 has a ### Migration
    section) and both sites say something the code and filename do not.
  • The registration failure path. A non-2xx throws, the partition lands in
    retryPartitions, the name is not added to the registered set, and the next
    tick re-announces. A partial forward cannot double-send: the announce precedes
    the first chunk, and a forward that fails after a successful announce resumes
    from the same watermark with the same chunk boundaries and the same batch ids.
  • The 401 path refreshes once through the same identity seam as ingest and
    escalates a second 401 to a throw.
  • Conventions: no semicolons, no em dashes, no inline import('...') types,
    @import at the top, no @typedef, DatasetRegistration imported from the
    root kernel contract with a .js specifier. Clean.

The LLP question

neutral observe flags this change set as owing a plan. My read:

The attach half is covered. LLP 0262 §Migration settles it, and both new
sites carry @ref LLP 0262#migration. Nothing new is decided there. One small
gap: isCurrent now has a third freshness axis that LLP 0086 (endpoint-aware
markers) and LLP 0107 / 0138 (asset-set currency) between them describe as two.
A forward ref on LLP 0086 would keep that honest, but the decision itself is
already recorded in 0262.

The central-forwarding half is not covered, and it decides several
non-obvious things with no record and no @ref anywhere in the diff.
I could
not find any LLP mentioning /v1/datasets, an arbitrary-dataset ingest
protocol, a legacy-four split, or who owns schema registration. Grep over
llp/ for v1/datasets, "arbitrary dataset", "open dataset" returns nothing.
The nearest documents are:

  • LLP 0014 §Export contract / §Forward sink backpressure (Active,
    Systems: Sinks): the forward sink's contract. It defers the wire detail to
    proto.md, which is where the four-signal table lives. This change replaces
    that table's meaning, so 0014 is the doc a new decision should extend.
  • LLP 0040 (incremental sink reads): the watermark contract this rides on.
    Unchanged, but it is what makes the first-export-reads-everything behaviour
    in M4 a documented consequence rather than a bug.
  • LLP 0070 / 0069 / 0105 / 0188 (local-only export seam, dir selection,
    query visibility, source opt-out): the privacy invariants H1 lands on. LLP
    0105 §graph-provenance explicitly reasons about datasets whose rows cannot
    prove provenance, and settles it on the query side only. Whether the export
    side may forward those rows is undecided, and this change decides it by
    omission.
  • LLP 0255 §owned-by-claude: says registration sets the source signal so
    rows "forward centrally by the same rules message rows follow". Still true in
    spirit, but the mechanism it names has changed.

So: yes, this realizes and extends documented design, and yes, it introduces
non-obvious decisions CLAUDE.md's conventions say should be recorded. At
minimum a decision covering (a) the legacy-four vs open-dataset routing split,
(b) who announces a schema and when, and (c) which datasets a joined machine
may forward
, extending LLP 0014 and referencing LLP 0070 / 0105. The code
sites that would then carry @refs are forwardingTarget and
ensureDatasetRegistered, which today carry none, and the CLAUDE.md
"land the doc edit in the same commit" rule was not met. I have not minted
anything; that is the author's or the orchestrator's call.


Checks

Run in a detached worktree at 7efff0bc.

  • npm test: 5067 tests, 5063 pass, 3 fail. The same 3 fail unchanged at
    d20d8077, and all 3 are artifacts of this sandbox's stale node_modules
    (hypgrep absent, icebird 0.8.22 against the pinned 0.8.25):
    hyparquet-floor-pin, storage.dataSourceForTable ..., purged rows stay purged .... Delta from my changes: +3 tests, +3 pass, 0 new failures.
  • npm run typecheck: clean.
  • npm run smoke -- claude_telemetry_capture: ok.
  • npm run smoke -- client_attach_idempotent: ok.

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

Copy link
Copy Markdown
ContributorAuthor

Stuck: this change needs a decision only you can make

What neutral was doing. Review rung on PR #997 (change set claude-otel-central-forwarding). Round 1 reviewed head d20d8077, fixed six findings, and pushed them as 7efff0bc. The full review record is here. Neutral is stopping rather than opening round 2, because the largest finding is a privacy question it must not answer on your behalf.

Why it cannot proceed

1. The sink forwards every registered dataset, not just the Claude OTEL ones (high).
The driver hands all of queryRegistry.listDatasets() to every sink and CentralSinkConfig has no filter, so the change is broader than its title. Bundled @hypaware/context-graph rows (label, props, natural_key, source_keys) are derived from prompt and message content and carry no cwd, which is exactly why they declare localOnlyContentColumns. The LLP 0070 export seam withholds on cwd, so it can never fire on those rows. Concretely: a user whose .hypignore keeps message rows local would still ship the graph rows built from that same content. This was left unfixed deliberately, because narrowing the forwardable set is a design decision with no existing export-seam analogue to extend.

2. The first tick forwards the entire local history, contradicting the PR description (medium).
The PR states that 2,090 historical local OTEL events were not backfilled. The code says otherwise: with no watermark, since === undefined, so includeLegacy = true and the scan starts from the beginning. The previous code threw before any watermark could be written, so the first tick after this change forwards every newly eligible dataset's whole local history. An existing test already pins that behaviour, and nothing pins the "not backfilled" claim. The canary observation was probably real but is not evidence the code guarantees it.

3. Unverifiable from this repo (medium). Whether the deployed central server actually serves PUT /v1/datasets/{name}. If it does not, non-legacy datasets PUT and fail every tick and never drain, and the only symptom is a repeated warn.

4. Latent re-attach loop (medium).marker.mode is now a freshness key but is recorded only on a best-effort JSON parse, while parseAttachOutput returns undefined precisely so a successful attach is not re-run. An unparseable claude payload yields a marker with no mode, and the reconciler then re-attaches every pass forever with no self-heal. Latent today since the adapter writes exactly one JSON line. Not fixed because absent-means-stale is load-bearing for the migration, so a repair needs your intent.

What neutral needs from you

  • Is forwarding the whole local catalog intended? If not, which shape do you want: an explicit allowlist, a CentralSinkConfig filter, or fail-closed on any dataset declaring localOnlyContentColumns?
  • Does the deployed central server serve PUT /v1/datasets/{name}?
  • Is the full-history first export intended? If not, the watermark needs seeding rather than defaulting to a scan from the beginning. Either way the PR description should stop claiming no backfill.
  • For marker.mode: should an unparseable payload read as stale (today's behaviour, loops forever) or as current (risks missing a real migration)?

Also worth your attention: no LLP covers this

The attach half is covered by LLP 0262 and both sites are annotated. The central-forwarding half carries no @ref at all, and nothing under llp/ mentions /v1/datasets, an arbitrary-dataset protocol, a legacy-four split, or who owns schema registration. LLP 0014 (Active, Systems: Sinks) is the doc a new decision should extend, since it defers wire detail to the proto.md table this change invalidates. The privacy question above lands on LLP 0070, 0069, 0105 and 0188; LLP 0105 reasons explicitly about unprovenanced graph rows but settles it on the query side only, so the export side is being decided here by omission. Neutral minted nothing and restructured nothing.

How to unstick

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.

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

Copy link
Copy Markdown
ContributorAuthor

Re-engaging after the new head: open-dataset forwarding now fails closed for datasets that declare local-only content columns, preserving the privacy contract while keeping Claude OTEL eligible.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Dual-agent review - request_changes

  • Verdict:request_changes
  • Risk class:high
  • Auto-merge advisory: thumbs down, findings require fixes and a fresh exact-head review

Advisory only: no merge was attempted.

Both independent reviewer families found the same major issue: an initial open-dataset baseline failure falls into the legacy full-history scan, violating the no-backfill guarantee and potentially replaying duplicates. The blast-radius pass also identified ordering and contract hazards around registration outages, overlapping ticks, reserved legacy route names, and old-server capability probing.

Codex review

1) Behavioral Correctness

  • Severity: major
  • Evidence:hypaware-core/plugins-workspace/central/src/sink.js:272, :283, :291, :375
  • Why it matters: a failed durable baseline resets since and sends the historical table.
  • Suggested fix: fail the open-dataset partition closed until its baseline is durable.
Claude review

Failed initial-history baseline reverts to full backfill

  • Severity: major
  • Confidence: 82
  • Evidence:hypaware-core/plugins-workspace/central/src/sink.js:266-302, :449-464, :375
  • Why it matters: transient state I/O can replay history repeatedly without checkpointing.
  • Suggested fix: separate the open-dataset initialization failure path from the legacy fallback.

Reports are retained in the local dual-review artifact directory for PR #997.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral review round 2

Verdict on the reviewed head:request_changes (dual-review risk class: high)

Findings

  1. Major, behavioral correctness:hypaware-core/plugins-workspace/central/src/sink.js:272-375 let a failed initial watermark baseline fall through to since = undefined, causing a full historical export and violating the no-backfill guarantee.
  2. Major, ordering/data loss: registration ran before the durable local baseline, so a registration outage could absorb post-upgrade events into a later skipped baseline.
  3. Major, concurrency: overlapping daemon ticks could scan and POST one partition concurrently, duplicating a suffix and racing the watermark backward.
  4. Major, wire contract: an open dataset could use a server-reserved legacy path name and ingest into a different built-in dataset.
  5. Minor, release compatibility: an older server's 404/405 response retried every tick instead of holding locally and re-probing slowly.

Fixed

All findings were fixed in 988226cc: open-dataset initialization now fails closed, the local baseline precedes registration, partition exports serialize, reserved legacy names are rejected, and old servers are re-probed after five minutes. The changed symbols and regression tests were positively verified against this reviewed head.

Validation: focused central/attach tests passed, npm run typecheck passed, npm test passed (5,077 tests, 5,075 passed, 0 failed, 2 expected skips), and both claude_telemetry_capture and client_attach_idempotent smokes passed.

… lost watermark
Round-2 neutral review of #997.
A dataset ruled ineligible (one declaring `localOnlyContentColumns`, or one
whose name a legacy ingest path reserves) was rejected by throwing, which
routed it into `retryPartitions`. That is a permanent verdict, not a transport
failure: `@hypaware/context-graph` is default-bundled, so on a joined machine
every tick wrote an outbox file the driver never drains and held the sink at
`partial` for a condition LLP 0305 calls correct. It is now a skip, named once
per sink instance in `central.forward.dataset_withheld` (info for the privacy
outcome LLP 0305 names, warn for the reserved-name plugin bug). Nothing is
announced and nothing is ingested either way, so the privacy guarantee is
unchanged.
`SinkWatermarkStore.read` returns `null` for both "no watermark was ever
written" and "a watermark is on disk but could not be read or parsed", and it
never throws, so the caller's catch cannot tell them apart. The open-dataset
baseline treated the second as the first: a truncated or transiently unreadable
watermark on a host that had been forwarding for weeks would jump the cursor to
the current high-water and permanently drop every row in between, silently,
while reporting `exported`. That turns LLP 0040's at-least-once degradation into
at-most-once loss. The baseline now stats the watermark path and refuses when a
file is there, which fails only that partition and retries next tick.
Also: `isCurrent`'s JSDoc still said `perform()` records the mode the adapter
reports, which f07c161 changed, and did not mention that a machine below the
LLP 0258 version floor now flips to a terminal `refused` marker.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Review round 2: f07c1617 (and 988226cc, which landed mid-review)

Verdict: changes requested, on one open privacy finding the code does not
reach. Two new defects found in the round-2 commits are fixed and pushed as
8528f28e. Five of the six stuck-report items are now closed; the sixth (H1)
survives verification only in part, and the part it misses is the reason the PR
was stuck.

Method: the code-review skill at high effort plus an independent direct pass
over the diff with whole-file reads. Both are merged here.

Head moved twice during this review. I started at f07c1617, and the branch
advanced to 988226cc ("Fix open dataset forwarding review findings") while I
was working. I re-derived everything against 988226cc and rebased my fixes
onto it. Everything below describes 988226cc plus 8528f28e.


Status of every stuck-report item

H1 (high, privacy) - the claim is half true. Read this one.

The <!-- neutral-ack --> said open-dataset forwarding "now fails closed for
datasets that declare local-only content columns". I traced the forwardable set
end to end. That sentence is literally accurate, and the mechanism is sound:

  • forwardingTarget (central/src/sink.js:240) is the single gate, and it runs
    before anything leaves the machine. The announce (ensureDatasetRegistered)
    now sits inside forwardPartition, after the missing-table guard and after the
    watermark baseline, so a withheld dataset leaks neither its rows, nor its name,
    nor its column list.
  • There is no bypass. CentralSinkConfig has no dataset filter or override; the
    only other route out is the KNOWN_SIGNALS early return above it, which is
    reachable only for a dataset declaring one of the four legacy sourceSignal
    values, and all four are cwd-bearing so the LLP 0070 export seam covers them.
    The reconnect / 401-refresh / 404-degrade paths all sit downstream of the gate.
  • Nothing regressed for @hypaware/context-graph. context_graph_nodes /
    _edges declare localOnlyContentColumns
    (context-graph/src/datasets.js:101), so they are withheld. That was the named
    example, and it is genuinely closed.

But the finding was not only about datasets that declare. The round-1 write-up
named @hypaware/context-graph-enrich as "in the same position", and the stuck
report asked about "the unprovenanced case LLP 0105 reasons about". Those
datasets are the case, and the new guard cannot see them:

hypaware-core/plugins-workspace/context-graph-enrich/src/datasets.js:124

enrichDatasetRegistration builds enrichment_prospects,
enrichment_resolutions, and enrichment_committed with no
localOnlyContentColumns and no cwd column
. Their schemas carry label,
props, evidence, source_keys, anchor_key, and note - the same
content-bearing, provenance-free shape context-graph declares, produced by an
LLM reading session text. readRowsSince's withhold keys on the row's own cwd
(src/core/cache/storage.js:311), which those rows do not have, so the export
seam passes them. forwardingTarget then reads them as an ordinary eligible open
dataset and forwards them in full.

LLP 0305 §eligibility makes this a dataset contract on purpose ("All other
registered datasets are eligible ... rather than a central-sink allowlist"). That
is an Accepted decision and I did not re-open it. Under that contract the defect
is in @hypaware/context-graph-enrich, which never applied LLP 0105
§graph-provenance to itself. The sink is doing what 0305 says.

I did not fix it, and this is the item to decide:

  • The blast radius is narrow but real: enrichment is opt-in
    (V1_EXCLUDED_FROM_DEFAULT, needs a completion provider plus vector-search) and
    central is opt-in, so it takes a machine that enabled both. Before this PR the
    combination leaked nothing, because no non-legacy dataset was forwardable at
    all. After it, it ships model-extracted knowledge items derived from
    local-only sessions.
  • Closing it means classifying 32 columns across three schemas as content or
    structural, the way test/plugins/context-graph-content-column-allowlist.test.js
    forces for context-graph. Two of them are judgement calls I will not make for
    you: anchor_key (the anchored node's natural key, which context-graph
    classifies as content) and item_id on enrichment_committed, which is a
    non-nullable natural key - suppressing it destroys the row's identity,
    where context-graph could suppress natural_key and keep the node_id hash.
  • The good news is that it is cheap and safe on the query side: every
    context-graph-enrich read goes through runSql, which passes
    includeLocalOnly: true (context-graph-enrich/src/sql.js:49), so declaring
    the columns would not disturb propose, curate, or batch.

Question for you: are those three enrichment datasets meant to be centrally
forwardable? If not, which columns are content?
Once you answer, the fix is one
declaration plus an allowlist test.

M4 (medium) - closed.

writeInitialHistoryBaseline (sink.js:540) now scans to the current
high-water, persists it with exportedRowCount: 0, and sends nothing, so a
newly eligible open dataset genuinely starts from now. LLP 0305 §start-now
records the decision and the code carries the @ref. The PR description's
"2,090 historical local OTEL events were not backfilled" is no longer
contradicted by the code - it is now a guarantee rather than a lucky
observation. No description edit is owed.

M2 (medium) - answered by the author, still unverifiable here.

proto.md now states the route is the fleet server's accepted catalog protocol
and was exercised against the deployed Hyperparam server on 2026-08-24 with
claude_telemetry_events. 988226cc also added a graceful degrade: a 404 or
405 holds the dataset locally, logs central.forward.dataset_unsupported, and
re-probes after five minutes instead of failing every tick. Nothing in this repo
can confirm the server side; recording the attestation is the right resolution.

M5 (medium) - closed.

action_attach.js:208 now records detail.mode = 'otel' from the requested
client rather than from the parsed payload, so an unparseable claude report can
no longer leave the marker permanently stale. parseAttachOutput's docstring was
updated to match. Verified against the adapter: every claude attach path emits
MODE_OTEL and rethrows on failure, so the hardcode cannot lie today.

L3 (low) - closed.

Two mechanisms now: datasetRegistrations single-flights the announce, and
partitionExports serializes one logical partition across overlapping ticks
with a test that gates a POST open and starts a second tick behind it.

L4 (low) - closed.

The announce moved inside forwardPartition, after the !partition.tablePath || !storage.tableExists(...) guard, so a discovered-but-missing partition no longer
registers a schema. There is a test. This incidentally also protects
@hypaware/s3's user-configured remote datasets, whose partitions carry
tableUrl rather than tablePath and would otherwise have announced an
arbitrary user schema to the org server.


New findings, fixed and pushed as 8528f28e

N1 (high, silent data loss) A lost or corrupt watermark re-baselines an open dataset and drops every row in between

hypaware-core/plugins-workspace/central/src/sink.js:540

SinkWatermarkStore.read returns null for both "no watermark has ever been
written" and "a watermark is on disk but could not be read or parsed", and it
never throws: src/core/sinks/watermarks.js:187 swallows the read error, and
parseRecord returns null on any malformed record. So the record === null
branch that triggers writeInitialHistoryBaseline cannot distinguish them, and
988226cc's new if (skipInitialHistory) throw in the catch never fires for
this case, because there is nothing to catch.

Concretely: a host has been forwarding claude_telemetry_events for a week. Its
watermark is truncated by a disk-full, hand-edited, or removed by a state-dir
reset. The next tick treats it as never-forwarded, writes a fresh baseline at the
current high-water, logs initial_history_skipped at info, and reports
exported. Every row between the real watermark and now is never forwarded, and
never will be
. A legacy signal in the same situation degrades safely to a full
rescan the server ledger dedupes. The open-dataset path turned LLP 0040's
at-least-once degradation into unrecoverable at-most-once loss, silently.

Fixed: writeInitialHistoryBaseline stats watermarks.filePath(key) and refuses
when a file is there. The refusal throws, which fails only that partition, so a
transient fs error self-heals on the next tick and a genuinely corrupt watermark
stays visible instead of quietly skipping rows. New test: a present-but-unreadable
watermark fails the open-dataset partition instead of re-baselining
.

N2 (medium, operability) Ineligibility was a permanent partition failure, so it filled the outbox forever

hypaware-core/plugins-workspace/central/src/sink.js:240

Rejecting a withheld dataset by throwing put it in retryPartitions, and the
driver writes <state>/sinks/forward/outbox/<batchId>.json on every non-ok
result (src/core/sinks/driver.js:237) with nothing in the repo draining it.
@hypaware/context-graph is in V1_BUNDLED_PLUGIN_ALLOWLIST, so on any joined
machine with a projected graph that is two partitions failing every tick,
forever: one outbox file per tick of unbounded state growth, a
central.forward.failed warn per tick drowning real failures, and a sink that
can never report exported - for a condition LLP 0305 calls correct. The
same applied to 988226cc's new reserved-name guard.

Fixed: forwardingTarget now returns a { withheld, level } verdict instead
of throwing, and exportBatch skips the partition and names it once per sink
instance in central.forward.dataset_withheld (info for the privacy outcome LLP
0305 names, warn for the reserved-name plugin bug, which silently forwards
nothing until someone renames the dataset). The privacy behaviour is byte-for-byte
unchanged: still nothing announced, still nothing ingested. Only the resolution
of an unresolvable local registry lookup still throws, which is genuinely
retryable. Three tests: the withhold is skipped and stated once, a withheld
dataset does not stop a sibling partition shipping, and the reserved-name case
reports at warn.

N3 (low, doc honesty) isCurrent's JSDoc described the mechanism f07c1617 replaced

src/core/config/action_attach.js:275. The mode paragraph still ended "because
perform() records the mode the adapter reports", which stopped being true when
perform() started hardcoding it from the requested client. Rewritten, and the
@ref LLP 0040#watermark-contract comment above the watermark block was
similarly re-stated: it claimed "never a silent skip", which the open-dataset
path no longer honours in the same way.

proto.md gained the two contract statements the code now makes: the withhold is
skipped rather than retried, and "no forward watermark" means none was ever
written, not merely one the client failed to read.


Open findings, reported not fixed

O1 (high, privacy) @hypaware/context-graph-enrich forwards unprovenanced derived content

See H1 above. context-graph-enrich/src/datasets.js:124. Needs your call on the
column classification.

O2 (low) An old server reports healthy while forwarding nothing

hypaware-core/plugins-workspace/central/src/sink.js:380. When registration
returns 404/405, forwardPartition returns 0 and exportBatch counts the
partition as exported, so the sink reports exported, sink.export_batch.ok
fires, and hyp status reads healthy while claude_telemetry_events drains
nowhere. The only signal is one central.forward.dataset_unsupported warn per
five minutes. That is a defensible trade against the previous every-tick outbox,
but the health line should probably not claim success. Left alone because
choosing what exportBatch should report for "held locally" is a contract call.

O3 (low) Version-floor machines now flip to a terminal refused marker

src/core/config/action_attach.js:303. marker.mode !== 'otel' is stale on
every pass for a host whose Claude Code is below the LLP 0258 floor.
perform() then calls attach(), preflightOtelAttach throws
VERSION_FLOOR, and the reconciler writes a refused marker that short-circuits
unconditionally (action_reconciler.js:151) - so the host stays refused even
after the user upgrades Claude Code, until someone runs hyp attach claude by
hand. Such a host previously sat at done indefinitely. The existing attach is
not damaged (preflight throws before any settings I/O), and this is LLP 0186's
named auto-re-arm gap rather than a new one, but this change makes it reachable
by a whole population that never hit it before. I documented the consequence on
isCurrent rather than changing behaviour, since LLP 0262 is Accepted.


Reviewed and found correct

  • partitionExports serialization. No deadlock, no unhandled rejection
    (previous.catch(() => 0) and the promise is always awaited), correct map
    cleanup under overlap, and the finally identity check stops a late tick
    deleting a newer entry.
  • writeInitialHistoryBaseline's use of the last entry.after.
    readRowsSince yields a running monotonic max, not the raw per-row seq
    (src/core/cache/storage.js:286-297), so taking the final entry is the
    high-water even for an unordered scan.
  • The 404/405 degrade bookkeeping. The dataset is not added to
    registeredDatasets, datasetRegistrations is cleared so the re-probe
    actually re-runs, and unsupportedDatasetsUntil bounds it to one probe per
    five minutes. nowFn is injected and tested.
  • Baseline before registration. Establishing the local start-now boundary
    before the remote handshake is right: a registration outage can no longer
    absorb rows that arrived during it into a later, higher baseline. There is a
    test that pins exactly that.
  • URL escaping stays identical on the PUT and the POST, so the two always
    name one resource.
  • Conventions: no semicolons, no em dashes, no inline import('...') types,
    @import at the top, no @typedef, root-anchored .js type specifiers.
    Clean.

The LLP question: closed

Round 1 reported that the central-forwarding half carried no @ref at all and
that nothing under llp/ mentioned /v1/datasets, an arbitrary-dataset protocol,
or who owns schema registration. That gap is now closed properly:

  • LLP 0305 (Decision, Accepted, Systems: Sinks, Cache, Plugins, Usage-Policy) settles all three things round 1 said were owed: the
    legacy-four vs open-dataset routing split (§routing), which datasets a joined
    machine may forward (§eligibility), and the start-from-now baseline
    (§start-now). Its Alternatives section records the rejected options, including
    the central-only redaction that would have created a second privacy mechanism.
  • LLP 0014 carries Extended-by: LLP 0305, and LLP 0255's Extended-by
    line was amended. Both were mechanical forward-ref edits to Accepted docs,
    which CLAUDE.md permits.
  • forwardingTarget and writeInitialHistoryBaseline now carry @refs to
    0305 anchors that resolve, plus a [constrained-by] to LLP 0105
    §graph-provenance. My two fixes are annotated against the same anchors, since
    both are the same decision applied more exactly.

Nothing further is owed by this PR. O1 is not a missing decision - LLP 0105
§graph-provenance already settles it, and @hypaware/context-graph-enrich
simply never applied it to itself. That is a plugin fix, not a new LLP.


Checks

Run in a detached worktree. Baseline established at 988226cc first.

  • npm test: 5079 tests, 5075 pass, 3 fail, 1 skipped. The same 3 fail
    unchanged at 988226cc (5077 / 5073 / 3 / 1) and are artifacts of this
    sandbox's stale node_modules (hypgrep absent, icebird 0.8.22 against the
    pinned 0.8.25): hyparquet-floor-pin, storage.dataSourceForTable ...,
    purged rows stay purged .... Delta from my changes: +2 tests, +2 pass, 0 new
    failures.
  • npm run typecheck: clean.
  • npm run smoke -- claude_telemetry_capture: ok.
  • npm run smoke -- client_attach_idempotent: ok.
  • npm run smoke -- central_forward_outbox: ok.
  • npm run smoke -- local_only_export_withhold: ok.
  • npm run smoke -- source_optout_export_withhold: ok.

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

Copy link
Copy Markdown
ContributorAuthor

Stuck: one privacy question is still open, and it is yours

What neutral was doing. Triage rung on PR #997 at head 8528f28e, after the review fix-loop exhausted its two-round budget. Every residual finding was re-derived against the tree and classified as true blocker or preference (LLP 0017). One is a true blocker, so the PR is held here instead of shipping with a follow-up issue.

What your pushes closed (settled ground, do not re-litigate)

Your pushes (577dbb8d, f07c1617, 988226cc) plus the review fixes (7efff0bc, 8528f28e) genuinely closed five of the six round-1 stuck items:

  • M4 (backfill):writeInitialHistoryBaseline starts a newly eligible open dataset at its current high-water and sends zero historical rows; the "not backfilled" claim is now a code guarantee (LLP 0305 §start-now) with tests, not a lucky canary.
  • M5 (re-attach loop):perform() records mode: 'otel' from the requested client, so an unparseable adapter payload can no longer leave the marker permanently stale.
  • L3 (announce concurrency): the announce single-flights and partition exports serialize across overlapping ticks, with tests.
  • L4: the announce moved behind the missing-table guard, so absent partitions no longer register schemas.
  • The context-graph half of H1:forwardingTarget (hypaware-core/plugins-workspace/central/src/sink.js:240) is a single gate that runs before any bytes and before the announce, has no config override, and genuinely withholds @hypaware/context-graph, which declares localOnlyContentColumns. LLP 0305 records the decision and the code carries the refs. That mechanism is sound and verified.

The blocker

@hypaware/context-graph-enrich forwards LLM-extracted content derived from local-only sessions, and merging this PR is what opens that path.

  • The gate keys on a dataset declaring localOnlyContentColumns. enrichDatasetRegistration (hypaware-core/plugins-workspace/context-graph-enrich/src/datasets.js:124) registers enrichment_prospects / enrichment_resolutions / enrichment_committed with no such declaration and no cwd column, while their schemas carry label, props, evidence, source_keys, anchor_key, and note: content a model extracted from session text.
  • The export seam withholds on the row's own cwd (src/core/cache/storage.js:311), which these rows lack, so it passes them; forwardingTarget then treats them as ordinary eligible open datasets and forwards them in full. The driver hands every registered dataset to every sink (src/core/sinks/driver.js:164).
  • The enrichment pipeline reads local-only sessions on purpose (context-graph-enrich/src/sql.js:49 passes includeLocalOnly: true), so these rows can carry content derived from sessions docs/PRIVACY.md promises are "withheld at the export seam, so no sink or remote query can see them". hyp join consent is stated subject to exactly those controls, so consent does not cover this; it promises the opposite.
  • Introduced by this PR: on master the sink throws unknown signal before any bytes for every non-legacy dataset (central/src/sink.js:200), so nothing enrichment-derived could leave the machine. After this PR it does.
  • Bounded, but real: enrichment is opt-in (V1_EXCLUDED_FROM_DEFAULT, needs an explicit completion provider plus vector-search) and central is opt-in, and the start-now baseline keeps pre-merge enrichment rows local. But on any machine with both opt-ins, every enrichment row written after the merge ships to the org server, silently. LLP 0305's own invariant says "a plugin declaring unprovenanced content must remain local"; the plugin simply never declares, and nothing machine-checkable notices.

Neutral is not re-opening LLP 0305 §eligibility; the declaration-keyed contract stands. The blocker is that the fix for this one plugin needs a column classification only you can make, and shipping without it would knowingly merge a silent privacy defect.

What neutral needs from you

Are enrichment_prospects, enrichment_resolutions, and enrichment_committed meant to be centrally forwardable?

  • If no: which of their 32 columns are content? Most classify themselves (label, props, evidence, note, source_keys are content; ids, types, versions, timestamps are structural). Two are judgement calls neutral will not make for you:

    • anchor_key: the anchored node's natural key; context-graph classifies its own natural_key as content.
    • item_id on enrichment_committed: a non-nullable natural key, so suppressing it destroys row identity, where context-graph could suppress natural_key and keep the node_id hash.

    With your answer the fix is one declaration in enrichDatasetRegistration plus an allowlist test in the shape of test/plugins/context-graph-content-column-allowlist.test.js; the existing gate then withholds all three datasets with no sink change.

  • If yes, say so on this thread; that contradicts the LLP 0105 §graph-provenance constraint LLP 0305 cites, so it would need a recorded decision, and neutral will re-run triage with it.

Non-blockers, recorded so you see the whole PR

These are preferences or accepted trades; they move to a follow-up issue the moment the blocker clears:

  • O2 (low, operability): a 404/405 from an old central server makes forwardPartition return 0 (sink.js:380) and the partition still counts as exported, so hyp status reads healthy while claude_telemetry_events drains nowhere; the only signal is one central.forward.dataset_unsupported warn per five minutes. No data loss: the watermark does not advance, so rows forward once the server supports the route. A deliberate trade against the previous every-tick outbox growth; the health line is the residue.
  • O3 (low): a host below the LLP 0258 Claude version floor now flips to a terminal refused marker (action_attach.js:303 staleness, action_reconciler.js:151 unconditional short-circuit) and stays refused after upgrading Claude Code until a manual hyp attach claude. The existing proxy attach is untouched (preflight throws before any settings I/O), so no capture is lost today; this is LLP 0186's named auto-re-arm gap, newly reachable by a wider population.
  • M2 (attestation): whether the deployed server serves PUT /v1/datasets/{name} is unverifiable from this repo; proto.md records it was exercised against the deployed server on 2026-08-24, and the 404/405 degrade path bounds the failure mode if that regresses.
  • The 3 npm test failures seen in review sandboxes are stale node_modules artifacts, identical at base and head.

How to unstick

Reply on this PR with the forwardability answer (and the column classification if the answer is no), or push the declaration to the branch yourself. Neutral monitors this thread and re-engages on its next tick.

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

Copy link
Copy Markdown
ContributorAuthor

Acknowledged and fixed on exact head 0082fecb0322849a684231270480d4ffdfe2bde8: @hypaware/context-graph-enrich now classifies every column in all three derived datasets, treats anchor_key and item_id conservatively as content, and includes a full content-vs-structural allowlist regression test so future columns require explicit classification.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Neutral capped-round triage

Privacy blocker O1 is closed on exact head 0082fecb0322849a684231270480d4ffdfe2bde8: all context-graph-enrich columns are explicitly classified, with anchor_key and item_id treated as content and pinned by an allowlist regression test.

The remaining O2 and O3 findings cannot cause captured-data loss in this patch and are non-blocking operational recovery/status issues. They are deferred to #999. The server-route availability remains the accepted external attestation already recorded in this PR.

@philcunliffe

Copy link
Copy Markdown
ContributorAuthor

Final-head ship-risk assessment

Changed surface: central arbitrary-dataset registration/ingest, durable per-sink rollout manifests and watermarks, overlapping export serialization, Claude proxy-to-OTEL attach reconciliation, and privacy classification for context-graph-enrich datasets.

Critical safety fact: enabling open-dataset forwarding must neither backfill pre-rollout Claude OTEL history nor forward datasets containing unprovenanced local-only content. The exact head establishes a durable start-now baseline before remote registration, fails closed on missing/corrupt established progress, serializes overlapping exports, and withholds every dataset declaring localOnlyContentColumns before registration or ingest. All three enrichment datasets classify every schema column; anchor_key and item_id are content.

Executable evidence on exact head:node --test test/plugins/central-forward-chunking.test.js test/plugins/central-rollout.test.js test/plugins/context-graph-enrich-content-column-allowlist.test.js test/core/attach-endpoint-drift.test.js passed 66/66. GitHub CI is green on Node 22 and 24, typecheck, and both LLP checks. The PR also records the live Hyperparam canary proving the registration/ingest path and fresh-only rollout behavior, but the final-head classification is e4 because the complete exact head was proven hermetically rather than redeployed as that canary.

Confirmed residual risks: an older server may look healthy while retaining unsupported open-dataset rows locally, and a version-floor refusal requires manual reattach after upgrade. Both are deferred to #999; neither loses existing capture. Server route availability remains an external deployment attestation.

Cleared risks: no historical open-dataset backfill, no silent rebaseline after established progress is lost, no duplicate first-row forwarding under overlapping ticks, no endless outbox for deliberately withheld datasets, no reserved legacy-path impersonation, and no context-graph-enrich privacy leak.

Classification: high. The patch changes privacy/export behavior, durable data progress, concurrency, and a client-server wire contract. Executable proof clears the known defect paths but cannot reduce those inherent consequence classes below high.

@philcunliffephilcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Aug 24, 2026
@philcunliffe
philcunliffe marked this pull request as ready for review August 24, 2026 23:38
@philcunliffe
philcunliffe added this pull request to the merge queueAug 24, 2026
Merged via the queue into master with commit a4c6350Aug 24, 2026
8 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approvedneutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@philcunliffe