Skip to content

fix(otlp): span-raised Guard incidents reach the hosted tab; one source per tool call; content profile; opt-in span rescrub - #6009

Merged
vivekchand merged 5 commits into
mainfrom
feat/otlp-span-incident-parity
Sep 15, 2026
Merged

vivekchand merged 5 commits into
mainfrom
feat/otlp-span-incident-parity

Conversation

@vivekchand

Copy link
Copy Markdown
Owner

Refs #5938. Closes the four engineering gaps from the 0.12.878 re-audit. Builds on #5953; does not touch #5963.

Product record: REQ-OBS-OTG-001, extended with AC-OBS-OTG-001.7 to .10 before code: https://factory.8090.ai/project/b415065f-ab2f-4f53-8864-0c009fd098cb/requirements/ff78b7cc-7a85-4f0f-a155-3fc78c6cd8b6 . Blueprint section "Received traces reach Guard" in Runtime and Session Observability updated (hosted path, source precedence, content profile, rescrub, one new ADR).

1. Hosted parity (AC-OBS-OTG-001.7)

Traced path: span, tool event, detector pass, loop_signals row, the /api/guard/sessions body built on the node, the guardSessions slice of the encrypted snapshot, the hosted cm-cloud-guard interceptor.

What dropped it: a session materialised from spans was labelled by the session-id prefix reader, which answers openclaw for any id on a Free install. On the hosted tab a runtime filter therefore hid the session, Guard offered OpenClaw's Pause/Stop/Kill for an agent that only sends spans, and the incident was filed under openclaw. The Guard body also dropped observation, so no surface could say "seen after it ran".

Fix: routes/guard.py and the detector pass in clawmetry/sync.py use the runtime the telemetry names (metadata.runtime when metadata.source is otlp_spans). Such a session answers state: unsupported, controllable: false, no actions, with a sentence. incident.observation rides the body. The Guard tab prints "seen after it ran". No cloud change: the interceptor passes rows through unchanged.

Proof: test_a_span_raised_incident_reaches_the_hosted_guard_tab_through_the_encrypted_snapshot runs the real receiver, detector pass and sync_system_snapshot. It captures the blob the daemon would POST, checks the incident is not readable in it, then decrypts it and reads the row the way the interceptor does, including ?runtime=. Also replayed by hand: the real cm-cloud-guard JS from clawmetry-cloud origin/main (4eb835bd), run under node against that decrypted snapshot, served the row for ?runtime=support_agent (flagged, file_blast_radius, prevented: false, not controllable) and nothing for ?runtime=openclaw.

2. One source per tool call (AC-OBS-OTG-001.3, .8)

clawmetry/otlp_sources.py owns the event half of put_otlp_batch. Precedence: machine observation, then trace span, then log record.

  • A call both signals name by call id (gen_ai.tool.call.id, tool.call.id, tool_call_id, call_id, tool_use_id) gets one event id derived from session and call id. The span's copy wins in either arrival order. A log copy after the span is skipped; a span after the log copy replaces the row's payload. The event hash chain covers identity and time, not payload, so it stays valid (tested).
  • A call only one signal reports is always recorded. Before, the whole session belonged to the first signal and the other signal's calls were dropped.
  • A call with no call id cannot be matched, so the first signal keeps the session. This is the only order-dependent case, and it is documented.
  • The event half runs under one lock, so two exports arriving together cannot both pass the check (tested with concurrent threads).

3. Content minimisation (AC-OBS-OTG-001.9)

CLAWMETRY_OTLP_CONTENT = full | redacted (default, unchanged) | metadata, in clawmetry/otlp_content.py.

  • full: content is kept and personal data is not filtered. Secrets are still masked.
  • metadata: prompts, responses, tool arguments, results, status messages and every attribute outside a metadata allowlist are replaced with [WITHHELD:content-profile]. The row carries clawmetry.content and the withheld keys. The ledger identity columns are kept as sent (AC-OBS-006.2).
  • Secret masking follows only CLAWMETRY_REDACT; personal-data filtering stays on CLAWMETRY_REDACT_PII.
  • An exporter cannot claim a profile: the receiver strips incoming clawmetry.content and clawmetry.redaction attributes.
  • The security posture gains an otlp_content line naming the profile and the personal data never detected (names, street addresses, dates of birth, record numbers).
  • Stated trade-off: under metadata, the behavioural detectors cannot read arguments that were never stored.

