Repository navigation
feat(OMN-19513): node_projection_claude_hook_events -- every Claude Code hook, with subagent lineage - #2956
Conversation
…ode hook, with subagent lineage The consumer half of the all-hooks capture contract (omniclaude#2370). One subscribe topic, onex.evt.omniclaude.hook-event.v1, carries all 33 Claude Code hook types with lineage on every event. This node projects it into omninode_internal.claude_hook_events (one row per event, session_id as a real column, agent_id NULL for the main thread) and folds subagent lineage into omninode_internal.claude_agent_spans (parent_agent_id plus parent_resolution: main_thread, resolved, workflow or unknown). Rule 7a pair: HandlerProjectionClaudeHookEvents is the pure definition-B fold; ClaudeHookEventsProjectionWriter is the in-process writer that reads the spawning PreToolUse, calls the fold and persists. Idempotent by construction: event rows insert ON CONFLICT (event_id) DO NOTHING, the span tool_call_count is recounted from the event table rather than incremented, and a known parent replaces unknown but never the reverse. A child written before its spawning call is repaired when the call arrives. Only the writer is a routed entry. The contract's event has a top-level payload mapping and an event_id, which the runtime's typed arm reads as a transport envelope and unwraps through (OMN-16050); a routed fold would receive the per-hook payload without its lineage and dead-letter every event. The wire model refuses a payload key the capture contract does not declare for the metadata topic, so content that reaches this topic by a producer regression is dead-lettered, not stored. It drops, by name, the five top-level stamps the emit seam adds (measured on a live lab record). Tests reproduce the contract's expected_projection rows for the nested subagent, workflow-agent and orphan-agent scenarios through the writer's runtime entry, pin the hook list, payload keys and projection columns to the contract, and prove the SQL against real Postgres (skips without a DB).
…ps by name The wire model for onex.evt.omniclaude.hook-event.v1 forbids extra top-level keys and drops transport stamps by name. It previously dropped only five stamps: correlation_id, causation_id, entity_id, session_id, and redaction_state. The real appender (omniclaude hook_emit_append.py) also merges lane, lane_source, lane_ticket, workspace_path, and turn_id into every record. Because these five keys were not in the drop set, a fixture event journaled through the real appender was rejected with extra_forbidden, so every real hook event would have been dead-lettered. The fix adds the five appender keys to the named stamp set so they are dropped instead of rejected. The event's lineage.turn_id remains authoritative over the appender's top-level turn_id. The seam test now carries the appender stamps with a different top-level turn_id and asserts the stored turn_id is the lineage one. A new test proves an unnamed top-level key such as prompt is still refused.
|
| Rule | Description |
|---|---|
| ARCH-TOPIC-001 | No hardcoded Kafka topic strings in handler code |
| ARCH-DI-001 | No event_bus=None bypass |
| ARCH-DI-002 | No direct Handler instantiation outside workflow_runner/adapters |
| ARCH-DI-003 | No reinvented DI containers |
| ARCH-TOPIC-002 | contract.yaml topics follow `onex.{cmd |
ARCH-DI-002: Direct Handler instantiation (warning)\n\n\nsrc/omnimarket/nodes/node_projection_claude_hook_events/handlers/handler_claude_hook_events_writer.py:210: self._derive = HandlerProjectionClaudeHookEvents()\n\n\nHandler classes should be wired only in workflow_runner.py or adapter modules. Direct instantiation elsewhere bypasses the contract system.\n\n
Static architectural lint — no model inference (OMN-14176).
…nimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36247082497
✅ Hostile Reviewer — PASSEDBlocking findings (critical/error): 0 Gate semantics
Powered by omniintelligence.review_pairing.cli_review — node-based adversarial review via HandlerLlmCliSubprocess (OMN-8524) |
| _SCENARIOS = ("subagent_tree", "workflow_agent", "orphan_agent") | ||
|
|
||
|
|
||
| async def _connect_or_skip() -> asyncpg.Connection: |
…nership ahead of their vendoring Step 1 of the three-PR order scripts/application-relation-ownership.yaml documents, as omnimarket#2937 did for session_content. omnibase_infra#4169 vendors the node_projection_claude_hook_events migrations and its Application Database Domain Enforcement gate resolves ownership from this file at omnimarket dev tip, so the declaration has to land first; omnimarket#2956 then lands the node and its migration. No SQL, no exemption. The relation inventory is regenerated: source_declared_tables 81 to 83, source_created_tables unchanged at 72.
… stamp by name The omniclaude emit drainer stamps hook_fired_at on every published record, so every real drained hook event failed validation and was dead-lettered. This adds hook_fired_at to TRANSPORT_STAMP_KEYS, so the wire model drops it by name while still refusing unknown top-level keys. Tests carry the new key and assert it is accepted while an unknown key is still rejected. Onex-Lane: all-hooks-contract-83 Onex-Session: 9a8f8ac2b564447cbcbd3142788e28a1
…ook-events-ownership' into jonah/omn-19513-claude-hook-events-projection
…nimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36251210117
…2956 OCC companion by node_pr_lifecycle_fix_effect (OMN-13317 F1 / OMN-13990 / OMN-14285). Product PR head d5084f6ac5e446dd080c19c91217d2346d657a58.
…nimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36251278975
…osters CI on the first push failed three roster tests this node moves. ClaudeHookEventsProjectionWriter joins the pinned exact set of in-process-dispatch writers as the eighth, on the same reviewed terms (its pure fold does not declare the capability). The relation inventory is regenerated after merging the omnimarket#2958 ownership declaration: source_created_tables 72 to 74 and the arithmetic unreconciled bound 14 to 12, declared unchanged here. The dogfood inventory counts node_dirs 425 to 426 and entry_points 416 to 417.
…ts-projection' into jonah/omn-19513-claude-hook-events-projection
…nimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36251674264
…imarket#2956 (#11473) * evidence(OMN-19513): author OCC companion for OmniNode-ai/omnimarket#2956 OCC companion by node_pr_lifecycle_fix_effect (OMN-13317 F1 / OMN-13990 / OMN-14285). Product PR head d5084f6ac5e446dd080c19c91217d2346d657a58. * evidence(OMN-19513): self-bind OCC#11473 + rebind contract_sha256 * evidence(OMN-16859): executed test_passes receipts for OmniNode-ai/omnimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36251278975 * evidence(OMN-16859): executed test_passes receipts for OmniNode-ai/omnimarket#2956 The declared checks were executed for real in the OmniNode-ai/omnimarket checkout at the PR head by the product-repo OCC receipt runner, and the results written append-only into this companion. Producer-minted PENDING receipts are superseded by a net-new record; no existing receipt was edited. Ticket: OMN-16859 (AC3b) Onex-Workflow: OCC Receipt Runner Onex-Run: https://github.com/OmniNode-ai/omnimarket/actions/runs/36251674264 --------- Co-authored-by: omnimarket-bot <bot@omninode.ai> Co-authored-by: onexbot-occ-writer[bot] <307849072+onexbot-occ-writer[bot]@users.noreply.github.com>
… sequences Two changes carried here so this node package stays byte-equal to what omnibase_infra#4169 vendors and to what omnimarket#2958 declares: - 0001 now asserts the omninode_runtime USAGE grant on schema omninode_internal with has_schema_privilege, answering the Hostile Reviewer MAJOR finding on omnibase_infra#4169 that it was the one issued privilege left unasserted. The grant set itself is unchanged: it is already exactly the writer scope. The file is byte-identical to the vendored copy on omnibase_infra#4169 (cmp). - scripts/application-relation-ownership.yaml gains the two database_objects sequence entries, identical to omnimarket#2958, because the OMN-15361 SQL gate refused both sequences by name with only the tables declared. The ownership test from #2958 is carried too, so the hunks are identical and merge cleanly in either order. Lab (.201 dev lane, omnibase-infra-postgres, database omnidash_analytics): both files applied inside BEGIN ... ROLLBACK with ON_ERROR_STOP=1, clean; with the schema USAGE revoked inside a rolled-back transaction the new assertion raised division by zero. Post-state unchanged (USAGE true, 21 event rows, 4 span rows).
Resolved three census conflicts by adding this PR's increments on top of dev's figures after OMN-19716 landed. Dogfood node_dirs moves from 427 to 428 and entry_points from 418 to 419. The relation inventory's source_created_tables moves from 73 to 75 for claude_hook_events and claude_agent_spans. source_declared_tables stays at dev's 84 because the declarations landed with omnimarket#2958, and minimum_unreconciled_live_base_tables drops from 13 to 11. The inventory JSON was regenerated with the generator's --write mode, and 120 focused tests pass with 2 skipped (real Postgres). Onex-Lane: all-hooks-contract-83 Onex-Session: 9a8f8ac2b564447cbcbd3142788e28a1
|
OCC autobind rebound this PR's evidence-source stamp line to The receipt gate's own eligibility validator, run against the change-control tree the gate pins for Reported by |
…evmerge # Conflicts: # tests/test_market_node_runtime_dogfood.py
|
OCC autobind rebound this PR's evidence-source stamp line to The receipt gate's own eligibility validator, run against the change-control tree the gate pins for Reported by |
Three test files conflicted on count assertions that both sides moved. Resolved by adding this PR's node on top of dev's counts: node_dirs 434 (dev 433 + node_projection_claude_hook_events), entry_points 423 (dev 422 + 1), source_created_tables 77 (dev 75 + claude_hook_events and claude_agent_spans), minimum_unreconciled_live_base_tables 9 (max(0, 86 - 77)). The writer-dispatch test keeps both the ClaudeHookEventsProjectionWriter and PrLandingProjectionWriter notes. docs/evidence/OMN-15423-relation-inventory.json regenerated with scripts/generate_application_relation_inventory.py --write.
|
OCC autobind rebound this PR's evidence-source stamp line to The receipt gate's own eligibility validator, run against the change-control tree the gate pins for Reported by |
…evmerge-84e # Conflicts: # docs/evidence/OMN-15423-relation-inventory.json # tests/test_market_node_runtime_dogfood.py # tests/unit/scripts/test_application_relation_inventory.py
…evmerge-84f # Conflicts: # tests/test_market_node_runtime_dogfood.py
|
Lab pool readback (OMN-18893, rule 24) for head 39e7cb0. Host lab-202 (192.168.86.202), isolated project omnibase-infra-local, lane prove-omnimarket-2956-84f, driver prepr_runtime_pool (profile omnimarket.runtime_image), run 2026-09-28T20:37:58Z to 20:54:29Z. Result PASS, receipt lab_pass_receipt.v1 for exactly this sha (base 341957d). Checks, all ok: head identity (fetched head equals expected head); stack built (build rc=0, up rc=0); runtime image identity (omnimarket package tree 4787 files against 4788 in both omninode-runtime and runtime-effects images, missing=0 differ=0); health 8085 and 8086 healthy with no failed handlers; migration gate healthy with 0 restarts; no wiring failures per the judge (runtime: autowire-fail=1, dup-dispatcher=0, ERROR lines 69, Traceback 2; effects: autowire-fail=1, dup-dispatcher=0, ERROR lines 26, Traceback 0, all within the judge's bar; I did not attribute those counts to this PR); focused tests rc=0 (projection, real-Postgres, golden chain, consumer-flow writer dispatch, dogfood roster, relation inventory). Teardown: no omnibase-infra-local containers, no pool lease directory and no prove-pool work directory on lab-202 at 2026-09-28T21:41Z. What this pass did not exercise: the tenant, RLS and reader-authorization paths through onex-api (rule 24(d); this PR touches none), and the live runtime auto-wiring of the node on the .201 dev lane, which is read back after merge. The receipt was written by the prove lane; this comment was posted by lane hook-events-chain-83 after re-reading the receipt file and the host. |
…evmerge-84f # Conflicts: # tests/test_market_node_runtime_dogfood.py
|
Lab pool readback (OMN-18893, rule 24) for head 31e80c5, the head after the second dev merge (only tests/test_market_node_runtime_dogfood.py conflicted: dev added node_pr_landing_decision_compute, so the roster asserts are node_dirs 438 and entry_points 428). Host lab-105 (192.168.86.105), isolated project omnibase-infra-local, lane hook-events-chain-83, driver prepr_runtime_pool with --base-control, profile omnimarket.runtime_image, run 2026-09-28T21:56:35Z to 22:15:25Z. Result PASS for exactly this sha. Checks, all ok: head identity; stack built (build rc=0, up rc=0); runtime image identity (omnimarket package tree 4797 files against 4798 in the image, missing=0 differ=0, both runtimes); health 8085 and 8086 healthy with no failed handlers; migration gate healthy with 0 restarts; no new wiring failures (the isolated bundle fails to wire 29 contracts in the runtime and 7 in runtime-effects for missing DSNs, and the base control built at dev on the same host shows the same failures; none is this PR's node); focused tests rc=0 (projection, real-Postgres, golden chain, consumer-flow writer dispatch, dogfood roster, relation inventory), and the migration probe read both omninode_internal.claude_hook_events and omninode_internal.claude_agent_spans back as existing. A first run of this head without the base control failed only no_wiring_failures on those same inherited environment failures; the earlier head 39e7cb0 passed the same way on lab-202 (lane prove-omnimarket-2956-84f). Teardown: restored=yes (containers 0, volumes 0, networks 0, images 0, listeners 0, workdir gone, snapshot diff 0). The receipt verifier line reads MISSING_MANDATORY_CHECK for changed_path_live because no live-grep list was passed, the same note the omnimarket#3045 proof carried. Not exercised: onex-api tenant, RLS and reader-authorization paths (this PR touches none), and the runtime's auto-wiring of the node on the .201 dev lane, which is read back after merge. |
Summary
Ticket OMN-19513. Operator ruling on the rolling ledger (RULING by lane all-hooks-capture-83-ruling, 2026-09-26T11:15:41Z): "we need all hooks captured". This PR is build task 3 of the all-hooks task split: the consumer (writer) half.
It builds on omniclaude#2370 (merged 2026-09-26T12:38Z), the capture contract: one topic onex.evt.omniclaude.hook-event.v1 for all 33 Claude Code hook types, lineage on every event, content only as references.
New node node_projection_claude_hook_events. Subscribes to onex.evt.omniclaude.hook-event.v1. Writes omninode_internal.claude_hook_events (one row per event, session_id is a real column, agent_id NULL means main thread, tool_use_id, parent_tool_use_id, parent_agent_id, workflow_run_id, spawn_depth, turn_id, correlation and causation ids, payload JSONB metadata only, content_ref_ids) and omninode_internal.claude_agent_spans (one row per subagent: parent_agent_id and parent_resolution main_thread / resolved / workflow / unknown, spawn depth, start and stop, tool_call_count).
Rule 7a pair: HandlerProjectionClaudeHookEvents is the pure fold; ClaudeHookEventsProjectionWriter is the in-process writer (onex_runtime_inprocess_dispatch = True). Only the writer is a routed entry, because the event has a top-level payload mapping and event_id and the runtime typed arm would unwrap through it and strip the lineage.
Idempotent: event rows insert ON CONFLICT (event_id) DO NOTHING; span tool_call_count is recounted from the event table, never incremented; a known parent replaces unknown and never the reverse; a child written before its spawning call is repaired when the call arrives.
The wire model refuses a payload key the capture contract does not declare for the metadata topic (content cannot be stored by a producer regression; it is dead-lettered to onex.dlq.omnimarket.projection-claude-hook-events-malformed.v1). Top-level transport stamps are dropped by name, never by ignoring extras.
Seam defect found and fixed before push (second commit): the real omniclaude hook_emit_append.py stamps lane, lane_source, lane_ticket, workspace_path and turn_id at top level. The first commit's model rejected those, so every real hook event would have been dead-lettered. Reproduced by journaling a fixture through the real appender; the fix adds them to the named stamp set, and lineage.turn_id stays authoritative.
Two migrations: 0000 creates both tables, 0001 grants to omninode_runtime.
Proof
Local proof: 63 focused tests passed, 2 skipped (the real-Postgres tests skip without a local DB). ruff format, ruff check, mypy --strict clean; commit hooks passed.
Lab proof on the .201 dev lane (compose project omnibase-infra), no service restart, no container recreate, no site-packages edit: the branch node was loaded inside the omninode-runtime container from a temp directory (removed afterwards) and its writer runtime entry was driven per consumed message. The two migrations were applied by hand to omnidash_analytics in the earlier pass (both are IF NOT EXISTS, no DROP). 20 contract scenario fixture events (nested subagent tree, workflow agent, orphan agent) plus 1 PostToolUse record journaled through the real omniclaude appender were published to onex.evt.omniclaude.hook-event.v1 on the dev broker, keyed by session_id. A fresh consumer group read 41 messages (this run's 21 plus 20 from the earlier pass): 0 validated as ModelEventEnvelope, so all 41 take the payload=data branch of the runtime's auto-wiring consume path (handler_wiring.py), which the driver mirrors; 1 new event row inserted, the 40 fixture replays wrote no new rows. Readback as role omninode_runtime: 20 of 20 fixture event rows and 4 of 4 span rows equal the contract's expected_projection rows exactly. Spans: a1 main_thread with 2 tool calls, a2 resolved to parent a1 at depth 2 with 1 tool call, workflow-a1 workflow, orphan-a1 unknown. The appender-shaped row landed with no stamp keys in its payload and turn_id taken from lineage.
What the lab pass did not exercise: the runtime's own auto-wiring of this node (the node is not installed in the runtime image until merge), and the DLQ route.
Rule 24(d): no tenant, RLS or reader-authorization path touched; the lab pass did not exercise onex-api.
Merge order
The forced three-PR order from
scripts/application-relation-ownership.yaml: omnimarket#2958 (ownership declaration only), then omnibase_infra#4169 (vendors this node's two migrations; its Application Database Domain Enforcement gate reads ownership from omnimarket dev), then this PR, whose node-migration-vendor-parity-gate stays red until #4169 is on omnibase_infra dev. This branch has merged #2958's branch so its CI evaluates the combined state.Later commits on this branch: the landing lane added
hook_fired_at(stamped by the omniclaude drainer) to the dropped-stamp set; and the repo rosters this node moves are pinned (the in-process-dispatch writer set gains ClaudeHookEventsProjectionWriter as the eighth; the relation inventory is regenerated, source_created_tables 72 to 74; dogfood node_dirs 425 to 426 and entry_points 416 to 417). 85 focused tests pass at 3467e4f.Not in this PR
Until the producer and registration land, the topic carries only the lab fixtures. The node goes live on the lab after merge through the deploy agent.
Evidence-Ticket: OMN-19513
Evidence-Source: OCC#11488