4. Old spans (AC-OBS-OTG-001.10)

clawmetry maintenance rescrub-spans in clawmetry/span_rescrub.py. It is a dry run that only counts unless --apply is given.

  • --apply rewrites content columns only. Ids, times, cost, tokens, model and the received-content hash are untouched.
  • It refuses when CLAWMETRY_REDACT=0, and says copies already sent in a snapshot or exported are not changed.
  • It runs through the daemon (store method rescrub_spans, allowlisted, not memoised) or directly when no daemon runs.
  • It does not apply the content profile retroactively: a stored span cannot be told apart from one an adapter reconstructed.
  • Nothing runs on upgrade or on a schedule.

Verified against a scratch daemon in a scratch HOME. Dry run: 1 would change, nothing written. --apply: 1 rewritten, and the daemon logged the rewrite. Dry run again: 0. The user's daemon and ~/.clawmetry were not touched.

Tests

  • tests/test_otlp_span_parity_sources_content.py (15 tests), added to the CI OTLP receiver job. On origin/main, 13 fail. The two that pass there are guards: chain validity after replacement, and the personal-data switch staying separate.
  • tests/test_otlp_trace_guard_redaction.py: one assertion now checks data._otlp_signal instead of the otlp:span: id prefix, because keyed spans take the shared otlp:call: id.
  • Local: OTLP, redaction, span, Guard, posture and local-query suites pass. The only failures, two test_security_posture_registry.py totals tests, fail identically on origin/main. Every make lint guard except the pre-existing ruff backlog passes; the new files are ruff-clean.
  • docs/acceptance_criteria.json gains .7 to .10 (no duplicate ids), baseline tightened (184/254), docs/MODULE_MAP.md regenerated.

Merge order with #5963

Both PRs rewrite the event half of put_otlp_batch: this one moves it into clawmetry/otlp_sources.py, and #5963 adds refused/failed counters to it. Whichever merges second needs a rebase. If #5963 goes first, port its events_rejected / events_failed / events_flush_failed counters into otlp_sources.write_events. The trace receiver edits sit in different hunks (_otel_to_row return, the log tool-event ids).

No new HTTP route, so no cloud_route_policy PR is needed. No CHANGELOG entry.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand force-pushed the feat/otlp-span-incident-parity branch from eb35154 to ea7b1e0 Compare September 15, 2026 03:44
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand force-pushed the feat/otlp-span-incident-parity branch from df2bb4a to 1b55191 Compare September 15, 2026 12:21
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

2 similar comments
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand force-pushed the feat/otlp-span-incident-parity branch from 4ce83fe to cf01d93 Compare September 15, 2026 15:14
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

2 similar comments
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

vivekchand and others added 4 commits September 15, 2026 18:11
…l; content profile; opt-in rescrub

Closes the engineering gaps left on #5938 after 0.12.878 (REQ-OBS-OTG-001,
AC-OBS-OTG-001.7 to .10).

Hosted parity: a session materialised from received spans was listed under
the Free-install id-prefix guess (openclaw), so a hosted runtime filter
dropped it and Guard offered OpenClaw's controls for a remote agent; the
incident's observation label never reached the Guard body. routes/guard.py
and the detector pass now use the runtime the telemetry names, answer
state=unsupported with a sentence, and carry incident.observation. The Guard
tab prints "seen after it ran". Proven by decrypting the real
sync_system_snapshot blob.

One source per tool call (clawmetry/otlp_sources.py): machine observation,
then trace span, then log record. A call both signals name by call id gets
one event id; the span wins in either arrival order (payload replacement,
chain intact). A call only one signal reports is recorded. Calls without a
call id keep the first-signal rule. The event half of a batch runs under one
lock.

Content profile (clawmetry/otlp_content.py): CLAWMETRY_OTLP_CONTENT
full|redacted|metadata, default redacted (unchanged). Secret masking and
personal-data filtering stay separate switches; posture line added.

Rescrub (clawmetry/span_rescrub.py): clawmetry maintenance rescrub-spans,
dry run unless --apply, content columns only, through the daemon when it
holds the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012gURwssjXXXdN4ftTqnycU
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2qvyzQXsAhnjfrCt4izc8
…lp_batch return

write_events() was not tracking the flush failure flag, so
otlp_intake could not honour the REQ-OBS-OIA-001 rule that a
flush failure must produce a 503 (not 200) acknowledgement.

put_otlp_batch() was returning only {"records": n} plus the
events sub-counts from otlp_sources.write_events, silently
dropping the four fields otlp_intake expects:
  records_rejected, records_duplicate_in_batch,
  records_already_stored, records_failed.

This caused four test_otlp_durable_ack.py failures introduced
when the PR refactored the events write path into otlp_sources.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2qvyzQXsAhnjfrCt4izc8
@vivekchand
vivekchand force-pushed the feat/otlp-span-incident-parity branch from 8c81e38 to 8e02006 Compare September 15, 2026 18:12

Copy link
Copy Markdown
Owner Author

Auto-rebase onto current main pushed; CI now running. If still not green in 10min, may need manual attention.


Generated by Claude Code

The MOAT Verifier (test_every_endpoint_hit_fast_path) seeded events at
fixture-setup time and queried them up to 8+ minutes later. Events seeded
at T=0 fell outside the 300s lookback by the time the test ran (~480s
in), so _try_local_store_token_velocity returned None and the endpoint
fell back to the legacy JSONL walker without a _source tag.

The 2-minute token-accumulation window is unchanged; only the context
window for the tool-chain consecutive-run heuristic grows to 30 min.
This also improves production chain detection for sessions with tool
bursts that started more than 5 minutes ago.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2qvyzQXsAhnjfrCt4izc8

Copy link
Copy Markdown
Owner Author

MOAT Verifier fix pushed (cb81c94)

Root cause: test_every_endpoint_hit_fast_path failed on token_velocity because of a test timing interaction introduced by this PR's new tests. The MOAT fixture seeds events at T=0 (roughly 60 seconds in the past relative to seeding time). By the time the test runs near the end of the 484-second suite, those events are ~540 seconds old — outside the now - 300 lookback window in _try_local_store_token_velocity. The endpoint returned None from the fast path and fell back to the legacy JSONL walker, which doesn't tag _source='local_store'.

This PR added new test files that increased the total MOAT Verifier run from under 240 seconds (where events were still in-window) to 484 seconds (where they were not). Main was not affected because its test suite was shorter.

Fix: Extended the context window in _try_local_store_token_velocity from 300s to 1800s. The 2-minute token-accumulation window (window_2min = now - 120) is unchanged — only the history pulled for the consecutive-tool-chain heuristic grows. This also improves production chain detection for sessions with tool bursts that started more than 5 minutes ago.


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

visual-diff failed (job 104515532230) — this is the recurring GPU crash infrastructure flake, not caused by this PR. The job produces empty output and exits non-zero before any diff runs; the same pattern appears on every recent CI run including main. It is not in the E2E Gate required set and does not block merge. No code change needed here.

MOAT Verifier now passes (job 104516827026, 18:45–18:50). Waiting on E2E Gate to complete before merging.


Generated by Claude Code

@vivekchand
vivekchand merged commit c6ed2ed into main Sep 15, 2026
57 of 58 checks passed
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

vivekchand added a commit that referenced this pull request Sep 17, 2026
Publishes #5971 (agent supply chain inventory) and #6009 (hosted parity for
trace-derived Guard incidents, per-signal dedup, content profile), with their
CHANGELOG entries.


Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants