Skip to content

sync: update Matrix pilot with nearai main - #3

Merged
theredspoon merged 106 commits into
native-matrix-channel-pilotfrom
sync/native-matrix-pilot-20260601
Jun 2, 2026
Merged

theredspoon merged 106 commits into
native-matrix-channel-pilotfrom
sync/native-matrix-pilot-20260601

Conversation

@theredspoon

Copy link
Copy Markdown
Owner

Summary

  • Merge the current nearai/main sync branch into native-matrix-channel-pilot.
  • Keep the pilot artifact workflow's Rust 1.93/toolchain/cache fixes.
  • Regenerate Cargo.lock with the Matrix SDK pilot dependency still enabled.
  • Preserve the Matrix feature-parity status while taking upstream's expanded channel notes.

Validation

  • cargo generate-lockfile
  • cargo check --locked --no-default-features --features postgres,html-to-markdown,matrix-e2ee

Notes

This keeps native-matrix-channel-pilot as the approved Matrix overlay branch until nearai/ironclaw ships Matrix on main. It should be reviewed before mirroring/building deployment artifacts.

theredspoon and others added 30 commits May 5, 2026 13:34
…ange (nearai#3235)

* ci(e2e): replace deleted preflight test with tool_activate surface

The v2-engine E2E group still listed test_v2_kernel_auth_preflight.py,
but that file was removed in nearai#2868 (engine-v2: callable-only available
actions) and replaced with test_v2_tool_activate_surface.py for the new
tool_activate / Activatable Integrations contract.

The Web E2E Full job is skipped on PR-level CI but runs in the merge
queue, so the bad path filter dequeued nearai#3197 and nearai#3203 with
"file or directory not found: test_v2_kernel_auth_preflight.py".

* test(e2e): unblock Live Canary auth lanes after engine-v2 contract change

The Live Canary "Auth Smoke", "Auth Full", and "Auth Live Seeded" jobs
have failed every scheduled run since 2026-05-01 (when the canary cut
over to main). Three tests in test_v2_auth_oauth_matrix.py drive the
failures, all rooted in the engine-v2 callable-only contract from nearai#2868
that didn't exist when these tests were written.

## What was broken

`test_mcp_same_server_multi_user_via_browser`
After OAuth completes, sending "check mock mcp search" through each
user's browser opens an `approval` pending_gate on the first MCP tool
call (engine v2 default). The browser fixture has no auto-approve UI,
so the chat sat in `pending_gate` for the full 5-min Playwright
timeout — `expected_text_contains="Mock MCP search result"` could
never match because the assistant bubble never received any text.

`test_wasm_tool_oauth_refresh_on_demand`
Same shape: gmail call gates on `approval` before reaching the http
credential-injection layer that performs the OAuth refresh. Without
approving, refresh_count never went above 0, so the test failed with
"Timed out waiting for OAuth refresh request".

`test_wasm_tool_first_chat_auth_attempt_emits_auth_url`
Tested OLD engine-v2 behavior — that an LLM-emitted call to a not-yet-
authed extension would surface a `gate_required` Authentication event
with an auth URL. After nearai#2868, the engine returns "action 'gmail' is
not callable in this execution context" instead, and `tool_activate`
became the model-facing enablement path. The mock LLM is canned to
emit tool calls directly, so this scenario can't be reproduced from a
scripted LLM until the canned response is updated.

## Fixes

- `_wait_for_tool_call`: accept a `token` kwarg so multi-user tests can
  poll/approve through a per-user identity. Backwards-compatible.
- `test_mcp_same_server_multi_user_via_browser`: drive approval through
  the per-user API while waiting for the tool to land. Drop the broken
  `expected_text_contains` predicate and the tied "Mock MCP search
  result" text assertions; the bearer-token isolation assertion (what
  this test actually exists to prove) is retained and unaffected.
- `test_wasm_tool_oauth_refresh_on_demand`: insert a `_wait_for_tool_call`
  approval step between `_send_chat` and `_wait_for_refresh_request`
  so the http credential layer actually runs.
- `test_wasm_tool_first_chat_auth_attempt_emits_auth_url`: marked xfail
  with an inline reason pointing at nearai#2868 and the replacement coverage
  (`test_v2_tool_activate_surface.py`,
  `test_settings_first_gmail_auth_then_chat_runs`).
- Drop the now-unused `send_chat_and_wait_for_terminal_message` import.

## conftest fix

`ironclaw_server` now sets `SECRETS_MASTER_KEY` in the spawned env.
On macOS without it, `auto_generate_and_persist` blocks on a Keychain
authorization prompt that no one's home to click, so `wait_for_ready`
times out at 60s and the fixture kills the process with SIGKILL —
making any session-scoped browser test impossible to run locally.
On Linux, the keychain backend errors fast and the auto-generate
fallback writes to `.env`, so CI was unaffected. Setting the key
explicitly matches the pattern already used in
`auth_matrix_server`, `test_v2_engine_auth_cancel`,
`test_v2_tool_activate_surface`, etc.

## Verification

Local repro confirmed each failure mode (HTTP-only repro for the
non-browser tests, server-side log inspection for the multi-user
test). Reproduced the exact pending_gate=approval pattern, fixed it,
verified the assertion semantics still hold:

```
$ pytest tests/e2e/scenarios/test_v2_auth_oauth_matrix.py::test_wasm_tool_oauth_refresh_on_demand
PASSED in 6.74s

$ pytest tests/e2e/scenarios/test_v2_auth_oauth_matrix.py::test_wasm_tool_first_chat_auth_attempt_emits_auth_url
XFAIL in 93s

$ pytest tests/e2e/scenarios/test_v2_auth_oauth_matrix.py -v --timeout=120
12 passed, 2 skipped, 3 xfailed (browser tests errored locally;
they'll run cleanly in CI)
```

The browser-driven `test_mcp_same_server_multi_user_via_browser`
couldn't be exercised locally (chromium can't launch under this
shell sandbox), but the API + auto-approve flow it now relies on is
exercised by an HTTP-equivalent repro and matches the pattern used
in `test_settings_first_gmail_auth_then_chat_runs`.

* test(e2e): set LLM_API_KEY in auth_sse_server fixture

`test_auth_required_sse_without_duplicate_response` was failing in the
merge queue on every PR (most recently bouncing nearai#3197 and nearai#3203 from
the queue) because the `auth_sse_server` fixture never set
`LLM_API_KEY` in the spawned ironclaw env. After nearai#2572 added a missing-
API-key check to the openai_compatible config validator (Apr 22),
ironclaw rejected the env-supplied openai_compatible config, fell back
to the NearAI default, hit "missing session token", and failed the
turn before the github skill could even fire its 401.

The chat thus reached `state: Failed` with no tool calls and no
`onboarding_state/auth_required` event — which is exactly what the
test asserted on, hence the consistent failure.

Adding `LLM_API_KEY=mock-api-key` matches the value already used in
every other e2e fixture (auth_matrix, conftest's ironclaw_server,
v2_engine, etc.) and unblocks the assertion. Local run: PASSED in 8s.

* fix(gateway): suppress duplicate assistant bubble after streamed response

[skip-regression-check]

The SSE `response` handler unconditionally called addMessage('assistant',
data.content) even when stream_chunks had already populated and
finalized a bubble for the same response. This stayed invisible in the
common case but surfaced as a hard test failure under the path
test_switching_back_preserves_in_progress_turn:

1. Send "What is 2+2?" on thread A — stream chunks start filling an
   assistant bubble with "data-streaming".
2. Switch to thread B mid-stream — container clears (history reload).
3. Switch back to thread A — history rehydration shows the in-progress
   turn with no response yet, so 0 assistant bubbles in DOM.
4. Stream chunks continue to fire for A — appendToLastAssistant creates
   a new bubble and accumulates the response into it.
5. response event fires — flushes any remaining buffer, removes the
   data-streaming flag (good) — then addMessage('assistant', content)
   creates a SECOND identical bubble.

Result: locator(".message.assistant").filter(has_text="4") matches
two elements, Playwright strict mode rejects the wait_for, the test
fails. Outside the test, two identical bubbles render to the user.

Fix: only call addMessage in the response handler when there was no
in-flight streaming bubble. If one existed, the streamed content is
already correct (chunks accumulate `data.content` verbatim) and the
data-streaming flag has just been cleared. Non-streaming responses
(no chunks fired) still take the addMessage branch.

Regression coverage: tests/e2e/scenarios/test_message_persistence.py::
test_switching_back_preserves_in_progress_turn already reproduces this
exact scenario and was failing in the merge queue. With this fix it
passes; skip-regression-check used because the existing E2E test is
the regression test, and the gateway doesn't have a JS unit test
harness for SSE handler state.

* fix(gateway): dedupe history-rendered SSE responses

* test(e2e): set mock LLM API key in standalone fixtures

* fix(e2e): make v2 approval tests deterministic

* test(e2e): stabilize duplicate skill install assertion

* test(e2e): assert duplicate install stays ungated

* fix(skills): skip approval for disk-installed duplicates

* fix(v2): honor no-op skill installs without approval

* test(e2e): wait for pending send marker to clear

---------

Co-authored-by: Firat Sertgoz <f@nuff.tech>
Move the existing database backends and configuration reference
docs out of drafts/ and into the live navigation under
Core Capabilities.

- capabilities/database.mdx: PostgreSQL vs libSQL setup, env vars,
  SSL modes, hybrid search, migration, backup, troubleshooting
- capabilities/configuration.mdx: full environment variable reference
  with two-layer config system
- docs.json: add both pages to Core Capabilities nav group
- Add DATABASE_POOL_SIZE env var (default 30) to PostgreSQL config section
- Fix /setup/configuration → /capabilities/configuration
- Fix /install/vps → /infrastructure/droplet (live page)
- Fix /setup/database → /capabilities/database
- Fix /providers → /capabilities/llm-providers
- State explicitly that PostgreSQL is the default backend
- Add warning that DATABASE_URL is required (shows exact error message)
- Add Docker Compose as recommended installation method (accordion)
- Document all DATABASE_BACKEND aliases (postgres/postgresql/pg/libsql/turso/sqlite)
- Add DATABASE_POOL_SIZE to config example
- Add warning that LIBSQL_AUTH_TOKEN is required with LIBSQL_URL
- Fix onboard page: mention both backends, link to database docs
- Fix onboard step title: 'Select the Database Path' → 'Select the Database'
Gemini review pointed out that sqlite3 .dump output is often incompatible
with PostgreSQL (PRAGMA statements, type differences, quoting).

- Add warning callout explaining the incompatibility
- Recommend pgloader as the primary migration tool
- Keep manual export as fallback with editing caveat
- Fix DATABASE_POOL_SIZE default: 10 → 30 (matches code)
- Quickstart local tab: add 'by default' to libSQL mention and link to
  database backends page for production/multi-user setups
Defaults corrected against Rust source (src/config/settings.rs, channels.rs):
- AGENT_JOB_TIMEOUT_SECS: 300 → 3600 (1 hour)
- AGENT_STUCK_THRESHOLD_SECS: 60 → 300 (5 min)
- SELF_REPAIR_CHECK_INTERVAL_SECS: 30 → 60 (1 min)
- SESSION_IDLE_TIMEOUT_SECS: 3600 → 604800 (7 days)
- ROUTINES_CRON_INTERVAL: 60 → 15
- ROUTINES_MAX_CONCURRENT: 3 → 10
- EMBEDDING_ENABLED: true → false
- EMBEDDING_PROVIDER: openai → nearai
- HTTP_HOST: 0.0.0.0 → 127.0.0.1
- SKILLS_MAX_TOKENS → SKILLS_MAX_CONTEXT_TOKENS (renamed)

Phantom env vars removed (don't exist in codebase):
- GATEWAY_USER_ID, HTTP_USER_ID, HEARTBEAT_NOTIFY_CHANNEL,
  HEARTBEAT_NOTIFY_USER, SKILLS_CATALOG_URL, SKILLS_AUTO_DISCOVER

Also fixed HTTP webhook warning to match actual default binding.
Add SUPERSEDED comment to drafts/setup/database.mdx and
drafts/setup/configuration.mdx pointing to their promoted
live versions in capabilities/.
…ation

docs: salvage database and configuration docs navigation from nearai#2948
* docs(docker): fix Docker Hub image name nearai/ironclaw -> nearaidev/ironclaw (nearai#2963)

Closes nearai#2963.

@magnusviri reported `pull access denied for nearai/ironclaw,
repository does not exist` when following the install/docker.mdx
guide. The Docker Hub repo is `nearaidev/ironclaw` (confirmed by
both the publish workflow at .github/workflows/docker.yml — IMAGE_NAME:
nearaidev/ironclaw — and the public Docker Hub page); `nearai/ironclaw`
was never the correct image and is not under our control.

Sweeps every Docker-Hub image reference in the docs to the correct
name. README.md is intentionally untouched — its `nearai/ironclaw`
references are gitcgr.com URLs to the source repository, not Docker
Hub image pulls.

Files updated:
- docs/drafts/install/docker.mdx (5 occurrences)
- docs/drafts/install/updating.mdx (2 occurrences)
- docs/drafts/install/uninstalling.mdx (1 occurrence)
- docs/drafts/platforms/docker-compose.mdx (1 occurrence)

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

* docs(docker): clarify latest tag guidance

---------

Co-authored-by: Abhishek Vaidyanathan <abhishekvaidyanathan@0a:c3:a6:ef:bb:28.home>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rai#2901 (nearai#3265)

* fix(skills): linear credential injection and identity bootstrap

Fix credential injection: `type: bearer` → `type: header` with explicit
`name: Authorization` — Linear API keys are sent raw, not as Bearer tokens.
The wrong injection type caused all authenticated requests to fail silently.

Also add first-use identity bootstrap (cache viewer id/email/teams in
`context/intel/linear-identity.md`, 30-day TTL), use cached user_id for
assignee filters, and tighten activation keywords/patterns.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* use single flat list for yaml in skills/linear/SKILL.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* unquote graphql enum values in skills/linear/SKILL.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* skills(linear): remove stale cross-skill reference

---------

Co-authored-by: Tobias Holenstein <tobias.holenstein@near.foundation>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…earai#3267)

* test(e2e): add Admin and Responses API scenarios

Salvaged from nearai#2174 commit 663a388.

The original PR also edited tests/e2e/conftest.py, including removal of Slack E2E fixtures and SSE wait logic. Those stale fixture edits are intentionally omitted because current main still has Slack E2E tests and current conftest already configures SECRETS_MASTER_KEY for the shared server.

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

* test(e2e): avoid optional OpenAI SDK dependency

---------

Co-authored-by: Pranav Raja <pranavraja99@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* ci: add deterministic nightly deep checks

* fix(ci): preserve nightly test gating

* chore(ci): mark workflow-only regression check

The workflow-only fixes are verified with actionlint and shell classifier simulations; there is no Rust regression test target for this YAML behavior.\n\n[skip-regression-check]
* ci: add deterministic nightly deep checks

* ci: slim main merge queue checks
fix(e2e): stabilize coverage suite failures
* ci: add nightly failure issue alerts

* ci: harden nightly alert reporting
…earai#3253)

* feat: multi-tenant relay channel with per-user identity resolution

Wire PairingStore into RelayChannel so incoming Slack events resolve the
sender_id to an internal IronClaw UserId. This enables multi-tenant
IronClaw where multiple users each have their own Slack connection.

Key changes:
- RelayChannel resolves sender_id → internal UserId via PairingStore
  on every incoming event (falls back to raw sender_id for single-tenant)
- OAuth callback creates a channel_identity pairing between the Slack
  authed_user_id and the IronClaw user who initiated the OAuth flow
- ExtensionManager stores oauth_user during OAuth initiation so the
  callback knows which user to pair
- PairingStore gains create_identity() for trusted OAuth-based pairing
- Both DB backends (postgres + libsql) implement create_channel_identity
- RelayClient passes slack_user_id on proxy calls for display name

Depends on: nearai/channel-relay#14

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

* style: fix cargo fmt

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

* fix: preserve user role when creating relay channel identity

Look up the actual user role from the DB instead of hardcoding
UserRole::Regular. Prevents owner/admin capabilities from being
silently stripped when their Slack identity is cached via PairingStore.

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

* feat: pairing code flow for unpaired Slack senders

When an unpaired Slack user DMs the bot, generate a pairing code via
PairingStore and reply with instructions. The user enters the code in
the IronClaw web UI to pair their account. Reuses the existing WASM
channel pairing infrastructure (pairing_requests table, /api/pairing
endpoint).

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

* fix: address review comments — security and correctness

1. Share OwnershipCache between gateway and ExtensionManager PairingStore
   so admin suspend/delete evicts relay identity cache (serrrfirat)
2. Preserve raw Slack sender_id via with_sender_id() on IncomingMessage
   so downstream code has both internal user_id and external actor (serrrfirat)
3. Drop messages on pairing resolution error instead of admitting raw
   sender_id — fail closed when PairingStore is configured (serrrfirat)
4. Verify OAuth user is active before creating relay identity (serrrfirat)
5. Fail OAuth callback when identity creation fails instead of silently
   continuing with partial state (serrrfirat)

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

* fix: address three issues introduced by multi-tenant relay

1. authed_user_id tampering: fetch from relay connections API instead of
   trusting the redirect URL query parameter. The relay is the authority
   on which Slack user completed OAuth.

2. Workspace-scoped identity: external_id in channel_identities is now
   "team_id:slack_user_id" instead of bare "slack_user_id". Prevents
   stale mappings when the relay is reconnected to a different workspace.

3. Shared OwnershipCache: create once before init_extensions and share
   between the gateway PairingStore and ExtensionManager PairingStore.
   Admin suspend/delete now evicts relay identity cache.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com>
Co-authored-by: firat.sertgoz <firat.sertgoz@near.ai>
…arai#3197)

* fix(bridge): coerce engine action params per schema; resolves nearai#3132

LLMs routinely send numeric tool params as JSON strings (`"120"` instead
of `120`). Host tools handle this — `ToolDispatcher::dispatch` runs
`prepare_tool_params` which coerces against the tool's JSON Schema. But
engine actions (`mission_*`, `routine_*` aliases, `tool_info`) are
intercepted in `effect_adapter::execute_action_internal` *before* that
coercion ever runs, so `mission_create` rejected `cooldown_secs="120"`
with `'cooldown_secs' must be an integer, got "120"`.

Lift the schema-guided coercion to the engine-action dispatch boundary
so both pipelines share the same input pre-amble. Schema sources, in
order: orchestrator-populated `available_actions_snapshot`, bridge-known
mission action defs, host tool registry. Idempotent for already-typed
inputs, so the existing host-tool path sees unchanged shape.

The `extract_guardrails` / `strict_u64` helper stays strict as defense
in depth — if a future code path bypasses the new pre-amble, it still
fails loudly rather than silently dropping the value.

Tests:
- mission_create_string_guardrails_coerced_via_execute_action — drives
  execute_action with all four guardrail params as strings; asserts
  values persist correctly to the mission row.
- mission_update_string_guardrails_coerced_via_execute_action.
- mission_create_non_coercible_string_guardrail_returns_error —
  `"abc"` still surfaces a clean error.
- extract_guardrails_rejects_string_typed_integers (existing, doc
  comment updated to reflect defense-in-depth role).

* refactor(bridge): drop redundant sandbox-path coercion after upstream lift

Follow-up to the schema-guided coercion lift in execute_action_internal.
Two cleanups now that the upstream pre-amble runs for every action:

1. Use `discovery_schema()` consistently for both the ActionDef and
   host-tool paths (matches what `prepare_tool_params` was doing all
   along — for tools whose `parameters_schema()` is permissive but
   `discovery_schema()` is strict, this is the schema you actually
   want for coercion).

2. Drop the explicit `prepare_tool_params` call in the sandbox branch
   (was lines 1572-1581). It existed to keep the sandbox-path
   validator and `maybe_intercept` in sync with the host path's
   coercion; now that `parameters` is coerced once at the top of
   `execute_action_internal`, both downstream callees see the same
   shape without the duplicate pass.

The remaining `prepare_tool_params` call inside `execute_tool_with_safety`
(`tools/execute.rs:40`) stays as-is — it serves both engine v2 (now
idempotent) and v1 callers like `execute_chat_tool_standalone` that
don't pre-coerce. Lifting that one out is a v1-side refactor outside
nearai#3132's scope.

Coverage from the prior commit's tests is unchanged: the engine v2
mission_create / mission_update coercion tests exercise the same
upstream code path, and engine_v2_sandbox_integration tests exercise
the sandbox branch with already-coerced params.

[skip-regression-check] No new behavior — this is a code-deletion
refactor of the duplicate coercion call site. The behavior is covered
by the prior commit's tests; this commit just removes the now-dead
parallel path.

* refactor(bridge): address nearai#3197 review — flatten schema chain, fix doc

Two review-driven cleanups (PR nearai#3197):

1. Schema lookup chain: collapse the double-`match`-with-shadowing into
   the if/else if chain Gemini suggested, with `discovery_schema()` on
   both branches (Copilot's earlier point that the host-tool fallback
   needed `discovery_schema`, not `parameters_schema`, was already
   addressed in commit e492f59). Three branches, one `prepare_params_for_schema`
   call per branch, no shadowing.

2. Doc comment on `engine_action_schema`: previous wording claimed
   `mission_*`, `routine_*`, and `tool_info` are "not in the host
   ToolRegistry" — only `mission_*` is actually absent. `routine_*`
   tools are registered as legacy v1 host Tools (intercepted by the
   alias path in v2 before they execute) and `tool_info` is a v1/v2
   host tool. Both reach `execute_action_internal`'s registry branch
   directly. Reworded the doc to call out the asymmetry honestly.

[skip-regression-check] No behavior change — readability refactor and
doc correction. Coverage from existing tests unchanged.
…arai#3301)

* ci: build ironclaw staging tag from main branch

* style: comment

* comment fix

---------

Co-authored-by: Nick Pismenkov <50764773+nickpismenkov@users.noreply.github.com>
Co-authored-by: Nikolay Pismenkov <nickpismenkov@gmail.com>
…arai#3310)

Update FEATURE_PARITY.md with the latest OpenClaw releases from March 11
through April 30, 2026. Adds entries across infrastructure, channel-specific
features (Telegram, Discord, Slack, Mattermost, Lark, QQBot, BlueBubbles,
Voice Call, Google Meet, Yuanbao, WeCom), and other core capabilities
(OpenAI-compat endpoints, OpenTelemetry/Prometheus exporters, outbound
proxy routing, diagnostics bundles).
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…nearai#3912/nearai#3913 (nearai#3921)

* refactor(hooks): align ExtensionId/HookLocalId with newtype template

Address henrypark133 approval-with-followup items from PR nearai#3912:

- L1: remove dead empty-id guard in HookManifestEntry::validate.
  HookLocalId::new now rejects empty strings at construction, so
  manifest deserialization fails before validate() is ever called.
- L2: add AsRef<str>, From<Self> for String, and into_inner() to
  ExtensionId and HookLocalId per the canonical newtype template in
  .claude/rules/types.md. into_string() is retained as a thin alias
  for compatibility; new code should prefer into_inner().
- L3: document why the builtin_id_distinct_from_extension_id test
  fixture substitutes "path.module" for the original "path::module"
  (the new grammar rejects colons in HookLocalId).

No behavioral change.

* fix(hooks): fire AfterCapability observer for hook-suspended entries after allowed entries

henrypark133 M1 on PR nearai#3911: in `HookedLoopCapabilityPort::invoke_capability_batch`,
when Phase 1 produced a mix of Pending (hook-allowed) and Resolved
(hook-suspension) slots with the suspension appearing AFTER an allowed
entry and `stop_on_first_suspension = true`, the merge loop initialized
`stopped_on_suspension` from `stopped_in_preflight` and broke after the
very first iteration. Result: trailing Resolved suspension slots never
fired their `AfterCapability` observer and never surfaced in the merged
`outcomes` vec, violating the per-entry observer contract from PR nearai#3573
(serrrfirat P2 #3).

Fix: continue iterating the merge loop so every slot fires its observer
and every Resolved outcome is pushed. Only Pending slots are dropped
after a stop (their inner work was already short-circuited in Phase 1
or by an early inner-port stop), tracked via `pending_after_stop`.
Re-pop guard on `inner_outcomes.pop()` keeps the previous "inner
stopped early on its own suspension" semantics: pending slots without
an inner outcome are dropped, but the loop continues so any trailing
Resolved observers still fire.

New regression test
`batch_invocation_fires_observer_for_hook_suspended_entry_after_allowed_entry_with_stop_on_first_suspension`
pins the behavior: `[alpha=hook-allowed, beta=hook-suspension]` with
`stop_on_first_suspension = true` produces a 2-entry `outcomes` vec
(Completed alpha + ApprovalRequired beta), fires the observer twice,
and only sends alpha to the inner port. Verified TDD-style: the test
fails on the pre-fix code with `outcomes.len() = 1`.

* perf(hooks): reuse serialized argument bytes in lazy resolve

henrypark133 L1 on PR nearai#3913: `resolve_arguments` measured the
post-resolver JSON payload by calling `serde_json::to_vec(&value)`
and discarding the `Vec<u8>` once its length was checked. The
`SanitizedArguments::from_json` constructor on the happy path
sanitizes the in-memory `serde_json::Value` directly without
re-serializing, so the materialized buffer was pure overhead.

Switch the size measurement to `serialized_len`, a counting
`io::Write` adapter that streams `serde_json::to_writer` into a u64
counter — saves one Vec<u8> allocation and the matching drop per
resolved invocation. Behavior is identical: the same JSON
encoding rules drive both writers, the cap check still fires when
the encoded length exceeds `MAX_PREDICATE_INPUT_BYTES`, and
serialization errors still fail closed.

No new tests required; existing
`dispatch_fails_closed_when_input_exceeds_max_bytes` and the
ordering / lazy-probe tests exercise this path.

* test(hooks): cover mixed needs_input hook binding short-circuit

henrypark133 L2 on PR nearai#3913: add
`before_capability_needs_input_returns_true_when_any_active_binding_needs_input`.
Installs two BeforeCapability bindings on the same scope (Global) — one
`needs_input() = false`, one `needs_input() = true` — and asserts both
ends of the short-circuit:

  1. `HookDispatcher::before_capability_needs_input(None)` returns true.
  2. Driving `HookedLoopCapabilityPort::invoke_capability` with an
     instrumented `ProbingResolver` confirms the resolver IS consulted
     exactly once — i.e. the short-circuit fires through the call site,
     not just the helper.

This pins the "any input-needing binding wins" contract end-to-end so a
future change to the dispatcher's probe (or to the middleware's
lazy-probe gate) can't silently regress to short-circuiting on the
first binding only.

Also tightens the merge-loop's pending-slot drop path to use
`Option::map` (clippy::manual_map) — cosmetic, no behavior change.
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…3920)

* Implement installed WASM hook runtime

Adds crates/ironclaw_hooks/docs/threat-model-wasm.md and follows the reviewed design ack: 1) module bytes are resolved, digest-cached, and compiled in the tool-WASM style while reusing its resource limiter; 2) each invocation gets a fresh wasmtime Store; 3) the ABI is a wasmtime::Linker surface, not wit-bindgen; 4) host-import sink shims enforce call, patch-byte, observer-fact, and decision budgets.

* Harden WASM hook string and metadata budgets

* fix(hooks): validate WASM hook ABI at install time (serrrfirat #3 on PR nearai#3634)

Address serrrfirat MEDIUM finding #3: `WasmHookRuntime::prepare()` compiled
and cached module bytes but did not validate imports or the requested
export. ABI mismatches (unsupported import, missing export, wrong export
signature) were deferred to first live dispatch — and the prior
`wasm_unsupported_host_import_fails_closed` test codified that a
bad-import module would install successfully and only fail closed at
invocation. Malformed untrusted modules should never reach live traffic.

Changes:
- `prepare()` derives the target hook point from `request.kind`, then
  runs `validate_module_abi()`: scratch-instantiate the module against
  the point-specific linker (catches unsupported / wrong-type imports)
  and resolve the typed export `() -> ()` (catches missing export and
  wrong signature). Failures surface as new
  `WasmHookRuntimeError::InvalidImports` or existing
  `WasmHookRuntimeError::InvalidExport`, both of which bubble up as
  `HookError::RegistryConstruction` from the registrar.
- `wasm_point_for_kind(HookManifestKind)` helper centralizes the
  kind → wasm-point mapping; the previous `execute_*` paths can share
  it in a follow-up but kept inline for now to minimize churn.

Tests:
- `wasm_unsupported_host_import_is_rejected_at_install_time`: replaces
  the prior test that codified late-failure behavior; asserts the
  registrar returns `RegistryConstruction` citing the bad import.
- `wasm_missing_export_is_rejected_at_install_time`: new module that
  compiles but lacks the manifest-declared export; same install-time
  rejection.

* fix(hooks): address henrypark133 must-fix #1, #2, #3 on PR nearai#3634

Three items from the 5-15 review:

**#1 (must-fix) Extract ironclaw_wasm_limiter micro-crate**
Replace `#[path = "../../../ironclaw_wasm/src/limiter.rs"]` cross-crate
file import with a proper Cargo edge. The 111-line `WasmResourceLimiter`
moves into a new `crates/ironclaw_wasm_limiter` micro-crate that both
`ironclaw_wasm` and `ironclaw_hooks` depend on. The architecture rule
forbidding `ironclaw_hooks -> ironclaw_wasm` is preserved (the new
crate sits below both consumers and pulls in only `wasmtime` +
`tracing`); `cargo check`, `cargo doc`, and architecture-linting tests
now see the edge, and the file can't be moved out from under one of
the consumers silently.

Mechanical changes:
- new `crates/ironclaw_wasm_limiter/` (Cargo.toml + src/lib.rs with the
  type exposed as `pub` instead of `pub(crate)`)
- workspace `members` entry added
- `crates/ironclaw_wasm/src/limiter.rs` deleted
- `crates/ironclaw_wasm/src/lib.rs`: `mod limiter` removed
- `crates/ironclaw_wasm/src/store.rs`: import switched to
  `ironclaw_wasm_limiter::WasmResourceLimiter`
- `crates/ironclaw_wasm/Cargo.toml`: dep added
- `crates/ironclaw_hooks/Cargo.toml`: dep added
- `crates/ironclaw_hooks/src/wasm/runtime.rs`: `#[path = ...]` block
  removed; import switched to the crate

**#2 + #3 (must-fix) Dead WASM arms in dispatch**
`run_before_capability_hook`, `run_before_prompt_hook`, and
`run_observer_hook` each had an early-return guard that dispatched
WASM hooks with `catch_unwind` + timeout, then ALSO had a matching
WASM arm in the inner `match` that ran without those protections. The
prompt-path arm additionally swallowed `WasmHookFailure` via `|_| ()`,
making the must-fix #2 problem worse on that path specifically.

If a future refactor removed any of the early-return guards, those
inner arms would silently take over and drop panic isolation, deadline
enforcement, AND (for prompts) the failure category. Replaced each
inner arm with `unreachable!()` carrying a comment that explains
why the arm exists and references the early-return guard above it.
A future refactor that removes the guard will now trip the
`unreachable!` at first call instead of silently degrading.

All 154 hooks lib + 29 reborn integration tests still pass.

* fix(hooks): plumb context to WASM hooks + runtime hardening

Critical #1 on PR nearai#3634: WASM hooks previously received no context. The
`execute_*` entry points dropped the `&BeforeCapabilityHookContext` /
`&BeforePromptHookContext` / `&ObserverHookContext` value and invoked
the guest export with `()`, so a WASM gate could never decide based on
the capability name, tenant, provider, or other dispatch-time facts. Add
an `ic:hooks/context@1` host-import module exposing two read-only
calls — `ctx_size() -> i32` and `ctx_read(ptr, len) -> i32` — backed by
a JSON-serialized blob the dispatcher writes per-invocation into the
fresh store. Modules that don't import these continue to link; modules
that do import them get a stable, non-empty payload to read. An
integration test (`wasm_before_capability_hook_reads_context_blob`)
asserts the contract end-to-end: a guest that fails to read a non-empty
blob traps before its `deny` call.

Also rolls up the other reviewer-flagged WASM runtime issues, all of
which touch `wasm/runtime.rs`:

HIGH #2: epoch-tick background thread now holds a shutdown
`AtomicBool` and joins on `Drop`. Previously it looped forever and
leaked an Engine clone on every runtime drop.

MED #4: compiled-module cache is now an `lru::LruCache` bounded by
`MODULE_CACHE_CAPACITY = 128`. Replaces the unbounded `HashMap`.

MED #7: `prepare()` no longer compiles under the cache lock. Fast
path reads from LRU under a brief lock; slow path compiles outside
the lock and re-checks on insert to avoid the TOCTOU window where
two concurrent installs of the same module both compile.

Bug #9: post-call `deadline_exceeded()` re-check on the Ok branch
is gone. wasmtime epoch-interrupt is the authoritative wall-clock
signal; an Ok return is no longer reclassified as a timeout because
the wall ticked over during host-side return.

Bug #10: `add_milestone_metadata` returns a distinct
"metadata value exceeds the u32 byte-length ceiling" error when the
guest-supplied `value.len()` overflows u32, instead of misreporting it
as "exceeded total prompt-patch byte budget".

Existing integration tests for WASM hooks are also re-wired through
`HookRegistrar::with_verified_grants` so the grants-store gate added in
the foundation-01 merge stops failing the pre-existing fixtures.

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

* fix(hooks): run WASM hooks on the blocking pool

HIGH #3 on PR nearai#3634: `tokio::time::timeout` does NOT cancel synchronous
wasmtime execution. The previous code awaited a `catch_unwind(async { h.evaluate(ctx) })`
future whose body completed in one poll, so the timeout could only fire
*around* the WASM call rather than against it; a hook that wedged inside
wasmtime simply pinned the calling tokio task.

Route gate, prompt, and observer WASM dispatch paths through
`tokio::task::spawn_blocking` via a shared `run_wasm_blocking` helper.
The outer `tokio::time::timeout` now governs the JoinHandle, so a stuck
blocking task stops blocking the dispatcher's caller; the wasmtime
epoch interrupt configured in the runtime (10 ms tick) is the
authoritative in-WASM wall-clock cancel signal. JoinError (panic in
the blocking task) maps to `FailureCategory::Panic`, matching the
pre-existing semantics for synchronous panics caught via
`catch_unwind`.

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

* perf(hooks): O(1) hook-id lookup via side index

Finding #8 on PR nearai#3634: `set_priority`, `poison`, `is_poisoned`, and
`contains_hook` all did full-registry scans over every binding at every
point. Each is called per-dispatch (poison-checks on the snapshot loop
in particular), so the cost is `O(registered_hooks)` per
`(installed_hook, registered_hook)` pair.

Maintain a denormalized `HashMap<HookId, (HookPointSpec, usize)>` side
index in lock-step with `by_point` so every per-hook-id operation
becomes a single hash lookup + a direct vec indexed access. The
duplicate-id rejection in `insert` now reads from the side index too,
turning what used to be a flat-map scan into a `HashMap::contains_key`.

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

* test(hooks): wall-clock timeout, observer memory, limiter rollback, registrar happy path

Round out the test set for the WASM hook execution path:

#11 / #12: gate + observer wall-clock timeout. The pre-fix dispatcher
ran wasmtime synchronously on the executor, so the outer
`tokio::time::timeout` `Err(_elapsed)` arm was effectively unreachable.
Now that WASM execution runs on the blocking pool, the timeout actually
fires; the new tests give the wasm budget headroom (1B fuel, 5s wall)
and the dispatcher a 20 ms timeout, then assert the failure
classification (FailClosed for gate, FailIsolated for observer).

#13: observer memory exhaustion. Mirrors
`wasm_memory_exhaustion_fails_closed_for_gate` against the observer
dispatch path so the FailIsolated branch of the failure matrix has
explicit memory coverage, not just fuel/wall.

#15: `WasmResourceLimiter::memory_grow_failed` rollback. Stages an
approved grow, simulates the OS-level grow failing, and asserts a
subsequent grow of the full ceiling succeeds — the inflated
`memory_used` from the failed attempt must be released.

#16: registrar WASM happy path. Companion to the existing
`install_wasm_body_requires_runtime` negative case: a valid module
installs, the binding is visible via the public registry accessor, and
is not pre-poisoned.

#14 (`add_milestone_metadata` happy path) is intentionally omitted —
the BeforePrompt dispatch path is currently unreachable due to a
pre-existing manifest-vs-registry scope conflict (`OwnCapabilities` is
the only valid `BeforePrompt` scope per manifest validation, but the
registry rejects `OwnCapabilities` at `BeforePrompt` because the point
has no provider context). That contradiction sits outside this PR's
scope; flagging for a follow-up.

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

* refactor(hooks): typed WASM version material, reconcile design doc

LOW #20 on PR nearai#3634: extract the
`{extension_version}+wasm:{module_digest_hex}` concatenation into a
`WasmVersionMaterial` newtype with a single `Display` impl. The
identity material no longer floats free as a stringly-typed argument
inside the registrar.

Reconcile `docs/successors/02-wasm-runtime.md` with the implementation:

- Spell out that wall-clock cancellation depends on the
  `tokio::time::timeout(tokio::task::spawn_blocking(...))` pair, and
  explain why a bare timeout over a synchronous wasmtime call cannot
  actually cancel.
- Define `FailIsolated` and `FailClosed` as `FailureDisposition`
  values, distinct from the older `HookFailureMode::{FailOpen,
  FailClosed}` policy switch that applies to predicates.
- Clarify the generic `evaluate` export contract — name is whatever
  the manifest declares, signature is `(): ()`, context arrives
  through the new `ic:hooks/context@1` host imports — and note the
  intentional divergence from `WitToolRuntime`'s hardcoded interface.

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

* fix(hooks): drop .expect() in WASM module cache capacity

Pre-commit no-panics CI flagged the .expect() on the LruCache capacity.
Move the validity check to a const match, so the NonZeroUsize is fixed at
compile time and the no-panics regex is satisfied.

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

* fix(hooks): use HookLocalId::new after newtype privatization

The newtype-privatization landed in reborn-integration after the
hooks-fu-wasm-runtime branch's WASM scaffolding tests were written;
update the affected test/registrar sites to use HookLocalId::new
instead of the now-private tuple constructor.

* style: cargo fmt after newtype-privatization fixups

* test(hooks): ignore 3 BeforePrompt WASM tests with manifest/registry conflict

These tests were failing on the original branch tip too (verified against
origin/hooks-fu-wasm-runtime @ 571efdf). The Installed-tier BeforePrompt
WASM install path has no valid scope today:
  - OwnCapabilities is rejected by the registry C3 check (finding #2 on
    PR nearai#3573) since BeforePrompt has no per-capability invocation
    context.
  - SameTenant is rejected by manifest validation ("cannot combine
    scope = same_tenant with kind = before_prompt").

The budget-overflow paths these tests exercise are point-agnostic; the
follow-up is to either rewrite the helper to install through
BeforeCapability or add a Global manifest scope. Tracked as a deferred
item on the new PR.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…i#3573) (nearai#3635)

* docs(hooks): scope persistent predicate counter backend (successor #3)

Successor PR from nearai#3573. Current sliding-window state is in-memory and
resets on restart. Adds a PredicateStateBackend trait + Postgres/libSQL
impls for cross-process and restart-survival semantics.

* feat(hooks): extract PredicateStateBackend trait + replay-safe in-memory impl

Addresses codex review's three Critical findings on PR nearai#3635:

1. Backend wiring: the trait is now registered (lib.rs:25-26) and
   PredicateEvaluator delegates to Arc<dyn PredicateStateBackend>
   via with_backend(...). Default constructor preserves the
   in-memory behavior so all 154 existing tests pass unchanged.

2. Atomic record-and-read: each record_invocation / record_value
   call performs the write AND returns the resulting in-window
   count/sum under a single mutex (in-memory) / transaction
   (durable backends). Splitting into separate record + read
   would let two hosts each see 'under cap' and both proceed,
   drifting past max.

3. Replay refusal: each record call carries a PredicateEventId.
   Re-emitting the same event_id is a no-op against the count.
   In-memory backend implements via a per-key bounded set
   (RECENT_EVENT_ID_CAP = 256); durable backends will use
   INSERT … ON CONFLICT DO NOTHING.

Trait surface (predicate_state.rs):
- PredicateEventId(String): opaque dedup key
- PredicateBackendError: thiserror enum for fallible durable
  backends; in-memory backend never returns Err
- PredicateStateBackend trait with Result return types
- InMemoryPredicateStateBackend default impl
- MAX_HISTORY_KEYS const re-exported via evaluator for back-compat

Evaluator changes (evaluator.rs):
- holds Arc<dyn PredicateStateBackend> (no more inline maps)
- evictions_observed() reads through to backend
- synth_event_id() generates per-call-unique ids via a
  process-local atomic counter so tests with identical
  (hook, ctx, now) still produce distinct ids
- LRU helpers + HistoryKey/ValueHistoryKey types moved into
  predicate_state.rs (as InvocationKey/ValueKey)

Tests:
- 6 new predicate_state tests:
  - in_memory_invocation_counts_within_window
  - in_memory_invocation_trims_outside_window
  - in_memory_value_sums_within_window
  - in_memory_tenant_isolation (regression on threat-model C2)
  - in_memory_duplicate_event_id_is_a_noop_for_invocations
  - in_memory_duplicate_event_id_is_a_noop_for_values
- 160 unit tests pass total. Reborn hooks_integration unchanged
  at 19 scenarios. Clippy/fmt/no-panics clean.

Sync trait + Instant timestamps documented as a v1 choice;
durable backends (Postgres, libSQL) will need an async companion
trait using SystemTime — tracked in the scope doc as the next
slice.

Scope doc: crates/ironclaw_hooks/docs/successors/03-persistent-counter.md

* fix(hooks): close codex P1 bugs in PredicateStateBackend in-memory impl

Addresses codex P1 review on PR nearai#3635:

P1 #1 — replay dedup loss under high-throughput keys
The prior design used a fixed-size (256) recent_ids ring per bucket
decoupled from entries. Under any workload with >256 distinct events
in the same window, the first event's id aged out of the ring while
its timestamp entry was still live, so a replay silently re-counted.

Fix: dedup memory is now intrinsic to entries. Each entry stores
(timestamp, event_id), and the dedup check is 'does any in-window
entry have this id?'. Dedup memory is therefore exactly the in-window
entry set — no fixed cap, no silent loss.

P1 #2 — zombie buckets clogging LRU
Two-part fix:
1. record_* drops empty buckets eagerly via history.remove(key).
   This is mostly defense-in-depth — under the new dedup design,
   the record path can't actually leave a bucket empty (proved in
   the test rationale comment).
2. evict_lru_* now preferentially targets empty buckets first
   (find any v.entries.is_empty()), only falling back to the
   oldest-timestamp scan if no empty bucket exists. Filter-out
   behavior is gone, so any empty bucket that somehow survives
   becomes the next eviction victim instead of a permanent zombie.

Test changes (+2 new, -0 removed):
- dedup_memory_covers_full_window_under_high_throughput: pushes 512
  distinct events into one bucket, then replays event-0. Pre-fix
  this would have counted again (silent dedup loss); post-fix the
  replay is a no-op.
- lru_evicts_empty_buckets_first: crafts an empty bucket alongside
  a live one, runs LRU eviction, asserts the empty one is evicted
  and the live one retained.

Tests: 162 unit total (+2 new). Clippy/fmt/no-panics clean.

* docs(hooks): address gemini review on persistent-counter scope doc

Four medium-priority doc nits from gemini-code-assist on the
crates/ironclaw_hooks/docs/successors/03-persistent-counter.md
scope:

1. run_id in the trait: removed. The trait dedupes on event_id
   (RuntimeEventId is already run-scoped), not run_id. Replaces
   the earlier 'backend stores (timestamp, run_id, event_id)' claim.

2. SystemTime vs chrono::DateTime<Utc>: switched to DateTime<Utc>
   to match project convention (src/db/mod.rs, ironclaw_events).
   The in-memory backend keeps Instant for monotonic process-local
   semantics; durable backends require DateTime<Utc> for cross-
   process serialization. Documented as a clock note.

3. libSQL TEXT column for rust_decimal: per src/db/CLAUDE.md,
   libSQL can't preserve Decimal precision with numeric/real
   types. LibSqlPredicateStateBackend serializes value as TEXT
   via Decimal::to_string() / from_str(). Postgres impl keeps
   numeric (correct for PG). Documented as the two LibSql-specific
   schema differences.

4. Batched-writes vs cross-process consistency tension: gemini was
   right that deferring writes to the tick boundary breaks
   requirement #1 (two hosts would each see 'under cap'
   simultaneously). v1 production backend keeps writes synchronous;
   future optimization batches reads (not writes).

* fix(hooks): thread stable caller_event_id through hook context (replay dedup)

henrypark133 HIGH on PR nearai#3635 + serrrfirat HIGH #1: the
`PredicateBackedBeforeCapabilityHook -> PredicateEvaluator` path
always synthesized a fresh `event_id` per evaluation by mixing in a
process-local atomic counter, so the same logical invocation
retried/replayed always got a different id. The backend's UNIQUE
constraint on `event_id` — the load-bearing dedup contract — never
engaged on the real production path. Replay dedup was effectively
"documented but unused."

Plumb a stable per-invocation identity through the public hook
surface:
- `BeforeCapabilityHookContext` gains a
  `caller_event_id: Option<PredicateEventId>` field. Middleware that
  threads through from the calling layer's runtime event identity
  populates `Some(...)`; older / in-memory-only callers pass `None`
  and degrade to the current synth path (no behavior change).
- New builder method `with_caller_event_id(...)`.
- `PredicateEvaluator` resolves the id through a new `resolve_event_id`
  helper: prefer `ctx.caller_event_id`, fall back to `synth_event_id`.
  Both `record_invocation` and `record_value` paths use it.
- Backend dedup behavior is unchanged — it was already correct on
  `event_id`. The bug was the caller path never supplying a stable id.

Tests (caller-boundary, henrypark133's required regression):
- `duplicate_caller_event_id_is_deduped_in_invocation_count`: two
  evaluations with the same `caller_event_id` count as one
  invocation; a third with a different id counts as two; a fourth
  crosses the cap. Sanity branch confirms the no-id synth path still
  exhibits "every call counts" semantics.

This is the API contract slice. Wiring the middleware to actually
supply a stable id (e.g. derived from the originating
`RuntimeEventId` once that runs through the BeforeCapability path)
is the follow-up that lights up the durable backend's end-to-end
replay-safety promise.

* refactor(hooks): demote PredicateStateBackend to pub(crate) (serrrfirat MED on PR nearai#3635)

serrrfirat MED: the `predicate_state` module exposed
`PredicateStateBackend` as `pub`, but the trait's `now: Instant`
parameter is process-local and not serializable. Any external durable
backend impl built against the current trait would have to be
rewritten when the durable contract lands with `chrono::DateTime<Utc>`
(see successor doc 03-persistent-counter.md). Hold the public surface
back until that contract is stable so we don't ship a public API we
know we'll break.

Demoted to `pub(crate)`:
- `PredicateStateBackend` (trait)
- `InvocationKey`, `ValueKey` (key types — backend ABI only)
- `PredicateBackendError` (error type, with `#[allow(dead_code)]` on
  the `Unavailable` variant since the in-memory backend is infallible
  and no durable backend exists yet)
- `InMemoryPredicateStateBackend` (the only impl)
- `PredicateEvaluator::with_backend` (with `#[allow(dead_code)]` —
  reserved for future internal injection paths)

Kept `pub`:
- `PredicateEventId` — it appears on the public hook surface via
  `BeforeCapabilityHookContext::caller_event_id` (from the nearai#3635 HIGH
  fix). Hook authors who want stable replay-dedup ids construct one.

No behavior change. All 163 hooks lib tests + 19 reborn integration
tests still pass.

* fix(hooks): address henrypark133 must-fix #1-5 on PR nearai#3635

Five items from the 5-15 review:

**#1 (must-fix) O(n) dedup scan**
The previous `bucket.entries.iter().any(...)` linear scan held the
outer history mutex while walking thousands of in-window entries at
high throughput. Add a companion `HashSet<PredicateEventId>` per
bucket (`InvocationBucket.dedup_ids` / `ValueBucket.dedup_ids`),
maintained alongside the deque via `pop_front`/`push_back` helpers.
O(1) dedup, same correctness, same memory bound (one set entry per
in-window entry — no fixed ring).

**#2 (must-fix) Mutex poison cascade**
`.expect("predicate history mutex poisoned")` propagated a panic to
every subsequent caller. Replace with
`match self.invocation_history.lock() { Ok(g) => g, Err(p) => p.into_inner() }`
so a poisoning thread doesn't take down all subsequent evaluations.

**#3 (must-fix) `caller_event_id` format validation**
`with_caller_event_id` now rejects empty strings and ids containing
NUL bytes. Failed validation logs a `tracing::warn!` and leaves
`caller_event_id == None` so the synth path takes over — operator
sees the warning, predicate dedup still works.

Also: `PredicateEventId(pub String)` → `PredicateEventId(String)`
with `new()` / `as_str()` (henrypark133 nit #9). Inner field is no
longer in-place mutable from outside the crate.

**#4 (must-fix) `with_backend` is `#[cfg(test)]`**
Previously `#[allow(dead_code)]` — reachable from release builds and
inviting future callers to inject backends through an unstable seam.
Gated to `cfg(test)`.

**#5 (important) `evict_older_than` trait stub**
Default-impl no-op added to `PredicateStateBackend` so the trait
signature is locked before the first durable-backend PR. Trait-object
callers won't break when durable impls override it.

**Bonus** (henrypark133 missing-coverage #1):
`in_memory_record_invocation_is_atomic_under_concurrent_writers` —
32 threads each record a distinct event id; final count must equal 32,
proving the atomic record-and-read contract holds under contention.

**Bonus** (henrypark133 nit #10):
The third stable id in `duplicate_caller_event_id_is_deduped_in_invocation_count`
was 62 chars; bumped to 64 to match the synth output format.

* fix(hooks): clippy doc-list-indentation + remove unused with_backend (nearai#3635 CI)

* fix(hooks): address serrrfirat HIGH + MEDIUM on PR nearai#3635 (5-15 review)

**MEDIUM — `caller_event_id` validation bypass**
`with_caller_event_id` validated for empty/NUL but the field on
`BeforeCapabilityHookContext` is `pub`, so callers could direct-
assign `Some(PredicateEventId::new("..."))` with `new()` permissive
and bypass the setter entirely. Move validation INTO the type
boundary:

- `PredicateEventId::new(...) -> Result<Self, PredicateEventIdError>`
  validates non-empty + NUL-free at construction. Any value that
  reaches a downstream backend now satisfies the format invariant by
  construction.
- `PredicateEventId::new_unchecked(...)` for internal synth paths and
  tests that mint ids from known-good shapes (hex digests).
- `with_caller_event_id` drops its now-redundant runtime check; the
  type already enforces it.
- Internal synth in `evaluator.rs` switches to `new_unchecked` (64-char
  hex output is always valid by construction).

Tests:
- `predicate_event_id_rejects_empty`
- `predicate_event_id_rejects_nul_bytes`
- `predicate_event_id_accepts_typical_hex_digest`

**HIGH — durable schema: dedup scope mismatch**
The successor doc's Postgres schema declared `event_id uuid PRIMARY KEY`
(globally unique), but the trait's replay-refusal contract dedupes
within the counter `key`. `caller_event_id` is per capability
invocation — two predicate-backed hooks observing the same invocation
share an id. A global PK lets the first hook's INSERT win and silently
undercounts the second hook's bucket.

- `docs/successors/03-persistent-counter.md`: PK changes to composite
  `(tenant_id, hook_id, capability, event_id)` for invocations and
  `(tenant_id, hook_id, capability, field, event_id)` for values,
  matching the trait's per-key dedup scope.
- `predicate_state.rs` trait doc: replay-refusal section rewritten to
  spell out the per-key scope and the corresponding
  `INSERT … ON CONFLICT (tenant, hook, capability[, field], event_id)
  DO NOTHING` shape durable backends should use.

* docs(hooks): document host-assigned trust boundary on PredicateEventId

henrypark133 / serrrfirat blocker B4 on PR nearai#3635: the `caller_event_id`
threading through `BeforeCapabilityHookContext` partially shipped earlier
(commit b4d8a35), but the trust-boundary documentation explaining the
host-assigned invariant was still missing.

Add rustdoc to `PredicateEventId` and the `PredicateStateBackend` trait
clarifying that:

- the id MUST be minted by trusted host code from authoritative sources
  (dispatcher RuntimeEventId, host-side hash, arguments digest)
- it MUST NOT pass through unchanged from any tenant-controlled surface
  (capability arguments, manifest fields, WASM memory, HTTP bodies)
- the format invariants in `PredicateEventId::new` (non-empty, NUL-free)
  are a durability contract for SQL backends, NOT a trust check
- a tenant-supplied id can either undercount itself into infinity by
  replaying a fixed id, or poison adjacent buckets if scoping is ever
  weakened

Doc-only; no behavior change.

* test(hooks): add caller-boundary replay-dedup test through wrapper hook

henrypark133 HIGH blocker B1 on PR nearai#3635: replay dedup must engage at
the caller boundary — `PredicateBackedBeforeCapabilityHook::evaluate` is
the production path the dispatcher invokes for installed predicate
hooks. A unit test on `PredicateEvaluator::evaluate_at` alone is
insufficient regression coverage (repo CLAUDE.md rule "Test through the
caller, not just the helper"): the wrapper hook reads
`BeforeCapabilityHookContext::caller_event_id` and threads it down to
the backend, so the regression test must drive the wrapper itself.

The threading work already shipped in commit e6df47d
(`caller_event_id` field on the public hook context + evaluator
preferring it over the synth path). This commit adds the missing
end-to-end test:

1. Two `PredicateBackedBeforeCapabilityHook::evaluate` calls with the
   same `caller_event_id` and a `RateOrValueCap { max: 1 }` predicate —
   the second call must stay under cap (dedupe engages at the wrapper
   boundary, not be re-counted into a deny).
2. A third call with a DISTINCT `caller_event_id` crosses the cap —
   proving dedup is replay-scoped (same id → no-op), not blanket-
   suppress (any id → no-op).

If the wrapper were synthesizing a fresh id per call (the bug Henry
flagged before threading landed), this test would fail at step 2 with
the second evaluation being denied.

* docs(hooks): D5a + cross-process replay note; add caller-API tests

henrypark133 should-fix S8 + S9 on PR nearai#3635.

S8 — threat-model expansion:
- Add D5a as the correctness-under-attack variant of D5: an attacker
  flooding high-cardinality keys can LRU-evict legitimate tenants'
  counters and reset their rate-limit state. Distinct from the
  memory-only framing of D5; tied back to per-extension caps (D3/D4)
  and the durable-backend successor (doc 03).
- Document the cross-process replay limit on the in-memory backend
  inside the PredicateStateBackend trait docs, not just in D5 — the
  process-local dedup is a property callers need at the trait surface,
  with a pointer to the durable backend as the cross-host story.

S9 — three new tests on the in-memory backend public API:
- lru_eviction_via_public_api_holds_max_history_keys_cap: drives
  MAX_HISTORY_KEYS + 1 distinct keys through record_invocation and
  asserts the map size cap holds + evictions_observed() advances. The
  previous coverage manually crafted buckets and called the LRU helper
  directly; this exercises the production path.
- in_memory_invocation_retains_entry_at_exact_window_cutoff: pins the
  `< cutoff` trim semantics so a refactor to `<=` would fail loud.
- event_id_dedup_is_isolated_across_invocation_and_value_maps: same
  event_id used in both record_invocation and record_value must not
  cross-suppress — the two maps key on disjoint types.

The fourth S9 item (concurrent N-thread atomicity) and the caller-
boundary replay test on the wrapper hook already landed in earlier
commits (f632d22, predicate_state.rs line 840). S2 (evict_older_than
stub), S3 (sync-trait docs), and S7 (consistency vs batched-writes)
were also already in HEAD; this commit ships the remaining items.

Quality gate: cargo fmt clean, cargo clippy -p ironclaw_hooks
--all-features --tests -D warnings clean, full hooks test suite green
(15 predicate_state unit tests + lib + integration).

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

* chore(hooks): co-locate synth_event_id with backend; rationale comment; pin synth format

henrypark133 nits N1, N2, N5 on PR nearai#3635.

N1 — Move `synth_event_id` from `evaluator.rs` to `predicate_state.rs`
as `PredicateEventId::synth(...)`. The id format (64-char lowercase
hex, no NUL, never empty) is part of the backend's durable contract,
so co-locating with `PredicateStateBackend` keeps the format change-
surface adjacent to the consumer.

To avoid inverting the module dependency (`predicate_state` is a leaf
below `points`), the synth helper takes raw bytes / &str rather than
a `&BeforeCapabilityHookContext`. The evaluator's `resolve_event_id`
fallback unpacks the context and delegates.

N2 — `// safety:` comment on a non-`unsafe` block (the
`write!(s, "{byte:02x}")` infallibility note) renamed to
`// RATIONALE:`. By convention `// SAFETY:` pairs with `unsafe`
blocks; using `// safety:` elsewhere conflates the two.

N5 — Add `synth_event_id_is_64_char_lowercase_hex` to pin the synth
output shape. A refactor that silently changes length or case would
break the durable backend's `uuid`-shaped UNIQUE constraint without
a test failure today; the new test fails loud.

Quality gate: cargo fmt clean, cargo clippy --all --benches --tests
--examples --all-features -D warnings clean, full hooks lib test
suite green (172 passing including the new pin).

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

* fix(hooks): drop expect() in hex formatting to satisfy panic CI check

The "No panics in production code" CI check (scripts/check_no_panics.py)
only recognizes `// safety:` suppression markers, not `RATIONALE:`. Since
std::fmt::Write for String is infallible, just discard the Result with
`let _ =` instead of `.expect()` — no panic call, no marker needed.

Also merges in latest origin/hooks-foundation-01 (now includes the
reborn-integration merge and PR nearai#3636).

* fix(hooks): narrow caller_event_id visibility to pub(crate)

henrypark133 MED on PR nearai#3635 5-19 review. The pub field let external
callers bypass with_caller_event_id and assign values the validated
PredicateEventId constructor would have rejected. Force every external
caller through the typed setter so PredicateEventId::new is the only
entry point.

* fix(hooks): drop arguments_digest from synth + add in-memory backend warn

Two PR nearai#3635 5-19 review findings on the evaluator surface:

- henrypark133 LOW (synth oracle): drop arguments_digest from the
  PredicateEventId synth hash input. The 64-char hex output was an
  equality oracle for argument shape; replay dedup for durable backends
  uses the caller-supplied caller_event_id, not the synth path, so
  synth only needs to be per-call unique, not content-addressed.

- henrypark133 HIGH + MED (in-memory production limits): expose
  PredicateEvaluator::warn_in_memory_backend_active_in_production for
  hosts to call at startup. Multi-host replay dedup is process-local
  and the LRU cap is shared across tenants; operators need this
  surfaced in logs when the durable backend is not wired.

* fix(hooks): harden predicate state backend per PR nearai#3635 5-19 review

Address five findings on crates/ironclaw_hooks/src/predicate_state.rs:

- A1 (henrypark133 HIGH): restrict PredicateEventId::new_unchecked to
  pub(crate) so external callers cannot bypass the durable
  UNIQUE-constraint format invariants enforced by ::new.

- A4 (henrypark133 MED): per-tenant LRU quota at MAX_HISTORY_KEYS / 4.
  Without it a noisy tenant could fill the global cap and evict a
  quiet tenant's bucket, resetting their rate-limit counter. With the
  quota, a tenant that overflows evicts its OWN oldest-front bucket
  first. New tests cover single-tenant cap and cross-tenant isolation.

- A5 (henrypark133 LOW): drop arguments_digest from the synth hash
  input (oracle closure mirrored from the evaluator side). Add a
  thread-local nonce alongside the process-global counter so synth
  remains per-call unique without relying solely on a contended
  AtomicU64. New test pins the divergence invariant.

- D6 (henrypark133 HIGH): O(1) NumericSum via an incrementally-
  maintained ValueBucket::running_sum, replacing the O(n) deque walk
  on every record_value call. New test covers push/trim/replay
  interactions.

- D8 (henrypark133 MED): implement evict_older_than for the in-memory
  backend (was a no-op Ok(0) default). Drops entries strictly older
  than the cutoff and removes empty buckets; operator reaper tasks
  rely on this to reclaim memory from idle keys.

- D7 (henrypark133 MED, partial): document the process-global synth
  COUNTER as a known contention hotspot and add a thread-local nonce
  so threads can advance without forcing cross-core invalidation in
  the common path.

Tests: 196 passing (+4 new); workspace clippy clean.

* fix(hooks): port MAX_SAMPLES_PER_KEY cap into PredicateStateBackend (D5 regression from r3)

Round 3 of PR nearai#3573 (already merged into hooks-foundation-01) added an
inline per-key sample cap of 4_096 in evaluator.rs to bound memory under
attacker-triggered hot capabilities with very large declared windows
(threat-model finding D5). The predicate-state extraction in PR nearai#3635
moved that bookkeeping into the PredicateStateBackend trait but missed
porting the cap, so the cap would silently disappear from production
once this PR rebases onto the foundation branch.

This commit moves the cap into the in-memory backend impl next to
MAX_HISTORY_KEYS / MAX_KEYS_PER_TENANT and enforces it in both
record_invocation and record_value. For the NumericSum path, the bucket
helper's pop_front already decrements running_sum, so the incremental
sum invariant survives cap-driven eviction.

The pre-existing inline copy in evaluator.rs becomes redundant once the
trait impl owns the enforcement; the rebase resolution deletes it.

Adds two regression tests:
- record_invocation_caps_samples_per_key_under_attacker_pressure
- record_value_evicts_oldest_keeping_running_sum_consistent

* fix(hooks): port predicate_state tests to ::new() after nearai#3912 newtype privatization

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
… to nearai#3573) (nearai#3637)

* docs(hooks): scope invocation_arguments_digest snapshot pin (successor #10)

Successor PR from nearai#3573 — addresses serrrfirat's #3 follow-up. Adds an
explicit snapshot test pinning the digest for a known input so a
future change to the hashing path or input-ref format is loud.

* test(hooks): pin invocation_arguments_digest with snapshot

Address serrrfirat's #3 follow-up from PR nearai#3573 — partial fix promoted to
full pin. Two new tests in capability_port.rs::tests:

- invocation_arguments_digest_is_stable_for_known_inputs: pins the
  digest for a fixed (capability_id, input_ref) fixture so any future
  change to the hashing structure or input-ref format is loud. The
  captured hex is documented in the assertion message + stability
  contract.
- invocation_arguments_digest_differs_for_different_input_refs:
  structural sanity check that distinct inputs produce distinct
  digests.

Plus expanded rustdoc on the function calling out the stability
contract: changing the hashing path requires updating the fixture,
surfacing in the cross-crate wire-format contract section, and
bumping the framework's contract version if downstream consumers
exist.

Tests: 156 unit tests pass (was 154; +2 new). Clippy + fmt clean.

* test(hooks): pin arguments_digest at middleware boundary (serrrfirat nearai#3637)

serrrfirat MED on PR nearai#3637: the existing snapshot test pins
`invocation_arguments_digest`'s raw output, but the public hook
contract is `BeforeCapabilityHookContext.arguments_digest` populated
via `HookedLoopCapabilityPort::hook_context`. If caller-side wiring
drifts — wrong field set, transform inserted, stale/default digest,
or an alternate path bypassing the helper — the helper snapshot stays
green while hook consumers observe a broken digest.

Add a boundary-level pin: construct a `HookedLoopCapabilityPort` with
a no-op inner port and an empty dispatcher, run the same fixed
`(capability_id, input_ref)` invocation through `hook_context()`, and
assert the resulting `ctx.arguments_digest` matches the same pinned
hex as the helper snapshot. If they ever disagree, this assertion
fails — surfacing wiring drift that the helper test alone cannot.

`HookedLoopCapabilityPort::hook_context` is widened from private to
`pub(crate)` to make the boundary test possible without bypassing
the function.

* docs(hooks): clarify arguments_digest rustdoc — input-ref identity, not arguments (serrrfirat blocker on PR nearai#3637)

The rustdoc summary on invocation_arguments_digest described the digest
as covering "capability arguments" with equivalence under "identical
arguments". This contradicted the new stability section in the same
file, which (correctly) documents that the digest is over the
(capability_id, input_ref) identity tuple — NOT over the resolved
argument content the input-ref points at.

Reword the summary and the equivalence claim to describe input-ref
identity, matching the stability section and the actual implementation.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…nearai#3640)

* docs(hooks): scope event-triggered hooks (Phase 5, successor #4)

Successor PR from nearai#3573. Adds a new EventTriggered hook point that
subscribes to RuntimeEvents asynchronously, outside the loop's
inline tick. Observer-only by construction (no Allow/Deny/Patch);
typed against a narrowed HookObservableEvent projection to keep
the cross-crate boundary clean.

Scope doc only; design questions about cursor/replay semantics
and per-extension event-rate caps need design review before
implementation.

* Implement Phase 5 event-triggered hooks

Cites crates/ironclaw_hooks/docs/successors/04-event-triggered-hooks.md as the scope contract.

Adds the EventTriggered observer hook point, durable RuntimeEvent dispatch path, and Reborn pull-driven subscription wiring with caller-level coverage for matching, replay, scope filtering, observer-only authority, and backpressure.

* Fix hook event OwnCapabilities owner lookup

* fix(hooks): carry owning extension into hook milestone runtime events

henrypark133 HIGH + codex P1 on PR nearai#3640: `OwnCapabilities`-scoped
event-triggered subscriptions silently never fired for
`HookFailed`/`HookDecisionEmitted`/`HookDispatched` events because
those `RuntimeEvent` constructors hardcoded `provider: None`. Since
Installed hooks default to `OwnCapabilities`, the very events that
Phase 5 was designed to observe (hook-failure / decision alerting)
never reached their default-configured subscriber.

A prior fix added a hook_id-based fallback in
`scope_provider_for_runtime_event` that resolves the owning extension
through the registry's hex index when `event.provider` is `None`. That
covers the case where the failing hook is still registered at replay
time, but the durable fix is to stamp the originating provider into
the event at emit time so the primary `event.provider` path resolves
without any fallback.

Plumbed `owning_extension: Option<ExtensionId>` end-to-end:
- `LoopHostMilestoneKind::{HookDispatched, HookDecisionEmitted,
  HookFailed}` gain the field (with
  `#[serde(default, skip_serializing_if = "Option::is_none")]` so
  pre-existing checkpoint payloads and the L3 schema-snapshot tests
  round-trip unchanged when no owner is set).
- `RuntimeEvent::hook_{dispatched, decision_emitted, failed}`
  constructors accept the owner and stamp it into `provider`.
- `milestone_events.rs` threads the field through the projection.
- `HookDispatcher::emit_dispatched/emit_decision` pass
  `binding.owning_extension.clone()` directly.
- `HookDispatcher::emit_failure` (no binding handy on the failure
  path) looks the owner up via the registry's existing
  `owning_extension_for_hook_hex` index.

Tests:
- `event_triggered_own_capabilities_matches_hook_failed_with_carried_provider`:
  primary-path regression — two `HookFailed` events with
  `provider: Some(ext_a|ext_b)` against an `OwnCapabilities`
  subscription scoped to ext_a; only the own-provider event fires
  and `event.provider == Some(ext_a)`.
- Existing `event_triggered_own_capabilities_scope_resolves_hook_failed_owner_from_hook_id`
  remains green: passes `None` for the new arg so the fallback path
  is still exercised for legacy payloads.

All other call sites updated to pass `None` (no owner available) or
the resolved owner where applicable.

* fix(hooks): validate event subscription scope against run scope (serrrfirat HIGH #1 on PR nearai#3640)

`EventTriggeredHookSubscription` accepted a caller-supplied
`EventStreamKey` + `ReadScope` and used `run_context.scope.tenant_id`
as the hook context's tenant — with no validation that the two
agreed. A caller wiring tenant A's host with tenant B's stream would
cause hooks to observe B's events while the hook context claimed
tenant A. Cross-tenant trust-boundary break.

Add `EventTriggeredHookSubscription::validate_against_run_scope` and
call it from `build_text_only_host_with_capabilities` before
spawning. Validation:
- Stream `(tenant_id, user_id, agent_id)` must equal
  `(run_context.scope.tenant_id, thread_scope.owner_user_id,
  run_context.scope.agent_id)`.
- Thread without `owner_user_id` cannot bind any subscription — the
  user dimension is required to verify stream identity.
- Every `Some(want)` in `ReadScope` must equal the corresponding
  run/thread scope value (project/mission/thread). `None` is
  permissive (run scope owns the dimension authoritatively).

Failures surface as `RebornLoopDriverHostError::ScopeMismatch` with
a specific reason naming the offending dimension.

Tests:
- `event_triggered_subscription_with_foreign_tenant_stream_fails_host_build`
- `event_triggered_subscription_with_foreign_user_stream_fails_host_build`

The integration fixture's `ThreadScope` now sets
`owner_user_id: Some(...)` so it passes validation; previously it was
`None`, which the new check (correctly) refuses. Existing tests
continue to pass.

* fix(hooks): surface event subscription replay-gap as a milestone (serrrfirat MED on PR nearai#3640)

When the durable event log returned `EventError::ReplayGap`, the
event-triggered subscription's background task previously logged a
`tracing::warn!` and broke out of the poll loop — silently killing all
future hook event delivery for the run with no operator-visible signal.
A scoped audit hook that mattered to compliance would just stop, and
nobody downstream would know.

Surface the termination through the host's milestone sink:
- New `LoopDriverNoteKind::EventSubscriptionTerminated` variant.
- The subscription's `spawn`/`run` now takes the host's
  `Arc<dyn LoopHostMilestoneSink>` and the active `LoopRunContext`.
  On `ReplayGap`, it constructs a `DriverNote` milestone with that
  kind plus a `LoopSafeSummary` describing the gap, publishes it
  through the same sink that carries every other host milestone, and
  *then* breaks (fail-closed: the at-most-once contract is already
  broken; resuming from `earliest` would silently lose the gap).
- Log level bumped from `warn` to `error` to match the severity.
- A best-effort send: failures to publish the milestone are logged
  but do not stall the subscription teardown.

Tests:
- `event_triggered_replay_gap_emits_subscription_terminated_milestone`:
  appends 3 events, `truncate_before_or_at` to cursor 2 to force a
  replay gap, starts the subscription from cursor origin (now stale),
  and asserts a `DriverNote { kind: EventSubscriptionTerminated, .. }`
  shows up on the host's milestone sink within a 2s deadline.

Self-emit reentrancy (serrrfirat MED #3 on the same PR) is intentionally
not addressed here — that fix needs a design call (task-local re-entry
flag vs. removing RuntimeEvent emit capability from event-hook execution
contexts) and is a follow-up.

* fix(hooks): suppress event-triggered self-observation (serrrfirat MED #3 on PR nearai#3640)

A hook that subscribes to one of the hook-lifecycle event kinds
(`HookDispatched`/`HookDecisionEmitted`/`HookFailed`) with a scope
that matches its own provider would otherwise be dispatched for
events describing its OWN executions. The dispatcher emits those
events itself when running the hook, so a hook subscribing to
`HookFailed` with `OwnCapabilities` against its own extension would
fail → emit HookFailed → re-dispatch → fail → emit → … storm.

`dispatch_event_triggered_at` now skips events whose `event.hook_id`
equals the binding's own hook id when the event kind is a hook-
lifecycle kind (`is_hook_lifecycle_kind`). The check is intentionally
narrow:

- It only fires for hook-lifecycle events. Subscriptions to other
  event kinds are unaffected.
- It only suppresses literal self-observation; events about other
  hooks (even hooks from the same extension) still dispatch.

This does NOT cover the broader case of a hook that captures an
`Arc<DurableEventLog>` and mints arbitrary `RuntimeEvent`s from
inside its `observe()`. That requires architectural restriction on
what hook impls can capture — tracked separately as a follow-up.

Tests:
- `event_triggered_self_lifecycle_event_does_not_redispatch`: appends
  two `HookFailed` events with the same provider — one targeting the
  subscriber's own hook id, one targeting a different hook. Asserts
  only the OTHER hook's failure fires (proves the filter is narrow,
  not blanket).

* fix(hooks): address henrypark133 should-fix #1, #2, #3, #6 on PR nearai#3640

Four items from the 5-15 review (#4 DoS budget and #5 narrowed
projection deferred — see below):

**#1 (should-fix) Invariant: EventTriggered ↔ event_kind_filter**
`HookRegistry::insert` now enforces the biconditional at install time:
an `EventTriggered` binding must declare an `event_kind_filter`
(otherwise the dispatcher's kind match would silently never fire — a
no-op binding), and conversely only `EventTriggered` bindings may
declare a filter (other points are kind-agnostic and would ignore the
field). Misconfigured bindings fail loud at install.

**#2 (should-fix) Remove `Clone` derive on EventTriggeredHookSubscription**
`Clone` on a spawn-semantics type was a footgun: external callers
cloning + spawning twice would create two consumers reading from the
same `start_cursor`, each dispatching every hook. Replace with an
explicit `clone_for_independent_spawn(&self)` method named verbosely
so the property is visible at the seam. Internal use updated in the
factory's host-build path; external callers can no longer accidentally
construct a dual-consumer pattern.

**#3 (should-fix) catch_unwind around the background `run()` task**
The subscription's tokio task body now runs inside
`AssertUnwindSafe(...).catch_unwind()`; a panic in `run()` emits the
same `EventSubscriptionTerminated` `DriverNote` milestone the
`ReplayGap` path already emits, instead of silently terminating with
no operator-visible signal.

**#6 (should-fix) Replay semantics in rustdoc on public API**
Added a "Replay semantics" section to `EventTriggeredHookSubscription`
rustdoc: at-least-once, caller-owned cursor persistence, the
restart-from-start_cursor replay pattern. Previously only in the
design doc; now load-bearing API contract is visible at the type.

**#4 (deferred) Per-hook DoS budget for Installed tier**
Henry's recommendation was to gate `Installed`-tier event-triggered
hooks entirely until the budget design lands, allowing only
Builtin/Trusted. That breaks 11 existing tests + the primary use
case. Instead: documented the existing first-line throttle
(`batch_limit` × `poll_interval`) as the current bound on indirect-
recursion fanout, and tracked the full per-hook rate cap with
poisoning + milestone-on-overrun as a follow-up. The self-trigger
guard (committed earlier in this PR) catches the most common direct
pattern; the throttle here bounds the indirect pattern until the
proper budget lands.

**#5 (deferred) Narrowed `HookObservableEvent` projection**
Would prevent full `RuntimeEvent` surface from reaching Installed-
tier hooks. Project-wide impact (events crate types, projection
glue). Tracked as a follow-up; the existing sanitized-event
projection bounds the surface to closed-vocab labels.

All 156 hooks lib + 30 reborn integration tests pass.

* chore(hooks): address nits from PR nearai#3640 review

Bundle three nit-tier review items into a single commit:

**#9 Replace author-internal tags with NOTE(nearai#3640)**
The Phase-5 PR (nearai#3640) had several `serrrfirat HIGH/MED #N on PR nearai#3640`
comment tags in this PR's diff. These are review-internal scaffolding,
not load-bearing for future readers. Replaced with `NOTE(nearai#3640)` in:

- crates/ironclaw_hooks/src/dispatch.rs (self-observation guard)
- crates/ironclaw_reborn/src/loop_driver_host.rs (scope validation,
  replay-gap milestone, subscription binding)
- crates/ironclaw_reborn/tests/hooks_integration.rs (three regression
  tests covering scope validation, self-observation suppression, and
  replay-gap surfacing)
- crates/ironclaw_turns/src/run_profile/host.rs
  (`EventSubscriptionTerminated` doc)

**#10 Replace 10ms spin-poll with tokio::sync::Notify**
`wait_for_seen_events` polled the shared `Mutex<Vec<SeenRuntimeEvent>>`
every 10 ms until the expected count was reached. Replaced with a
`SeenLog` newtype that pairs the events vec with a `Notify`; the
hook's `observe()` calls `seen.push(...)` which signals
`Notify::notify_one`, and `wait_for_seen_events` parks on
`notified().await` under a `tokio::time::timeout`. `notify_one` is a
permit-store, so an event landing between snapshot and wait still
wakes the waiter immediately. Test latency drops from ~10 ms median to
sub-ms and is no longer rate-limited by the polling cadence. All 30
hooks_integration tests still pass.

**#11 Remove unused Clone derive on EventTriggeredHookContext**
No call site clones the context — it's passed by reference. Dropped
the derive to make the borrow contract clearer.

* docs(hooks): reconcile event-triggered hooks design doc with Phase 5 reality

Address gemini-code-assist review on `04-event-triggered-hooks.md`:

- L50 (Likely surface): annotated the sketch's full `RuntimeEvent` use
  with a pointer to the narrowed-projection follow-up so the snippet
  no longer reads as a recommendation contradicting L119–121.
- L55 (sink methods): replaced `note_fact` / `emit_audit` (which never
  shipped on `ObserverSink`) with the actual `note(category, summary)`
  primitive and cross-referenced Reborn's
  `EventTriggeredObserverSink`.
- L95 (cursor / replay): "lost events during downtime acceptable"
  contradicted the at-least-once replay semantics described in the
  Phase 5 implementation notes. Rewrote the bullet to say replay is
  at-least-once from the persisted cursor and to spell out the
  operator obligation around cursor persistence before shutdown.
- L100/115 (forbids events dep): the original doc claimed
  `ironclaw_hooks` forbids an `ironclaw_events` dep, but the Risk
  section noted the dep is already established via PR nearai#3573. Updated
  both passages to reflect that the dep direction is set; Phase 5
  adds the *consumer* side. The narrowed `HookObservableEvent`
  projection is now framed as a follow-up tracked in nearai#3690.

* refactor(hooks): unify event-triggered sink with ObserverSink

Address PR nearai#3640 review findings A3, C4, F14, and cluster G:

- F14: drop duplicate `EventTriggeredObserverSink` trait and reuse
  `ObserverSink` directly in the `EventTriggeredHook` trait. The two
  surfaces were signature-identical; keeping them separate let them
  drift, and a future gate/mutator method added to one would not
  surface as a compile error on the other.
- A3: add `is_replay: bool` to `EventTriggeredHookContext` and a
  dedicated `dispatch_event_triggered_replay_at` entry point. The
  subscription contract is at-least-once, so side-effecting hooks need
  to dedupe by `event.event_id` on restart-driven replay.
- C4: index event-triggered bindings by `RuntimeEventKind` at install
  time so dispatch is O(matches) instead of scanning every
  event-triggered binding for every event.
- Cluster G: doc/04-event-triggered-hooks.md updated to reflect the
  unified sink, the explicit at-least-once semantics + `is_replay`
  signal, the actual `note(category, summary)` primitive (not the
  speculative `note_fact` / `emit_audit`), the corrected `ironclaw_events`
  dep status, and the issue nearai#3690 reference for the narrowed
  `HookObservableEvent` projection.

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

* perf(hooks): adaptive backoff for event-triggered subscription

Address PR nearai#3640 review findings C5, A1, A2:

- C5: empty-poll backoff for `EventTriggeredHookSubscription`. The
  previous loop hammered the durable log at a fixed 50ms cadence under
  sustained idle, even when no events had arrived for minutes. The
  subscription now tracks consecutive empty polls and sleeps for
  `min(poll_interval << streak, max_poll_interval)` before the next
  poll, defaulting to a 1s cap; a non-empty batch resets the streak
  so producer bursts restore low-latency dispatch immediately. Exposed
  via `with_max_poll_interval` so callers can tune.

- A1 / A2: explicit issue references for the deferred narrowed
  `HookObservableEvent` projection (nearai#3690) and the per-hook DoS
  dispatch budget (nearai#3689). The current self-trigger guard catches
  direct-recursion storms; the backoff bounds indirect ones until the
  proper budget design lands.

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

* test(hooks): cover event-triggered dispatch edge cases

Address PR nearai#3640 review findings D8, D9, D10, D11, D12, and B:

- D8: dispatching an event-triggered binding that has no installed hook
  impl must poison the slot and surface a Malformed failure rather than
  silently no-op. A follow-up dispatch on the same kind must skip the
  poisoned slot.
- D9: registry validation rejects non-event-point bindings that carry
  an `event_kind_filter`, mirroring the existing reverse-direction
  check.
- D10: the existing hook-meta serde round-trip tests always passed
  `None` for `owning_extension` and never asserted `event.provider`.
  Add `hook_meta_events_round_trip_owning_extension_as_provider` to
  pin the projection that scope filtering depends on.
- D11: `scope_provider_for_runtime_event` falls back to `None` when
  the registry mutex is poisoned. Force a poison on a spawned thread
  and assert the resolver remains fail-closed.
- D12: `run_event_triggered_hook` catches panics from the hook impl
  via `AssertUnwindSafe::catch_unwind`. Drive it with a deliberately
  panicking impl and assert `FailureCategory::Panic`.
- Cluster B: when a hook-meta event has `provider: None`, the
  dispatcher recovers the owning extension from the registry's
  hex-keyed index so `OwnCapabilities` watchers still fire. Add a
  full end-to-end test exercising that path through
  `dispatch_event_triggered_at`.

Also pin C4 indexing: a registry-level test that
`active_for_event_kind` returns only bindings whose declared filter
matches.

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

* fix(hooks): port event-triggered tests after foundation rebases (nearai#3911/nearai#3912/nearai#3913)

- Add event_kind_filter: None to HookBinding test constructions (foundation added new field)
- Replace tuple-struct construction of ExtensionId/HookLocalId with ::new() per nearai#3912 newtype privatization
- Lowercase RuntimeEventKind debug repr for HookLocalId validation (lowercase-only identifiers)
- Replace pub-use re-exports with module-path imports per foundation cleanup
- Rename fixture.user_id to fixture.actor_id per nearai#3633 final naming

* fix(hooks): adapt event-triggered to WASM hook runtime (nearai#3920)

- Add event_kind_filter: None to WASM HookBinding constructions in dispatch.rs
- Extend HookManifestKind match arms in registrar.rs and wasm/runtime.rs to handle EventTriggered (rejected: WASM-bodied event-triggered hooks are not yet supported)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
* Add saved output refs for Reborn shell

* Tighten Reborn shell output capture

* Harden Reborn shell output capture lifecycle

* Sanitize Reborn shell previews before saving

* fix(reborn): tenant-scope shell saved-output dir + GC (nearai#4154 review blockers #1, #4)

Saved shell-command output files previously landed directly in shared
std::env::temp_dir() (per-file 0o600, but the parent dir was world-listable),
and cleanup_stale_command_outputs() walked all of /tmp and unlinked any entry
that matched the well-known prefixes — both ambient surfaces let one principal
on the same host enumerate or delete another principal's saved output.

Route every saved output through a per-scope subdirectory derived from
RebornSandboxScopeKey (the same SHA-256-of-tenant/user/agent/project digest
the Reborn sandbox transport uses for workspace_path) under
<tempdir>/ironclaw-command-outputs/<scope_digest>/, created with owner-only
0o700. Both scratch streams and final sanitized outputs live inside that
directory, and the 24h GC scan is scoped to it — so two distinct
(tenant, user, agent, project) tuples produce disjoint, non-enumerable
directories and the cross-principal-delete surface closes by construction.

Closes blockers #1 and #4 from the PR-nearai#4154 review. Blocker #2 (typed
saved_output_read capability) and finding #3 (24h GC vs never-delete
retention) remain serrrfirat-owned design decisions and are intentionally
out of scope here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix: address saved shell output review findings

* fix: publish shell saved output through file_read

---------

Co-authored-by: Zaki <zaki@manian.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
* fix(reborn): remove preview line cap

* feat(reborn): add typed diff display previews

* fix(reborn): address typed diff preview review

* refactor(reborn): centralize display preview validation

* fix(reborn): address henrypark133 review — perf guard, path subtitles, typed kind, tests (nearai#4184)

* refactor(reborn): extract truncate_to_byte_boundary, has_unsafe_path_chars, will_use_large_diff_path

- Move truncation utility to ironclaw_host_api (canonical home next to
  the max-bytes constant); both diff_preview and the projection layer
  now delegate to it — deletes duplicate BoundedText/truncate_utf8.

- Extract has_unsafe_path_chars predicate in display_preview.rs so
  safe_display_path and safe_preview_subtitle share the rejection logic
  instead of copy-pasting the same five-condition check.

- Replace pub(super) DIFF_PREVIEW_DETAILED_INPUT_MAX_BYTES coupling with
  will_use_large_diff_path predicate so file.rs no longer reaches into
  diff_preview internals to compute the threshold.

Addresses thermo-nuclear review findings #1, #2, #3.

* refactor(reborn): simplify diff preview contract

* fix: address diff preview review cleanup

* fix: resolve CI clippy failures
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…#3899)

* Reborn budgets: address all nearai#3841 follow-ups end-to-end

Implements every open follow-up from PR nearai#3841 (cost-based budgets
foundation), driven by the plan in
`docs/plans/2026-05-22-reborn-budgets-followups.md`:

- **C2 (provider tokens)**: `LoopModelResponse.usage` carries real
  `(input_tokens, output_tokens)` from `CompletionResponse` /
  `ToolCompletionResponse`; `usage_for_response` reconciles to actual
  USD via the cost table instead of the conservative estimate.
- **D1 (cascade warnings)**: `CascadeOutcome` variants carry
  `Vec<BudgetWarning>` so warnings preceding a pause or hard deny
  reach the audit sink. `ResourceError::LimitExceeded` /
  `RequiresApproval` reshaped to struct variants.
- **C1 (cancellation safety)**: new
  `LoopModelBudgetAccountant::release_in_flight` trait hook + RAII
  `ReservationReleaseGuard` in `HostManagedLoopModelPort::stream_model`
  so a cancelled future doesn't orphan its reservation.
- **E1 (dead code)**: removed the never-set `budget_accountant` field
  on `ThreadBackedLoopModelPort`.
- **Real cost table**: new `StaticModelCostTable` +
  `LlmModelProfilePolicy::build_cost_table()` populated from
  `ironclaw_llm::costs::model_cost` with `default_cost` fallback so
  unknown providers never silently reconcile to zero.
- **B1 (filesystem gate store)**: new `FilesystemBudgetGateStore`
  mirroring `FilesystemResourceGovernorStore`; pending gates survive
  process restart.
- **A1 (production wiring)**: composition builds
  `GovernorBackedAccountant` from the cost table + governor and
  threads it through `RebornLoopDriverHostFactory::with_model_budget_accountant`.
- **A2 (audit / SSE projection)**:
  `InMemoryResourceGovernor::with_event_sink` emits `Reserved`,
  `Reconciled`, `Released`, `Warned`, `ApprovalRequested`, `Denied`,
  `LimitChanged`; composition holds an `InMemoryBudgetEventSink` ready
  for downstream SSE projection.
- **F1 (stuck-loop normalization)**: `CapabilityCallSignature::from_call`
  now runs `progress::normalize_for_hash` so the existing repetition
  window collapses request-id / UUID / timestamp noise.

Side fix: `ResourceValue` moved to adjacent serde tagging (the
combination of internal tagging + `Decimal`'s `serde-with-str`
representation breaks JSON serialization — rust-lang/serde#1402).

Regression tests added per item — see the acceptance evidence appendix
in the plan doc.

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

* Reborn budgets: end-to-end test coverage via test-support feature

Adds 13 e2e tests covering the budget pipeline through
`build_reborn_runtime` + `send_user_message`. Required infrastructure:

- **`test-support` feature** on `ironclaw_reborn_composition` exposing
  `BudgetTestGateway` (scripted token usage) and
  `RebornRuntimeInputTestExt`. Existing `model_gateway_override` field
  promoted from `#[cfg(test)]` to `#[cfg(any(test, feature = ...))]`
  with a new public `with_model_gateway_override_for_tests` setter.
- **Cost-table override** on `RebornRuntimeInput` so tests can pair
  the gateway with a deterministic `ModelCostTable`. Without this, an
  override gateway dropped the cost table and the accountant never
  fired.
- **Budget accessors** on `RebornRuntime`: `budget_resource_governor`,
  `budget_event_sink`, `budget_gate_store`, and
  `apply_resolved_budget_gate`. Test-feature gated.
- **`ResourceGovernor::usage_for`** added as a default-impl trait
  method so tests read spend through the trait surface.
- **`BudgetGateStore` wired into the accountant**:
  `GovernorBackedAccountant::with_gate_store(...)` opens a pending
  gate whenever the governor cascade returns `RequiresApproval`. The
  approval-required host error is unchanged; the gate is the
  out-of-band channel a user-facing handler resolves.

Scenarios covered:

| # | Test | What it asserts |
|---|---|---|
| F1 | `f1_happy_path_records_actual_usd_in_ledger` | Ledger depletes by provider tokens × cost table |
| F2 | `f2_crossing_warn_threshold_emits_warned_event` | Warn fires alongside successful Reserved/Reconciled |
| F3 | `f3_approval_with_increased_limit_unblocks_retry` | Approve → set_limit applies → retry succeeds |
| F4 | `f4_cancel_keeps_budget_blocked_on_retry` | Cancel → retry still short-circuits |
| F5 | `f5_expiry_marks_gate_terminal_and_keeps_budget_blocked` | Expiry → gate drops from pending list, retry still blocked |
| F6 | `f6_hard_cap_denied_before_provider_call` | Estimate over cap → zero model calls, Denied event |
| C1 | `c1_provider_tokens_reconcile_to_actual_usd` | Real numbers, not estimate |
| C2 | `c2_unknown_model_in_cost_table_reconciles_to_zero` | Unknown profile → zero spend |
| C3 | `c3_zero_cost_model_records_zero_spend` | Free model → zero USD with non-zero tokens |
| D1 | `d1_agent_deny_preserves_user_warn_event` | Cascade emits both Warned and Denied |
| D3 | `d3_fresh_user_without_limits_runs_without_denial` | No limit → no denial |
| + | `pause_in_distinct_runs_produces_distinct_pending_gates` | Per-run gate identity |
| + | `budget_test_gateway_scripted_replies_drive_per_turn_costs` | Multi-turn scripted accumulation |

F7 (cancellation mid-stream) is unit-covered by
`release_in_flight_drains_orphan_reservation_on_cancellation`.
D2 (period rollover) is unit-covered by
`rolling_24h_snapshot_reports_anchored_window_not_now_window`.
B-series (background ticks) await the BackgroundKind scheduler
call site (no production caller in Reborn yet).

Run via `cargo test -p ironclaw_reborn_composition --features test-support`.

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

* Budget review feedback: address all 7 findings from PR nearai#3899 review

Two High and five Medium issues raised by serrrfirat's multi-agent review.

**High #1 — `FilesystemBudgetGateStore` cross-tenant leakage**
The store hardcoded `ResourceScope::system()` for every op, so all
tenants wrote into the same `/tenants/__SYSTEM__/...` snapshot and
`list_pending` would expose gates across tenants. Fix: `new(...)` now
takes a `ResourceScope`; each tenant gets its own store, and the
`ScopedFilesystem` mount view routes the snapshot under that tenant's
path. Added `list_pending_does_not_leak_across_tenants` regression.

**High #2 — accountant wired without default budget limits**
Composition built `GovernorBackedAccountant` without
`with_seeding_policy`, so the local-dev governor started empty and
`reserve_with_outcome_in_state` skipped accounts that had no
configured limit — model calls reconciled spend but never enforced a
cap. Fix: `build_reborn_runtime` now loads
`BudgetDefaults::compiled_defaults().with_env()` and wires
`BudgetSeedingPolicy` + `with_overestimate_factor`. Renamed the D3
test to `d3_seeding_policy_installs_default_cap_on_first_touch` to
prove the wiring fires.

**Medium #3 — RAII guard disarmed before post_model_call await**
`HostManagedLoopModelPort::stream_model` was disarming the
`ReservationReleaseGuard` before awaiting `post_model_call`. A
cancellation during that await dropped the future without cleanup,
orphaning the reservation. Fix: disarm AFTER `post_model_call`
returns. `release_in_flight` is now idempotent (peek-then-release-
then-remove) so a successful post-call + subsequent guard drop is a
no-op.

**Medium #4 — failed release drops the retry handle**
`release_in_flight` removed the in-flight entry before calling
`governor.release`. A transient storage error left the reservation
active in the governor with the id discarded. Fix: peek first,
release, only remove on success. Errors keep the entry retained for
a future retry / cleanup hook.

**Medium #5 — unknown model silently reconciles to zero USD**
Both `estimate_for` and `usage_for_response` fell back to
`ModelCost { 0, 0, 0 }` when the cost table had no entry for the
effective model. Cost-table drift would silently bypass daily caps.
Fix: `GovernorBackedAccountant` carries a `default_cost` (default ~
GPT-4o pricing, ~`$0.0000025 input + $0.00001 output per token`) used
for unknown models. Callers wiring `ZeroCostTable` for free / Ollama
explicitly opt out of the fallback. Updated the C2 e2e test to
assert the new fail-closed shape.

**Medium #6 — paused dimension lost when another hard-denies**
`check_thresholds_all_interventions` stored `Approval` only in the
`approval` slot, so when one dimension paused and another hard-denied,
the `Deny { warnings, denial }` outcome lost the pause signal.
Fix: also push a warning-shaped record for the paused dimension.

**Medium #7 — unbounded terminal-gate retention**
The snapshot kept every gate forever; `open` / `resolve` / `get` /
`list_pending` were O(total historical gates). Fix:
`with_terminal_retention` (default 30 days). Every mutation prunes
terminal gates whose resolution timestamp is older than the window.
Added `terminal_gates_older_than_retention_are_pruned_on_next_write`
regression.

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

* ci: replace lock-poisoned expects with PoisonError::into_inner

scripts/check_no_panics.py flagged five .expect("...lock poisoned")
calls in the new test_support.rs. Use the same idiomatic recovery
pattern the rest of the codebase uses (see InMemoryBudgetGateStore,
InMemoryBudgetEventSink): on a poisoned lock, recover the inner data
via PoisonError::into_inner rather than panicking. The test gateway's
state is append-only logs / replies queues, so reading them through a
poisoned lock is safe.

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

* Finish A1 / A2 / F1 from plan + honest plan doc update

The plan claimed "all nine items landed" but A1 (production wiring),
A2 (SSE projection), and F1 (full progress strategy) were partials.
This commit finishes the work so the plan matches reality.

**A1 — production-shape accountant builder**

New `ironclaw_reborn_composition::build_default_budget_accountant`
public helper that wires the seeding policy + overestimate factor +
gate store from `BudgetDefaults::compiled_defaults().with_env()` and
returns an `Arc<dyn LoopModelBudgetAccountant>`. Production loop
composers call this with their `PersistentResourceGovernor` +
`FilesystemBudgetGateStore` + LLM-policy-derived cost table; the
local-dev runtime in `build_reborn_runtime` now uses the same helper
instead of duplicating the seeding logic inline. Unit-tier regression
`seeds_compiled_default_user_cap_on_first_touch` proves the helper
installs the compiled-default $5 user cap on first model call.

**A2 — broadcast sink + AppEvent projection**

- `ironclaw_resources::BroadcastBudgetEventSink` wraps
  `tokio::sync::broadcast::Sender<BudgetEvent>` with `subscribe()` /
  `subscriber_count()`. `CompositeBudgetEventSink` fans events to
  multiple sinks.
- Composition fans every `BudgetEvent` to the in-memory sink (for
  tests) AND the broadcast sink (for SSE projection) via
  `CompositeBudgetEventSink`.
- New `AppEvent::BudgetWarn` / `BudgetPause` / `BudgetDenied` /
  `BudgetLimitChanged` wire-stable variants in
  `ironclaw_common::event`.
- `src/bridge/budget_events.rs` carries the projection: a tokio task
  spawned by `spawn_budget_event_projection` drains the broadcast
  receiver and emits the appropriate `AppEvent` via
  `SseManager::broadcast_for_user`. System-scoped events (no user
  identity) are skipped. This is the only producer of these
  `AppEvent` variants per `.claude/rules/gateway-events.md`.
- `RebornRuntime::broadcast_budget_event_sink()` exposes the sink to
  the binary so the startup path subscribes. E2E test
  `broadcast_sink_publishes_events_to_subscribers` drives a real
  `send_user_message` and asserts Reserved + Reconciled lands on the
  broadcast.

**F1 — diminishing-returns stop condition**

The earlier shipped `ParamHash` normalization in
`CapabilityCallSignature` strengthened the existing
`recent_call_signatures`-based repetition detector. This commit adds
the second half of F1: a rolling output-token window that detects
"wedged" loops the repetition detector misses (model keeps
responding but produces no useful output).

- `LoopExecutionState.recent_output_token_counts: BoundedRing<u32, 8>`
  populated by the executor from `LoopModelResponse::usage`.
- `BoundedRing::iter` returns `impl DoubleEndedIterator` so the
  strategy can scan the trailing window.
- `DefaultStopConditionStrategy` gets `min_delta_tokens` (default
  4) + `noprogress_window` (default 4). When the last N turns all
  produce ≤ min_delta_tokens of output, fire
  `StopKind::NoProgressDetected`.
- Regression tests:
  `four_consecutive_low_token_turns_trigger_no_progress` proves the
  detector fires; `occasional_low_token_turn_does_not_trip_no_progress`
  proves a productive turn resets the trailing count.

**Plan doc**

Updated the status header from "all nine items landed" to the
honest per-item shape. Acceptance evidence table expanded with the
new test names. New "Review-feedback fixes layered on top" subsection
documenting all 2 High + 5 Medium findings addressed during review.

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

* Address thermo-nuclear review: collapse filesystem-store duplication, flatten cfg permutations, split budget accountant

Five structural simplifications surfaced by the deep audit on PR nearai#3899, plus
two bug fixes from the earlier review pass:

- ironclaw_resources: extract `cas_snapshot` shared infrastructure
  (`StorageError` + `Snapshot` traits + `CasSnapshotStore<F>` + async-runtime
  worker + per-path lock map) and merge `filesystem_gate_store.rs` into
  `filesystem_store.rs`. Deletes ~350 lines of duplicated read-modify-write +
  worker-thread + CAS machinery; both stores are now thin shims over the
  shared helper.

- ironclaw_reborn_composition: flatten the 4-way cfg permutation in
  `build_reborn_runtime` model-gateway resolution into three flat steps
  (normalize override → build production gateway via cfg-gated helper →
  test override wins). Also drops the `unused_mut` warning.

- ironclaw_reborn_composition: collapse the 3-layer test-only setter dance
  for `model_gateway_override` / `model_cost_table_override` into a single
  setter pair gated on `cfg(any(test, feature = "test-support"))`. Deletes
  the `RebornRuntimeInputTestExt` extension trait — integration tests now
  call the inherent methods directly.

- ironclaw_loop_support: split the 1305-line `budget_accountant.rs` into
  `budget_cost_table.rs` (ModelCost/ModelCostTable trait/ZeroCostTable/
  StaticModelCostTable), `budget_seeding.rs` (BudgetSeedingPolicy), and
  `budget_accountant.rs` (just GovernorBackedAccountant). Each module now
  owns one concern.

- ironclaw_resources: add `impl Display for ResourceAccount` and route the
  hierarchical account-label rendering through it; delete the 60-line
  bespoke `account_label` helper from `src/bridge/budget_events.rs`.

- ironclaw_common + bridge: collapse the four `AppEvent::Budget*` variants
  into a single typed `AppEvent::Budget(AppBudgetEvent)` with the four
  shapes carried inside the enum. Wire-shape stays identical (snake_case
  serde tag).

- ironclaw_resources + ironclaw_loop_support: thread real gate id through
  `BudgetEvent::GateOpened { gate_id, needed, at }` (new variant) and have
  the accountant emit it via the broadcast event sink after store.open
  succeeds. The bridge now projects `BudgetEvent::GateOpened` (not
  `ApprovalRequested`) into `AppEvent::Budget(Pause { gate_id, ... })` so
  SSE consumers receive the persisted gate id rather than a fabricated
  zero uuid.

- ironclaw_agent_loop: in the F1 token-counting path, push to
  `recent_output_token_counts` only when the model response carries
  `Some(usage)` and only on the `AssistantReply` arm (instead of
  `unwrap_or(0)`). Diminishing-returns detection now reflects real spend.

Net delta: -461 lines (+999 / -1460). Workspace `cargo clippy` clean,
`cargo test` clean on ironclaw_resources / ironclaw_loop_support /
ironclaw_reborn_composition; budget_e2e + budget_approval_e2e both green.

Pre-existing CI failures (`cli::tests::test_version` stack overflow,
`facade_factory::production_*` RuntimeProcessPort missing) are unrelated
and reproduce on the pristine branch tip.

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

* test(cli): refresh insta snapshots after runtime-policy flag additions

The `import`-feature variants of the help snapshots were left stale when
`--deployment-mode`, `--runtime-profile`, `--yolo-disclosure` were added in
cc04481 (nearai#3243); the `_without_import` variants were updated but these
were not. CI was failing the snapshot assertion under the slim PR matrix
(`--features postgres,libsql,html-to-markdown,bedrock,import`).

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

* Address PR nearai#3899 thermo-nuclear review (TN #1, #2, #3)

TN #1 — budget defaults resolved in wrong layer:
  - `build_default_budget_accountant` no longer reads process env; it
    now takes `&BudgetDefaults` as a parameter and the caller owns the
    config-layer precedence (compiled → section → env) plus the
    `validate()` call.
  - `RebornRuntimeInput` gains an optional `budget_defaults` field +
    `with_budget_defaults()` builder so the composition root passes a
    pre-resolved value. `build_reborn_runtime` falls back to
    `compiled_defaults().with_env() + validate()` when none is supplied
    so existing call sites keep working.

TN #2 — gate-store scoping at wrong boundary:
  - `BudgetGateStore` trait methods (`open`, `resolve`,
    `expire_pending_older_than`, `get`, `list_pending`) now take
    `&ResourceScope` as first arg. `GovernorBackedAccountant` passes
    the caller's scope from `resource_scope(context)`.
  - `CasSnapshotStore` gains `update_with_scope` so the same store
    instance can route per-operation. `FilesystemBudgetGateStore` no
    longer takes scope at construction — one shared instance serves
    every tenant via the `ScopedFilesystem` mount view.
  - `InMemoryBudgetGateStore` ignores scope (suitable for single-tenant
    tests / local-dev); production multi-tenant filesystem path is
    correctly partitioned by `ResourceScope`.
  - `RebornRuntime::apply_resolved_budget_gate` now takes scope too.

TN #3 — half-wired projection bridge:
  - Removed `src/bridge/budget_events.rs`, its `spawn_budget_event_projection`
    helper, the `AppEvent::Budget` variant, and the `AppBudgetEvent`
    type. No production caller ever subscribed the broadcast sink
    onto SSE and no frontend consumed the variant, so the
    half-wired bridge is gone pending a real owner that spawns a
    projection task with shutdown cancellation.
  - The runtime's `broadcast_budget_event_sink()` accessor stays so
    a future production composer can still subscribe without
    rebuilding the runtime.

Bonus — to keep budget e2e tests working under the new libsql local-
dev path that origin/reborn-integration introduced, added
`PersistentResourceGovernor::with_event_sink` (parity with the
`InMemoryResourceGovernor` accessor). The libsql variant of
`build_local_dev_store_graph` now wires the composite sink to the
persistent governor so governor-emitted `Warned`/`Denied`/`Reserved`/
`Reconciled` events reach subscribers on both feature paths.

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

* Wire budget-event projection task into RebornRuntime

Re-implements PR nearai#3899 follow-up A2 / Thermo-Nuclear #3 with a real
production owner instead of leaving the broadcast sink half-wired:

- `crates/ironclaw_reborn_composition/src/budget_events.rs` (new):
  `BudgetEventObserver` trait + `TracingBudgetEventObserver` default
  observer + crate-internal `BudgetEventProjection` task that drains
  the runtime's broadcast `Receiver<BudgetEvent>` and forwards every
  event to the observer. Cancellation via `CancellationToken`; lagged
  subscribers logged and resumed; receiver-closed exits cleanly.

- `RebornRuntimeInput::with_budget_event_observer(...)` lets
  production owners install a custom observer (SSE projection, WS
  fan-out, telemetry export). When unset, the runtime installs the
  tracing observer so events always surface in structured logs.

- `build_reborn_runtime` always spawns the projection task at runtime
  construction; `RebornRuntime::shutdown` cancels it and awaits the
  handle so background state drains before the runtime drops.

- E2E test `projection_delivers_budget_events_to_installed_observer`
  drives `build_reborn_runtime` with a capturing observer and asserts
  the observer sees `Reserved` + `Reconciled` from a real model call,
  testing through the caller per `.claude/rules/testing.md`.

- Existing `broadcast_sink_publishes_events_to_subscribers` updated
  to expect the runtime's own projection task as a baseline
  subscriber.

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

* fix(reborn): rustfmt the merged loop_support import block

The conflict resolution for the post-merge import list was not run
through rustfmt; CI Formatting flagged the wrapping. No logic change.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…ED flag (nearai#3934) (nearai#3938)

* feat(hooks): extension-declared hook section on ExtensionManifestV2 (nearai#3934)

Add a `[[hooks]]` declaration surface to the production v2 extension
manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected
`ExtensionManifest`). Each entry is carried as a structurally-typed
`HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized
to canonical TOML — so `ironclaw_extensions` (substrate) never imports the
`ironclaw_hooks` predicate vocabulary. The composition layer, which depends
on both crates, is the single seam that projects these payloads into typed
`ironclaw_hooks::HookManifestEntry` values (a later commit).

Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the
downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB
per entry). Entries must be tables carrying a non-empty `id`; ids must be
unique within the manifest. `#[serde(default)]` keeps every existing
manifest valid (empty `hooks` vec).

The DTO holds canonical TOML as a `String` rather than a `toml::Value` so
the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is
not `Eq`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): composition-layer activation module (loader, first-party hook, flag) (nearai#3934)

Add `ironclaw_reborn_composition::hooks` — the single seam that activates the
hook framework in production. Implements four numbered pieces of nearai#3934:

- Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from
  `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else =
  OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and
  the runtime composes no dispatcher — exact pre-hooks behavior. Hard
  rollout-safety contract.
- Manifest → registry loader (item 2): `install_extension_hooks` projects each
  `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed
  `HookManifestEntry` and installs it via `HookRegistrar::install` at the
  `Installed` trust tier. This is the clean-boundary projection: the hook
  vocabulary lives only here, never in `ironclaw_extensions`. Trust
  attenuation is enforced by construction (registrar only calls
  `install_installed_*`). Fail-closed: any projection/install error fails the
  build loudly.
- First-party builtin hooks (item 3): a single illustrative no-op observer
  (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero
  driver-visible effect even with the flag ON). Production catalog is TBD by
  design — this PR does not invent a first-party hook.
- Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator`
  over the in-memory state backend (swappable via the new public
  `PredicateEvaluator::with_state_backend` for durable nearai#3933), validates the
  full install set once fail-closed, and returns a per-run builder-factory
  closure. Per-run construction (fresh registry/dispatcher per host build) +
  per-tenant evaluator give full isolation; the host factory attaches the
  run-scoped milestone sink internally.

Per-tenant scoping is by construction: `build_reborn_runtime` runs once per
identity, so everything here is tenant-local — no global registry.

The router-backed gate-ref factory (PauseApproval/PauseAuth) and the
security-audit sink (nearai#3922, not yet on this branch) are deferred follow-ups;
their absence is fail-closed (PauseApproval surfaces as Denied) and noted for
the PR body. Not yet wired into the runtime — next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (nearai#3934)

Item 6 of nearai#3934. Add an optional `hook_dispatcher_builder_factory` to
`DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call
`.with_hook_dispatcher_builder_factory(...)` on the production
`RebornLoopDriverHostFactory` when it is present. `None` (the default) means
no dispatcher is composed — behavior identical to the pre-hooks runtime
(rollout-safety contract).

The composition layer (`build_reborn_runtime`) resolves the flag via
`HooksActivationConfig::from_env()` and builds the factory against this
tenant's extension registry (per-tenant by construction — the function runs
once per identity). Fail-closed: a malformed manifest hook fails the build
here rather than composing a broken dispatcher.

A per-run builder factory (not a captured dispatcher instance) is used so the
host attaches a run-scoped milestone sink internally per build — per-run
telemetry attribution, the nearai#3573 capture-and-stick lesson.

All `DefaultPlannedRuntimeParts` construction sites (8 test sites across
ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition)
updated with the new field defaulting to `None`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): e2e activation tests through build_default_planned_runtime (nearai#3934)

Item 8 of nearai#3934. Add four end-to-end tests in
crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production*
composition function `build_default_planned_runtime` with a per-run hook
dispatcher builder factory shaped exactly like the composition layer's output
(first-party builtin no-op observer + extension-declared `Installed`-tier
hooks projected from a manifest entry through `HookRegistrar::install`), then
build a host via the composed `host_factory` and invoke a capability:

- flag OFF (no factory): allowed capability completes unaffected and reaches
  the inner host runtime port — the pre-hooks behavior / rollout-safety
  contract.
- flag ON, first-party-only no-op observer: outcome unchanged, inner port
  reached — the builtin ships dark.
- flag ON, extension-declared deny hook: capability denied through the
  composed runtime and the inner port is never reached (installed at the
  Installed tier via the registrar; OwnCapabilities scope keyed to the
  capability provider).
- per-tenant isolation: tenant A's deny hook fires; tenant B (separate
  build_default_planned_runtime composition, no hooks) completes the same
  capability — proving no cross-tenant leakage.

Security-audit-on-deny assertion is intentionally deferred: nearai#3922's
SecurityAuditSink is not yet on reborn-integration. It lands with the
audit-sink wiring follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (nearai#3934)

The root-crate `tests/support/reborn/harness.rs` constructs
`DefaultPlannedRuntimeParts` directly; add the new
`hook_dispatcher_builder_factory: None` field so the parity-test harness
compiles. Default `None` keeps the harness on the no-hooks path (unchanged
behavior).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks): proper error handling / safety annotations for activation production paths (nearai#3938 CI)

The per-run dispatcher factory closure used `.expect()` on the
first-party and extension hook installs, tripping the no-panics CI gate.
These installs are pure replays of the install set already validated
fail-closed (`?`) against a scratch builder at composition time, so they
are genuine invariants. The factory type returns a non-Result
`HookDispatcherBuilder` and is invoked deep in the run loop, so the
documented `// safety:` suppression is the correct fix here.

Hoisted the expect messages into `let` bindings so the `.expect(msg)`
call fits on one line, keeping the scanner-required `// safety:` comment
on the same line as the call after rustfmt. The malformed-manifest path
(TOML projection) already uses real error propagation via map_err/`?`
and is unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): direct composition-loader coverage + activation-scope docs

Address Codex non-blocking follow-ups on nearai#3938.

Add three direct tests for the composition-layer hook loader
(`install_extension_hooks` via `build_hook_dispatcher_builder_factory`),
driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather
than mimicking the loader:

- valid `own_capabilities` predicate hook installs at the Installed
  trust tier; the dispatcher carries the derived binding at
  BeforeCapability alongside the first-party no-op observer
- malformed typed hook body (unknown `mode`) fails CLOSED with
  `RebornBuildError::InvalidConfig`, never a panic (the load-bearing
  degradation contract for untrusted external manifests)
- a hook claiming `scope = same_tenant` without a verified grant is
  rejected by trust attenuation (fail-closed)

No loader bug surfaced: `HookRegistrar::install` already returns
`Result` on every malformed/over-scoped path and the loader maps it to
`InvalidConfig` via `?`.

Document activation scope at both the loader rustdoc and the
`build_reborn_runtime` call site: production currently passes only
`builtin_extension_registry()`, so third-party installed-extension hooks
are not yet surfaced into the runtime path — only first-party-builtin
and builtin-package-declared hooks activate today.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks): thread HooksActivationConfig through input; empty production catalog

Two maintainability cleanups on nearai#3938 (firat review):

Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep
inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field
to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The
composition root now consumes the typed config; the env var is resolved ONCE
at the edge (the reborn CLI's build_runtime_input) via
HooksActivationConfig::from_env and threaded down. Testable without env
mutation; matches the project's env → typed config → composition pattern.

Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook
as a first-party builtin. install_first_party_hooks is now a no-op (empty
catalog); the production type/install/export for a hook that does nothing is
gone (removed from lib.rs exports). The activation machinery is still tested
end-to-end through the real composition path via a new
`build_hook_dispatcher_builder_factory_with` seam that takes a first-party
installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the
empty-catalog-is-valid contract: flag ON + empty first-party set + no
extension hooks composes a valid zero-binding dispatcher (not a panic/error).

Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now
drive the test-only seam; the reborn e2e tests already used a test-local no-op
and are untouched. Updated activation-scope docs (loader rustdoc +
build_reborn_runtime call site) to reflect the now-single live source
(builtin-package-declared hooks).

Deferred (not touched): switching to the canonical extension registry for
third-party installed-extension hooks (nearai#3934 follow-on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (nearai#3938)

Addresses serrrfirat's thermo-nuclear re-review on 1e618d0.

#1 (runtime.rs:839, canonical registry): make the extension registry a
shared composition artifact. `build_local_dev` builds one
`Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND
stores it in `RebornLocalRuntimeServices.extension_registry`. Hook
activation in `build_reborn_runtime` now consumes that same `Arc`
instead of rebuilding a builtin-only sidecar, so capability dispatch and
hook activation cannot drift. Third-party activation stays a follow-up,
but it now follows the canonical registry rather than a separate path.

#3 (hooks.rs factory machinery): replace the parse/validate/replay
duplication + two prose-justified `.expect()` calls with a typed
`HookInstallPlan`. TOML is projected once into typed entries, the full
install set is validated once against a fresh builder (fail-closed via
`?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The
per-run path is infallible by construction: a plan only exists for an
install set that already composed cleanly, so a deterministic replay
from the identical fresh-empty start cannot fail. One extension-install
code path (`project_extension_install_sets` + `install_extension_sets`)
is shared by validation and rebuild.

#4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is
intentionally tenant-scoped and shared across runs (rate/value caps keyed
`(hook, tenant, capability)` with no run_id; a run-scoped limit would
reset every run and enforce nothing). Document the split explicitly —
per-run-fresh dispatcher, tenant-scoped predicate counters — in the
module docs and fix the misleading "per-run isolation of hook state"
wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add
`predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives
a real rate-cap predicate through two dispatchers from one factory and
proves the second run sees the first run's recorded count. Rename
`factory_mints_independent_dispatchers_per_call` ->
`rebuild_mints_independent_dispatchers_per_call` and scope it to proving
dispatcher freshness only.

#6 (loop_driver_host tests): clarify that the hand-built builder
factories cover host PLUMBING, not composition activation. Add
`build_reborn_runtime_activates_hooks_through_real_composition_path`,
which drives the real `build_reborn_runtime` with `HooksActivationConfig`
threaded through `RebornRuntimeInput` (env-free) and the canonical
registry, proving the production activation wiring composes.

#2 (env boundary) and #5 (empty production catalog) were already fixed in
1e618d0; docs touched here for consistency.

Known follow-up (not one of the six items, not introduced here): with the
flag ON the standalone local-dev runtime does not yet reach `Completed`
for a capability turn even with a zero-binding dispatcher — the
composition root wires the dispatcher but not the companion hooked-prompt
dependencies. The new runtime test asserts `is_terminal()` + the
capability path and documents the gap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (nearai#3938)

Address henrypark133 review (review 4367870023):

- Add extension-manifest tests for the three previously-uncovered
  hook-entry validation branches: non-table `[[hooks]]` element,
  whitespace-only `id`, and oversized entry (HookEntryTooLarge).
- Document the InvocationCount inclusive-allow / deny-on-overflow
  semantics inline at the comparison site; behavior unchanged and still
  pinned by the cap test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(reborn-cli): assert hooks config threaded in caller test (nearai#3938)

Addresses the review finding that `build_runtime_input_maps_configured_cli_identity`
exercised `build_runtime_input` but never asserted the `hooks` field, so a
regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())`
or flipping the default-OFF rollout-safety contract would pass.

Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions
to the existing caller-level test:
- threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving
  the env-resolved config is actually threaded through and not dropped. Verified
  via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1).
- default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`,
  guarded to skip if the CI environment exports the flag so it only pins the
  contract it claims to.

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

* fix(hooks): correct in-memory backend warning text; delegate test ctor (nearai#3938)

Address serrrfirat review (2026-06-03):

- Low: the in-memory backend warning claimed the LRU cap is shared
  across tenants, but the Reborn composition constructs a fresh
  InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and
  doc comment so the real limitation (process-local replay dedup for
  multi-host deployments) is accurate, and note the backend is
  per-tenant in this composition.
- Nit: PredicateEvaluator::with_backend (test-only) and
  with_state_backend had identical bodies; delegate with_backend to
  with_state_backend so they stay in lockstep.

The Medium finding (hooks_config assertion in build_runtime_input
caller test) was already addressed in 218a1de.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…ection (HOOKS_THIRD_PARTY_ENABLED, default OFF) (nearai#3951)

* feat(hooks): extension-declared hook section on ExtensionManifestV2 (nearai#3934)

Add a `[[hooks]]` declaration surface to the production v2 extension
manifest (`ironclaw_extensions::ExtensionManifestV2` and its projected
`ExtensionManifest`). Each entry is carried as a structurally-typed
`HookSectionEntryV2` DTO — a `local_id` plus the entry's body re-serialized
to canonical TOML — so `ironclaw_extensions` (substrate) never imports the
`ironclaw_hooks` predicate vocabulary. The composition layer, which depends
on both crates, is the single seam that projects these payloads into typed
`ironclaw_hooks::HookManifestEntry` values (a later commit).

Parse-time structural bounds: `MAX_MANIFEST_HOOKS` (32, matching the
downstream per-extension registration cap) and `MAX_HOOK_ENTRY_BYTES` (8 KiB
per entry). Entries must be tables carrying a non-empty `id`; ids must be
unique within the manifest. `#[serde(default)]` keeps every existing
manifest valid (empty `hooks` vec).

The DTO holds canonical TOML as a `String` rather than a `toml::Value` so
the enclosing `ExtensionManifestV2` keeps its `Eq` derive (`toml::Value` is
not `Eq`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): composition-layer activation module (loader, first-party hook, flag) (nearai#3934)

Add `ironclaw_reborn_composition::hooks` — the single seam that activates the
hook framework in production. Implements four numbered pieces of nearai#3934:

- Feature flag (item 7): `HooksActivationConfig`, default OFF, resolved from
  `HOOKS_ENABLED` (only `1`/`true`/`yes`/`on` enable; unset/anything else =
  OFF). Flag OFF ⇒ `build_hook_dispatcher_builder_factory` returns `None` and
  the runtime composes no dispatcher — exact pre-hooks behavior. Hard
  rollout-safety contract.
- Manifest → registry loader (item 2): `install_extension_hooks` projects each
  `ExtensionManifestV2` `HookSectionEntryV2` (canonical TOML) into a typed
  `HookManifestEntry` and installs it via `HookRegistrar::install` at the
  `Installed` trust tier. This is the clean-boundary projection: the hook
  vocabulary lives only here, never in `ironclaw_extensions`. Trust
  attenuation is enforced by construction (registrar only calls
  `install_installed_*`). Fail-closed: any projection/install error fails the
  build loudly.
- First-party builtin hooks (item 3): a single illustrative no-op observer
  (`NoOpObserverHook`), installed regardless of extensions. Ships dark (zero
  driver-visible effect even with the flag ON). Production catalog is TBD by
  design — this PR does not invent a first-party hook.
- Dispatcher composition (item 5): builds a per-tenant `PredicateEvaluator`
  over the in-memory state backend (swappable via the new public
  `PredicateEvaluator::with_state_backend` for durable nearai#3933), validates the
  full install set once fail-closed, and returns a per-run builder-factory
  closure. Per-run construction (fresh registry/dispatcher per host build) +
  per-tenant evaluator give full isolation; the host factory attaches the
  run-scoped milestone sink internally.

Per-tenant scoping is by construction: `build_reborn_runtime` runs once per
identity, so everything here is tenant-local — no global registry.

The router-backed gate-ref factory (PauseApproval/PauseAuth) and the
security-audit sink (nearai#3922, not yet on this branch) are deferred follow-ups;
their absence is fail-closed (PauseApproval surfaces as Denied) and noted for
the PR body. Not yet wired into the runtime — next commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): wire dispatcher builder factory into build_default_planned_runtime (nearai#3934)

Item 6 of nearai#3934. Add an optional `hook_dispatcher_builder_factory` to
`DefaultPlannedRuntimeParts` and, in `build_default_planned_runtime`, call
`.with_hook_dispatcher_builder_factory(...)` on the production
`RebornLoopDriverHostFactory` when it is present. `None` (the default) means
no dispatcher is composed — behavior identical to the pre-hooks runtime
(rollout-safety contract).

The composition layer (`build_reborn_runtime`) resolves the flag via
`HooksActivationConfig::from_env()` and builds the factory against this
tenant's extension registry (per-tenant by construction — the function runs
once per identity). Fail-closed: a malformed manifest hook fails the build
here rather than composing a broken dispatcher.

A per-run builder factory (not a captured dispatcher instance) is used so the
host attaches a run-scoped milestone sink internally per build — per-run
telemetry attribution, the nearai#3573 capture-and-stick lesson.

All `DefaultPlannedRuntimeParts` construction sites (8 test sites across
ironclaw_reborn, ironclaw_product_workflow, ironclaw_reborn_composition)
updated with the new field defaulting to `None`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): e2e activation tests through build_default_planned_runtime (nearai#3934)

Item 8 of nearai#3934. Add four end-to-end tests in
crates/ironclaw_reborn/tests/loop_driver_host.rs that drive the *production*
composition function `build_default_planned_runtime` with a per-run hook
dispatcher builder factory shaped exactly like the composition layer's output
(first-party builtin no-op observer + extension-declared `Installed`-tier
hooks projected from a manifest entry through `HookRegistrar::install`), then
build a host via the composed `host_factory` and invoke a capability:

- flag OFF (no factory): allowed capability completes unaffected and reaches
  the inner host runtime port — the pre-hooks behavior / rollout-safety
  contract.
- flag ON, first-party-only no-op observer: outcome unchanged, inner port
  reached — the builtin ships dark.
- flag ON, extension-declared deny hook: capability denied through the
  composed runtime and the inner port is never reached (installed at the
  Installed tier via the registrar; OwnCapabilities scope keyed to the
  capability provider).
- per-tenant isolation: tenant A's deny hook fires; tenant B (separate
  build_default_planned_runtime composition, no hooks) completes the same
  capability — proving no cross-tenant leakage.

Security-audit-on-deny assertion is intentionally deferred: nearai#3922's
SecurityAuditSink is not yet on reborn-integration. It lands with the
audit-sink wiring follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): thread hook_dispatcher_builder_factory through shared reborn harness (nearai#3934)

The root-crate `tests/support/reborn/harness.rs` constructs
`DefaultPlannedRuntimeParts` directly; add the new
`hook_dispatcher_builder_factory: None` field so the parity-test harness
compiles. Default `None` keeps the harness on the no-hooks path (unchanged
behavior).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks): proper error handling / safety annotations for activation production paths (nearai#3938 CI)

The per-run dispatcher factory closure used `.expect()` on the
first-party and extension hook installs, tripping the no-panics CI gate.
These installs are pure replays of the install set already validated
fail-closed (`?`) against a scratch builder at composition time, so they
are genuine invariants. The factory type returns a non-Result
`HookDispatcherBuilder` and is invoked deep in the run loop, so the
documented `// safety:` suppression is the correct fix here.

Hoisted the expect messages into `let` bindings so the `.expect(msg)`
call fits on one line, keeping the scanner-required `// safety:` comment
on the same line as the call after rustfmt. The malformed-manifest path
(TOML projection) already uses real error propagation via map_err/`?`
and is unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): direct composition-loader coverage + activation-scope docs

Address Codex non-blocking follow-ups on nearai#3938.

Add three direct tests for the composition-layer hook loader
(`install_extension_hooks` via `build_hook_dispatcher_builder_factory`),
driving a real `ExtensionRegistry` with `[[hooks]]` declarations rather
than mimicking the loader:

- valid `own_capabilities` predicate hook installs at the Installed
  trust tier; the dispatcher carries the derived binding at
  BeforeCapability alongside the first-party no-op observer
- malformed typed hook body (unknown `mode`) fails CLOSED with
  `RebornBuildError::InvalidConfig`, never a panic (the load-bearing
  degradation contract for untrusted external manifests)
- a hook claiming `scope = same_tenant` without a verified grant is
  rejected by trust attenuation (fail-closed)

No loader bug surfaced: `HookRegistrar::install` already returns
`Result` on every malformed/over-scoped path and the loader maps it to
`InvalidConfig` via `?`.

Document activation scope at both the loader rustdoc and the
`build_reborn_runtime` call site: production currently passes only
`builtin_extension_registry()`, so third-party installed-extension hooks
are not yet surfaced into the runtime path — only first-party-builtin
and builtin-package-declared hooks activate today.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks): thread HooksActivationConfig through input; empty production catalog

Two maintainability cleanups on nearai#3938 (firat review):

Item 1 — config hygiene: stop reading HOOKS_ENABLED via std::env::var deep
inside build_reborn_runtime. Add a typed `hooks: HooksActivationConfig` field
to RebornRuntimeInput (default OFF) plus a `with_hooks_config` builder. The
composition root now consumes the typed config; the env var is resolved ONCE
at the edge (the reborn CLI's build_runtime_input) via
HooksActivationConfig::from_env and threaded down. Testable without env
mutation; matches the project's env → typed config → composition pattern.

Item 2 — empty production first-party catalog: stop shipping NoOpObserverHook
as a first-party builtin. install_first_party_hooks is now a no-op (empty
catalog); the production type/install/export for a hook that does nothing is
gone (removed from lib.rs exports). The activation machinery is still tested
end-to-end through the real composition path via a new
`build_hook_dispatcher_builder_factory_with` seam that takes a first-party
installer; tests pass a `#[cfg(test)]` NoOpObserverHook through it. Pinned the
empty-catalog-is-valid contract: flag ON + empty first-party set + no
extension hooks composes a valid zero-binding dispatcher (not a panic/error).

Reconciled the sibling loader tests/docs (f6c79c0): the in-module tests now
drive the test-only seam; the reborn e2e tests already used a test-local no-op
and are untouched. Updated activation-scope docs (loader rustdoc +
build_reborn_runtime call site) to reflect the now-single live source
(builtin-package-declared hooks).

Deferred (not touched): switching to the canonical extension registry for
third-party installed-extension hooks (nearai#3934 follow-on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks): canonical registry + infallible plan + tenant-scoped counter docs/tests (nearai#3938)

Addresses serrrfirat's thermo-nuclear re-review on 1e618d0.

#1 (runtime.rs:839, canonical registry): make the extension registry a
shared composition artifact. `build_local_dev` builds one
`Arc<ExtensionRegistry>`, hands it to `HostRuntimeServices::new` AND
stores it in `RebornLocalRuntimeServices.extension_registry`. Hook
activation in `build_reborn_runtime` now consumes that same `Arc`
instead of rebuilding a builtin-only sidecar, so capability dispatch and
hook activation cannot drift. Third-party activation stays a follow-up,
but it now follows the canonical registry rather than a separate path.

#3 (hooks.rs factory machinery): replace the parse/validate/replay
duplication + two prose-justified `.expect()` calls with a typed
`HookInstallPlan`. TOML is projected once into typed entries, the full
install set is validated once against a fresh builder (fail-closed via
`?`), and `HookInstallPlan::rebuild` mints a fresh builder per run. The
per-run path is infallible by construction: a plan only exists for an
install set that already composed cleanly, so a deterministic replay
from the identical fresh-empty start cannot fail. One extension-install
code path (`project_extension_install_sets` + `install_extension_sets`)
is shared by validation and rebuild.

#4 (hooks.rs:295, predicate counter scoping): the evaluator/backend is
intentionally tenant-scoped and shared across runs (rate/value caps keyed
`(hook, tenant, capability)` with no run_id; a run-scoped limit would
reset every run and enforce nothing). Document the split explicitly —
per-run-fresh dispatcher, tenant-scoped predicate counters — in the
module docs and fix the misleading "per-run isolation of hook state"
wording in `ironclaw_reborn` (loop_driver_host.rs / runtime.rs). Add
`predicate_counter_state_is_tenant_scoped_across_rebuilds`, which drives
a real rate-cap predicate through two dispatchers from one factory and
proves the second run sees the first run's recorded count. Rename
`factory_mints_independent_dispatchers_per_call` ->
`rebuild_mints_independent_dispatchers_per_call` and scope it to proving
dispatcher freshness only.

#6 (loop_driver_host tests): clarify that the hand-built builder
factories cover host PLUMBING, not composition activation. Add
`build_reborn_runtime_activates_hooks_through_real_composition_path`,
which drives the real `build_reborn_runtime` with `HooksActivationConfig`
threaded through `RebornRuntimeInput` (env-free) and the canonical
registry, proving the production activation wiring composes.

#2 (env boundary) and #5 (empty production catalog) were already fixed in
1e618d0; docs touched here for consistency.

Known follow-up (not one of the six items, not introduced here): with the
flag ON the standalone local-dev runtime does not yet reach `Completed`
for a capability turn even with a zero-binding dispatcher — the
composition root wires the dispatcher but not the companion hooked-prompt
dependencies. The new runtime test asserts `is_terminal()` + the
capability path and documents the gap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): third-party hook-only projection core (flag, newtype, quarantine, caps)

Steps 1-6 of third-party extension hook activation via hook-only projection:

- Step 1: HOOKS_THIRD_PARTY_ENABLED sub-flag on HooksActivationConfig
  (default OFF; is_third_party_enabled() requires master flag too).
  Resolved at the CLI edge via from_env().
- Step 2: tenant_extension_root(&TenantId) derives the fixed
  /system/extensions/<tenant> root from identity (never caller-supplied);
  projection-layer strict-child / no-`..` containment check.
- Step 3: build_hook_projection_registry assembles a HookProjectionRegistry
  (type-enforced hook-only newtype: no Deref / conversion back to
  ExtensionRegistry, so it can never reach HostRuntimeServices::new / the
  capability path). Sub-flag OFF => builtin-only, byte-identical to nearai#3938.
- Step 4/4a: atomic per-extension quarantine — untrusted (InstalledLocal)
  sets validated whole against a scratch builder, committed only if the
  whole set passes; any failure drops the extension's hooks entirely, emits
  a hook.quarantined security_audit tracing event (warn!, not info!), and
  continues. Trusted (HostBundled) sources stay fail-closed-whole-build.
- Step 5: MAX_INSTALLED_EXTENSIONS_CONSIDERED / MAX_TOTAL_HOOKS_PER_TENANT
  DoS caps; count_total_bindings() accessor on HookDispatcher(Builder);
  pre-read MAX_MANIFEST_BYTES bound via read_file_bounded in discovery.
- Step 6: third-party WASM stays out (loader registrar has no wasm_runtime)
  => WASM-bodied hook quarantines + build continues.

Registrar-only invariant: projection installs go exclusively through
HookRegistrar::install (ceiling + spoof-blocked owning_extension), never the
direct builder installer API.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(hooks): FS-scoped tenant isolation (Option 1), trust matrix, registrar-only assertion

Resolve the discovery/path conflict: the discovery layer hardcodes package
roots to /system/extensions/<id> because the per-tenant RootFilesystem is the
scope boundary (as with every other tenant-scoped resource), not a tenant path
segment. So:

- tenant_extension_root -> fixed /system/extensions (no tenant segment). The
  per-tenant RootFilesystem handed to discovery IS the isolation boundary.
  Documented as load-bearing; the openat2(RESOLVE_BENEATH)/O_NOFOLLOW backend
  hardening follow-up is what protects it (gating note kept prominent).
- build_local_dev mounts /system/extensions to a per-owner host subtree under
  the storage root (per-identity by construction, not a process-global mount);
  exposed via RebornLocalRuntimeServices.extension_filesystem.
- enforce_root_containment retained as defense-in-depth.

Tests:
- Integration (real build_hook_projection_registry + build_hook_dispatcher_
  builder_factory through a fake RootFilesystem, not a loader look-alike):
  containment (hook present / capability absent by construction), FS-as-boundary
  tenant isolation proof (two distinct per-tenant filesystems; A can't see B),
  bad dir name skipped, id mismatch not a panic, surplus-extensions DoS cap,
  sub-flag OFF discovers nothing.
- Per-hook-point trust matrix: BeforeCapability installed deny IS allowed and
  fires (Gate reachable); before_prompt predicate quarantined + build continues;
  after_model/after_capability/after_checkpoint/event_triggered WASM-only =>
  quarantined + build continues; owning_extension derived (not spoofable).
- Discovery pre-read bound: oversized manifest rejected via stat WITHOUT reading
  the body (fake fs panics on get); within-bound proceeds to read.
- ironclaw_architecture source assertion: the hooks.rs projection path never
  calls install_installed_* directly (registrar-only invariant).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(hooks): correct Option-1 path-shape references in test comments

Update the third-party projection integration-test module docs to reflect the
FS-scoped isolation model (fixed /system/extensions root; per-tenant filesystem
is the boundary), not the abandoned /system/extensions/<tenant> path segment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(hooks): tolerant+bounded third-party discovery, structural hook-only containment

Addresses Codex P1/P2 + serrrfirat P1 on nearai#3951.

Critical 1 (discovery-stage DoS): add
`ExtensionDiscovery::discover_with_manifest_contracts_tolerant_bounded`
(+ `discover_extensions_tolerant_bounded` host-runtime wrapper). It lists+sorts
the root once, then reads/parses at most `max_extensions` manifests, recording
the surplus as quarantines WITHOUT reading them. The hook projection calls this
with `MAX_INSTALLED_EXTENSIONS_CONSIDERED`, so the count cap fires before the
per-manifest read storm. New all-or-nothing path delegates to a shared
`load_package_entry` so per-package semantics are identical.

Critical 2 (fail-open): tolerant discovery quarantines a single
malformed/oversized/id-mismatched package and CONTINUES; valid siblings still
load. The builtin-only fallback is now reserved solely for failure to LIST THE
ROOT (directory unreadable). One bad manifest can no longer drop a tenant's
entire legitimate third-party hook set.

Refinement 3: the per-tenant hook budget is consumed only AFTER a successful
merge, so a quarantined/duplicate package no longer burns budget.

Refinement 4: the registrar-only arch assertion now scans the WHOLE
composition crate (every non-test source) and forbids all installed-tier-minting
primitives crate-wide (`install_installed_*`, `install_observer(`,
`insert_binding(`, `HookTrustClass::Installed`) — not just a hooks.rs substring
scan. Installed-tier bindings can only be minted via `HookRegistrar::install`.

serrrfirat P1 (structural containment): `HookProjectionRegistry` no longer wraps
`ExtensionRegistry`. It carries `Vec<HookProjection>` — hook metadata only
(id/version/source/root/[[hooks]]). The projection literally cannot reach
capabilities because it does not hold them; containment is by data shape, not a
withheld conversion. Removes the `ExtensionPackageView` ceremony.

Tests: bounded read-storm cap (read-counting fs panics on surplus), tolerant
per-package quarantine, root-unreadable fallback, quarantined-package-does-not-
consume-budget, malformed-sibling-survives at the projection layer.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(hooks): address serrrfirat review — tenant-attributed install audits + hooks decomposition

Addresses the maintainability review on nearai#3951. Findings #1 (narrow
hook-only boundary), #2 (per-extension discovery quarantine + mixed-batch
test), and #5 (behavioral arch-test invariant) were already satisfied by
the head commit (2b62597); this commit closes the two remaining items
and hardens the arch test against the decomposition:

- #3 (tenant attribution): add
  `build_hook_dispatcher_builder_factory_for_tenant`, threading the
  authenticated `tenant_id` (and its derived extension root) into the
  install-time quarantine-audit seam. `build_reborn_runtime` now calls it,
  so install-time quarantine audits carry the real tenant instead of the
  synthetic `reborn-hook-projection` fallback (closing the split where only
  discovery-time audits were attributed). New caller-driven test
  `for_tenant_entry_point_attributes_install_time_quarantine_to_real_tenant`
  asserts attribution via a deterministic thread-local audit capture
  (immune to tracing's process-wide max-level filter under parallel tests).

- #4 (decomposition): split the 1.7k-line `hooks.rs` into a focused
  `hooks/` module — `mod.rs` (flag/config + public surface), `projection.rs`
  (hook-only `HookProjection`/`HookProjectionRegistry` containment +
  discovery/admission), `factory.rs` (first-party install, per-extension
  quarantine validation, fresh-per-build replay), `audit.rs`
  (`hook.quarantined` emission), and `tests.rs` (the test matrix).
  Behavior-preserving; no logic change.

- arch test: skip dedicated test-module files in the registrar-only scan so
  the #4 decomposition cannot break it; the whole-crate behavioral invariant
  is preserved.

- audit emission uses `debug!` (not `warn!`) per the background/hook-path
  logging rule, on the stable filterable `security_audit` target.

- gemini nearai#353: add the documented no-empty-segment guard to
  `enforce_root_containment` (defense-in-depth, not relying on VirtualPath
  canonicalization).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(hooks): cover hook-entry rejection branches; document InvocationCount cap semantics (nearai#3938)

Address henrypark133 review (review 4367870023):

- Add extension-manifest tests for the three previously-uncovered
  hook-entry validation branches: non-table `[[hooks]]` element,
  whitespace-only `id`, and oversized entry (HookEntryTooLarge).
- Document the InvocationCount inclusive-allow / deny-on-overflow
  semantics inline at the comparison site; behavior unchanged and still
  pinned by the cap test.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(deps): pin kuchikikiki to 0.9.1 (0.9.2 yanked)

cargo-deny failed on the yanked kuchikikiki 0.9.2 pulled in transitively
via readabilityrs. Downgrade to 0.9.1 at the lockfile level.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(reborn-cli): assert hooks config threaded in caller test (nearai#3938)

Addresses the review finding that `build_runtime_input_maps_configured_cli_identity`
exercised `build_runtime_input` but never asserted the `hooks` field, so a
regression silently dropping `with_hooks_config(HooksActivationConfig::from_env())`
or flipping the default-OFF rollout-safety contract would pass.

Per `.claude/rules/testing.md` ("Test Through the Caller"): add two assertions
to the existing caller-level test:
- threading: `runtime_input.hooks == HooksActivationConfig::from_env()`, proving
  the env-resolved config is actually threaded through and not dropped. Verified
  via TDD that dropping the wiring fails this assertion (run under HOOKS_ENABLED=1).
- default-OFF: when `HOOKS_ENABLED` is unset, `!runtime_input.hooks.is_enabled()`,
  guarded to skip if the CI environment exports the flag so it only pins the
  contract it claims to.

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

* fix(hooks): cover build_reborn_runtime third-party wiring + async dir create (nearai#3951)

Address serrrfirat review findings M1 and L2.

M1: add an integration test in tests/runtime.rs that drives
build_reborn_runtime with HooksActivationConfig::enabled().with_third_party_enabled(true),
a real /system/extensions manifest tree on the local-dev host filesystem, and
tenant attribution. Asserts the runtime builds, starts a conversation turn, and
shuts down cleanly — exercising the runtime.rs third-party discovery input +
projection registry + tenant-threading wiring that was previously uncovered (the
projection tests call build_hook_projection_registry / the dispatcher factory
directly, and every other build_reborn_runtime call used the default disabled
config). Verified the test fails when the wiring is broken.

L2: switch the new factory.rs blocking std::fs::create_dir_all for the
extensions host root to tokio::fs::create_dir_all(...).await with the same
error mapping, so it no longer blocks the tokio executor thread inside the
async build_local_dev. The two pre-existing std::fs calls (lines 132/136) are
out of this PR's diff per the posted promise and are left untouched.

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

* fix(hooks): L1 quarantine-surfacing gate doc, L3 robust test-mod strip, M1 coverage-gap TODO

Address serrrfirat 2026-06-03 review (M1/L2 already landed in 9866793).

L1 (security observability): hook.quarantined audit events are emitted only
via tracing at the security_audit target / debug! level, which production
typically disables. Document durable quarantine surfacing as a hard
production-enablement prerequisite for HOOKS_THIRD_PARTY_ENABLED, alongside the
existing openat2(RESOLVE_BENEATH)/O_NOFOLLOW FS-hardening note, at all three
gate doc sites: HooksActivationConfig (hooks/mod.rs), the runtime.rs composition
-root gate comment, and the audit.rs module doc.

L3 (robustness): strip_test_module matched #[cfg(test)]\nmod tests specifically
and only the first occurrence. Generalize the anchor to #[cfg(test)]\nmod (any
module name) so a refactor that renames the test module or adds a second
#[cfg(test)] mod block is still fully stripped, preventing false positives in
the FORBIDDEN_INSTALLED_PRIMITIVES architecture scan.

M1 (test coverage): the build_reborn_runtime third-party wiring test already
landed in tests/runtime.rs (9866793). Add the reviewer-requested TODO
preserving the removed test's Cancelled-outcome coverage gap: the stub local-dev
gateway cancels the turn before any capability dispatches, so the test exercises
discovery + projection + tenant-threading at build/start but not end-to-end hook
enforcement. NOTE: third-party discovery is intentionally tolerant (skips
unparseable manifests), so this test catches compile-time field/arg regressions
and build-path failures but not a silent manifest-read drop; documented for the
reviewer.

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

* fix(hooks): correct in-memory backend warning text; delegate test ctor (nearai#3938)

Address serrrfirat review (2026-06-03):

- Low: the in-memory backend warning claimed the LRU cap is shared
  across tenants, but the Reborn composition constructs a fresh
  InMemoryPredicateStateBackend per tenant. Rewrite the warn! text and
  doc comment so the real limitation (process-local replay dedup for
  multi-host deployments) is accurate, and note the backend is
  per-tenant in this composition.
- Nit: PredicateEvaluator::with_backend (test-only) and
  with_state_backend had identical bodies; delegate with_backend to
  with_state_backend so they stay in lockstep.

The Medium finding (hooks_config assertion in build_runtime_input
caller test) was already addressed in 218a1de.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 18, 2026
…earai#4559)

* docs: trace commons agent onboarding design spec

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

* docs: address spec review findings (trust anchoring, key staging, consumption atomicity, replay validation)

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

* docs: spec review round 2 nits (server-anchored tenant wording, pending-key cleanup)

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

* docs: implementation plan for trace commons agent onboarding

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

* docs: address plan review findings (scope threading refactor, dispatch model, dev-deps, LazyLock hazard)

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

* docs: plan review round 2 fixes (literal dep versions, context constructor threading depth)

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

* docs: incorporate server-agent coordination feedback (optional community/profile/leaderboard URLs)

From TraceCommons/trace-commons#136-#141 comments: onboard response
gains optional browser-surface navigation hints, sanitized client-side
(HTTPS or dropped), never part of issuer trust anchoring.

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

* feat(traces): onboarding wire types matching trace-commons-server contract

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

* feat(traces): invite URL parsing with origin trust anchoring

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(traces): device keypair lifecycle with pending staging and self-signed workload JWTs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(traces): auth_mode and device_key_id policy fields with legacy-compatible defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(traces): onboard() orchestration with trust anchoring and retry-safe key staging

Wire invite parsing, device key staging, onboard POST, issuer origin trust anchoring,
ingest_url HTTPS enforcement, keypair promotion, and policy write into onboard_at_dir().
Refactors invite.rs to extract pub(crate) is_https_or_loopback, origin_of, and host_only
helpers shared with mod.rs (one source of truth for origin/bracket handling). Adds axum
mock-issuer tests covering the happy path, mismatch rejection, terminal vs transient error
key retention, insecure ingest URL, loopback ingest allowance, community URL sanitisation,
and retry key reuse.

Partial-failure lockout fix (spec §2.2): promote() no longer deletes the pending file.
The flow now writes the tenant key file, then the policy, and only discards the pending
file after BOTH durably succeed. If the policy write fails the pending key survives, so a
retry reloads the same key (server idempotency returns the original registration) and
harmlessly overwrites the tenant file — no permanent lockout from a consumed invite with a
regenerated keypair. Regression test simulates a policy-write failure (policy.json
pre-created as a non-empty dir so the atomic rename fails), asserts Err(Persist) with the
pending key intact, then asserts a retry succeeds reusing the same device_key_id.

Response validation (defense-in-depth): reject schema_version != the v1 response constant
as MalformedResponse, and cross-check the response device_key_id against the locally derived
id (we never trust the response value for policy; a disagreement is now treated as a tamper
signal and rejected). Both covered by tests.

The onboard response body is read with the 64 KB cap enforced per-chunk during streaming
(mirroring read_bounded_trace_upload_claim_response) rather than buffering the whole body
first, so a hostile server cannot force a large allocation.

Also fixes a pre-existing test-isolation defect surfaced by the added load: the
remote-request timeout test configured a 50ms timeout via the process-global
IRONCLAW_TRACE_REMOTE_REQUEST_TIMEOUT_MS env var. set_var is process-global, so under
parallel execution the 50ms value leaked into other tests' trace HTTP clients, producing
spurious `operation timed out` failures against fast local mocks. Replace the env mutation
with a task-scoped TEST_REMOTE_REQUEST_TIMEOUT_OVERRIDE task-local (visible only within the
awaiting test's own task tree, zero production change; documents the spawn caveat), and
decouple the timing assertion from a tight wall-clock race so it no longer flakes when
reqwest's timer is delayed under an oversubscribed runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(traces): device-key self-signed workload JWT branch in upload-claim refresh

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

* feat(engine): trace_commons onboard and status first-party tools with agent guidance

Add two model-visible first-party capabilities to the Reborn engine:
- builtin.trace_commons.onboard: drives operator-invite enrollment flow with
  explicit per-conversation consent gate (confirmed=true required before any
  network call); maps OnboardOutcome/OnboardError to clean agent-readable JSON
- builtin.trace_commons.status: read-only enrollment state inspector

Wires ironclaw_reborn_traces into ironclaw_host_runtime, creates schema files
(schemas/builtin/trace-commons-{onboard,status}.{input,output}.v1.json) and
prompt doc files (prompts/builtin/trace-commons-{onboard,status}.md) at the
manifest-derived paths. Includes 11 unit tests covering input parsing, consent
refusal, success/error value formatting, and status formatting.

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

* docs: add Task 11 — credits visibility (console display + agent-queryable balance)

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

* test(engine): e2e trace commons onboarding through capability dispatch

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

* docs(traces): document agent onboarding flow in trace-commons internal doc

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

* docs: correct Task 11 console scope (credit endpoint already exists; frontend = coordinate with designer)

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

* feat(traces): trace_commons.credits agent-queryable balance tool

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(gateway): minimal Trace Commons credits card in settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(traces): store upload-claim endpoint in policy; preserve primary onboard error; block metadata/link-local/multicast issuers

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

* fix(traces): route agent onboarding HTTP through host network-egress policy (nearai#4560)

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

* build: update Cargo.lock for trace-commons onboarding dev-deps

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

* chore(traces): drop orphaned schema/prompt files (main resolves builtin schemas inline; prompt_doc_ref dropped)

Post-merge cleanup: main's first_party_tools now resolves builtin input schemas
via the inline schemas.rs match (trace_commons arms added during the merge) and
sets prompt_doc_ref: None for all builtins, so the physical trace-commons-*.json
schema files and trace-commons-*.md prompt docs are no longer referenced. The
onboard consent contract remains in the capability description and is enforced in
dispatch_onboard.

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

* Fix Trace Commons invite hash contract

* fix(traces): grant trace_commons capabilities in local-dev policy

The three builtin.trace_commons.* capabilities were declared in the
first-party package but had no [[grants]] entries in
local_dev_capability_policy.toml, so local-dev runs (repl/serve)
filtered them out of the model-visible tool surface entirely. The
provider-level authority_effects ceiling had external_write, but the
per-capability grants were never added.

onboard gets the local_dev_wildcard egress profile (invite origins are
operator-chosen; private/metadata IP ranges stay blocked by the shared
enforcer). status/credits are read-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(traces): add Reborn e2e coverage for trace_commons first-party tools

Closes the coverage gate failure: builtin.trace_commons.{onboard,status,
credits} were declared in the first-party package but missing from
REBORN_FIRST_PARTY_E2E_COVERED_CAPABILITIES, failing
reborn_builtin_first_party_capability_e2e_coverage_is_complete on both
the Reborn root tests and all-features CI jobs.

Adds a trace_commons host-runtime harness (network policy populated so
the onboard Network-effect obligation passes) and a parity test driving
all three capabilities through the scripted model loop: onboard with
confirmed=false exercises the deterministic consent gate with no
network, status and credits return the unenrolled/zero-credit defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(traces): community profile second opt-in (token mint + profile set)

After device-key enrollment, public leaderboard attribution is a second,
separate opt-in: IronClaw mints a short-lived profile token from the
claim issuer with consent_scopes=[public_attribution] and empty
allowed_uses (such a claim cannot submit traces), then either prints it
for the web profile page or performs the profile update itself. The
browser cannot sign device-key requests, so the token must be minted by
IronClaw — previously this step was impossible and agent guidance
invented flows.

- ConsentScope::PublicAttribution mirrors the server protocol enum;
  default_allowed_uses_for_scope returns empty for it.
- mint_profile_attribution_token_for_scope / set_community_profile_for_scope /
  withdraw_community_profile_for_scope reuse the hardened issuer HTTP
  path (allowlist validation, pinned DNS, no redirects, bounded reads,
  token never in errors). PUT/DELETE /v1/community/profile per the
  server contract; handle (3-32 ASCII alnum/-/_) and bio (<=280 bytes)
  validated client-side.
- CLI: ironclaw-reborn traces profile token|set|withdraw.
- Onboard tool next_steps now describes the profile second opt-in so
  agent guidance stops inventing browser login flows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(traces): autonomous turn-end trace capture in the Reborn runtime

The Reborn binary could onboard, report status/credits, and manage
profiles, but never captured or submitted traces — the autonomous
pipeline existed only in the v1 agent loop. This wires it into the
Reborn runtime composition:

- TraceCaptureTurnEventSink subscribes best-effort to the turn
  lifecycle bus (the existing turn_event_sink injection seam). On
  Completed/Failed events with an explicit owner it spawns a detached
  task that reads the owner's standing policy (one file read for
  non-enrolled users), loads the recent thread history (last 24
  messages, 5 turns — v1 parity), adapts user/assistant text rows into
  the neutral ConversationMessage shape, redacts + scores locally, and
  queues + immediately flushes eligible envelopes. All failures are
  debug!-logged and never touch the turn lifecycle path.
- A periodic flush worker (300s, 25/scope — v1 parity) retries queued
  envelopes for the runtime owner plus every scope observed since
  boot, with CancellationToken shutdown alongside the other workers.
- TraceClientAutonomousCaptureRequest gains outcome_override so the
  lifecycle event's terminal status (authoritative in Reborn, where
  transcripts carry no structured outcome payload) marks failed turns
  as TaskSuccess::Failure; v1 passes None (no behavior change).
- Tool-result rows and credit-notice delivery are documented follow-ups
  (refs-only records; no composition-level outbound channel surface).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(traces): end-to-end auto-capture through send_user_message

Proves the full Reborn auto-submission chain with a real runtime: a
completed turn for an enrolled owner scope lands a redacted envelope in
that scope's submission queue with no manual trace command — turn
completion -> lifecycle bus -> capture sink -> thread-history read ->
redact/score -> eligibility -> queue (+ local-failing immediate flush
leaves the entry for the retry worker).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Expose Trace Commons profile token tool

* Expose Trace Commons profile set tool

* Allow Trace Commons profile setup from agent

* feat(webui-v2): Trace Commons credits card in WebChat v2 settings

Adds GET /api/webchat/v2/traces/credit and a read-only Trace Commons
settings tab to the v2 SPA, giving webui-v2-beta parity with the v1
console's credits card.

- Route follows the descriptor system end to end: bearer-auth required,
  NoBody, 120/60 per-caller read rate limit; descriptor-driven
  body/rate-limit enforcement applies automatically.
- RebornServicesApi::trace_credits derives the trace scope exclusively
  from the authenticated caller's user id (never from query/body) and
  reads contributor-local state via ironclaw_reborn_traces
  (policy + trace_credit_report), soft-falling back to an unenrolled
  zero-state on missing/unreadable local state, mirroring
  builtin.trace_commons.credits.
- SPA: Trace Commons subtab (enrollment, pending/final credit, delayed
  ledger delta, submission counts, last submission/sync, recent credit
  explanations) with the server-authoritative framing and a
  not-enrolled empty state pointing at agent onboarding.
- Tests: descriptor contract row, handler oneshot, and three composed-
  router serve tests (200 zero-state, 401 without bearer, enrolled
  policy reporting with per-test scope isolation).
- Drive-by: cfg-gate openai_user_id in webui_serve.rs to clear a
  pre-existing unused-variable warning under default features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Exempt Trace Commons profile setup from local-dev gate

* Route Trace Commons profile writes to ingest

* review(4559): address serrrfirat feedback

- Drop stray working-note markdown files from the repo root (they rode
  in via an early origin/main merge and are not this PR's documentation).
- trace_commons_dispatch_e2e: setup_base_dir is now a OnceLock that every
  test calls first — the previous 'single-threaded during init' claim was
  wrong under tokio's multi-threaded test runtime, and two of three tests
  skipped the setup entirely.
- settings.js: extract shared appendDisplayGroup + declarative row defs;
  loadTraceCommonsCredits drops from ~120 lines of manual DOM to a rows
  array; also removes a double-escape (textContent + escapeHtml) on
  explanation lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(webui-v2): add traceCommons i18n keys to all locales

The credits card added the traceCommons.* key set to en.js only; the
i18n consistency test (all_locales_share_the_en_key_set) requires
every locale to carry the same key set. Adds translated entries to
ar, de, es, fr, hi, ja, ko, pt-BR, uk, and zh-CN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(reborn): fail loud with source context on malformed local-dev master key

The local-dev secret store resolver read the cached key file (and the
SECRETS_MASTER_KEY env fallback) and passed the material straight into
SecretsCrypto::new several layers deep. A corrupt or low-entropy key
(e.g. a 64-char all-zeros value, which passes the length floor but has
one distinct byte) surfaced only as the opaque "Invalid master key",
with no pointer to the file the operator must fix.

- Add ironclaw_secrets::validate_master_key_material as the single
  source of truth for master-key rules; SecretsCrypto::new delegates
  to it.
- resolve_local_dev_secret_master_key now validates at the source
  (cached file vs SECRETS_MASTER_KEY env) and returns a
  RebornBuildError::InvalidConfig naming the offending path/env var and
  the actual constraint, before any crypto is constructed.
- A malformed env value is now rejected before being persisted to the
  cached key file (no more poisoned-cache state).

Tests: malformed-file path-context rejection, malformed-env
source-context rejection, valid cached file accepted.

Refs nearai#4741

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

* feat(webui-v2): add Trace Commons credits card to chat sidebar

Surface trace contribution credits at a glance in the chat sidebar,
above the conversation list. Previously credits were only visible under
Settings -> Trace Commons.

- New SidebarTraceCredits component reuses the existing useTraceCredits
  hook (/api/webchat/v2/traces/credit) — no new endpoint. Renders only
  when enrolled; loading/error/not-enrolled render nothing to keep the
  sidebar clean. Shows final credit and accepted/submitted counts and
  clicks through to Settings -> Trace Commons for the full ledger.
- useTraceCredits now refetches (60s interval + on window focus) so the
  card and the Settings tab reflect newly-accepted submissions live.
- Add one compact i18n key (traceCommons.cardAccepted) across all 11
  locales; reuse existing keys for the rest.
- Source-shape regression test in assets.rs.

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

* feat(reborn-traces): reconstruct tool calls in turn-end trace capture

The Reborn capture adapter dropped every tool-result row, so captured
trace envelopes were text-only. That left the two highest-value scoring
levers — replayability (0.20) and tool coverage (0.15) — permanently at
zero, so even agentic tool-using turns scored as plain chat and stayed
below the 0.35 submission gate. Nothing ever submitted.

conversation_messages_from_records now reconstructs a `tool_calls`
message from each run of ToolResultReference rows that carry
`tool_result_provider_call` replay metadata, collapsing consecutive
rows into one message positioned between the user message and the
assistant response (the shape capture_turns_from_conversation_messages'
per-turn lookahead consumes). Tool names always flow through so the
value scorecard sees required_tools/replayable; raw tool payloads stay
consent-gated downstream by include_tool_payloads. Rows without provider
metadata remain dropped.

TDD:
- adapter unit tests: single tool call -> tool_calls message;
  consecutive calls collapse into one; ref without provider metadata
  still dropped.
- integration guard: a captured tool-using turn's queued envelope
  carries replay.required_tools + replayable=true.

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

* fix(reborn-traces): read capture history from context window, not display projection

Tool-call reconstruction (previous commit) had no data to work with: the
capture history source read SessionThreadService::list_thread_history,
whose product-display projection (history_message) hard-nulls
tool_result_provider_call. So even though tool calls persist with full
provider metadata, the adapter received None on every tool row, dropped
them, and produced a text-only envelope that scored below the 0.35
submission gate. Nothing ever submitted.

SessionThreadHistorySource now reads load_context_window (the
model-context/replay view, which preserves tool_result_provider_call)
and maps ContextMessage -> ThreadMessageRecord via context_window_to_records.
This is the semantically correct source for trace capture anyway: the
replay transcript, not the display transcript.

TDD: a caller-level test (per .claude/rules/testing.md "test through the
caller") drives SessionThreadHistorySource against a real
InMemorySessionThreadService with an appended tool result, asserting the
returned tool row keeps provider_call. Failed on list_thread_history
(None), passes on load_context_window.

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

* feat(reborn-traces): auto-submit traces with PII risk below High

Previously any non-Low residual PII risk was blocked from auto-submission
two ways: the manual-approval eligibility gate held everything != Low, and
the value scorecard halved the score (privacy_gate Medium 0.5) and
subtracted a 0.60-weighted penalty. A minimal tool trace scores ~0.36 at
Low (barely over the 0.35 gate), so any Medium penalty collapsed it to 0 —
nothing below High could ever submit.

Treat below-High residual risk as clean for auto-submission (the
deterministic redactor has already scrubbed detected PII):

- trace_autonomous_eligibility manual-approval gate now holds only High
  (== High, was != Low).
- privacy_gate: Low|Medium => 1.0 (was Medium 0.5); High => 0.0.
- privacy_risk_score: Low|Medium => 0.0 (was Medium 0.5); High => 1.0.

High remains fully blocked: privacy_gate zeros its score and the gate holds
it for manual review. The 0.35 submission gate leaves no headroom for a
partial Medium discount on a minimal trace, so below-High is clean rather
than partially penalized.

TDD: medium_pii_tool_trace_auto_submits_while_high_is_held asserts a
Medium-risk tool trace clears 0.35 and auto-submits while High is held.

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

* docs(reborn-traces): design for Trace Commons held-trace review

Held traces are currently dropped on the autonomous capture path with no
visibility or authorize path. This plan reuses the existing hold-sidecar
machinery (TraceQueueHold / .held.json / read_trace_queue_holds_for_scope /
ManualReview) and adds: retain held traces, surface a held count+list on
the /traces/credit response, a card/tab UI, and a promote-as-is authorize
endpoint. Four independently-shippable TDD slices.

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

* feat(reborn-traces): retain manual-review held traces instead of dropping (slice 1)

Autonomous turn-end capture dropped every held trace (logged at debug,
envelope discarded), so PII-gated traces were unrecoverable and invisible.

Slice 1 of the held-review feature retains manual-review holds:

- TraceQueueEligibility::Hold now carries a typed TraceQueueHoldKind
  (ManualReview for the High residual-PII gate; PolicyGate for score /
  tool-allowlist / submission-class gates), replacing reason-string
  classification at the flush call site.
- TraceClientAutonomousCaptureOutcome::Held carries the built envelope and
  its kind so callers can persist it.
- New queue_trace_envelope_as_held_for_scope: queues the envelope plus a
  ManualReview .held.json sidecar under one scope lock; the flush worker
  already skips held sidecars, so it is retained but not submitted.
- capture_turn_trace retains ManualReview holds and still drops PolicyGate
  holds (low-value traces never pollute the review surface).

TDD: held-retain function (RED on missing sidecar -> GREEN), eligibility
kind classification, and caller-level capture tests (an AWS-key message
forces High PII -> retained ManualReview hold; a sub-threshold trace is
dropped, not retained).

Refs docs/plans/2026-06-10-trace-commons-held-review.md

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

* feat(webui-v2): surface manual-review held count + list on /traces/credit (slice 2)

Held traces retained by slice 1 were invisible to the UI. Slice 2 surfaces
them on the existing trace-credits response so one fetch powers the whole
card/tab.

- ironclaw_reborn_traces: manual_review_holds_for_scope() returns only
  ManualReview holds (excludes PolicyGate value-gates and transient
  RetryableSubmissionFailure retry holds), via an extracted
  retain_manual_review_holds filter.
- RebornTraceCreditsResponse gains manual_review_hold_count + holds[]
  ({ submission_id, reason }). Sanitized: submission id and the already
  privacy-safe hold reason only, never raw trace content.

TDD: retain_manual_review_holds filter unit test (excludes policy/retry),
disk-level manual_review_holds_for_scope test, and the facade zero-state
test asserts the new fields default empty. webui_v2 handler contract tests
(42) still pass with the propagated fields.

Refs docs/plans/2026-06-10-trace-commons-held-review.md

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

* feat(webui-v2): show held-for-review traces on card + Settings tab (slice 3)

Surface the manual-review held count/list from slice 2 in the UI. Both
render only when there are holds, so the common (nothing-held) state is
unchanged.

- Sidebar card: "{count} held for review" line when
  manual_review_hold_count > 0.
- Settings -> Trace Commons tab: a "Held for review" section listing each
  held trace's sanitized reason + submission id from holds[].
- No hook/api change: fetchTraceCredits already returns the raw response,
  so credits.holds / credits.manual_review_hold_count are available.
- Three i18n keys (cardHeld, heldTitle, heldDescription) across all 11
  locales.

The per-trace Authorize action ships with its endpoint in slice 4 (so the
UI never offers a button that 404s). Source-shape assertions extended.

Refs docs/plans/2026-06-10-trace-commons-held-review.md

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

* feat(webui-v2): authorize held traces for submission (slice 4)

Complete the held-review feature with a promote-as-is authorize action
across the stack.

ironclaw_reborn_traces:
- TraceContributionEnvelope gains `manual_review_authorized`; an authorized
  envelope submits past every gate in trace_autonomous_eligibility (the flush
  re-evaluates eligibility each pass, so removing the hold sidecar alone is
  not enough to promote).
- authorize_manual_review_hold_for_scope: stamps the envelope (durable
  consent record) BEFORE removing the .held.json sidecar, so a crash between
  the two leaves the trace held (fail closed). Only ManualReview holds are
  authorizable; unknown submissions return Ok(false), not an error.

ironclaw_product_workflow:
- RebornServicesApi::authorize_trace_hold derives scope from the
  authenticated caller (the path submission id is never cross-scope
  authority), validates the id, and returns RebornTraceHoldAuthorizeResponse.

ironclaw_webui_v2:
- POST /api/webchat/v2/traces/holds/{submission_id}/authorize — NoBody,
  mutation rate limit, bearer auth. Descriptor + handler + router + contract
  table (now 46 routes).

Frontend:
- authorizeTraceHold api, an authorize mutation in useTraceCredits that
  invalidates the credits query on success, and a per-hold Authorize button
  on the Settings tab. `authorize`/`authorizing` i18n in all 11 locales.

TDD: authorize promotes a High-PII held envelope past all gates; facade
zero-state; webui_v2 descriptor/handler contracts; composition serve (47);
source-shape assertions. clippy/fmt clean across crates.

Refs docs/plans/2026-06-10-trace-commons-held-review.md

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

* fix(traces): loopback dev claim exception + profile_set consent gate

Address the two codex P2 findings from review:

- Preserve loopback claim uploads after onboarding: the loopback-HTTP
  dev invite form stores a loopback claim/ingest endpoint in the
  policy, but the claim/ingest validators required https and rejected
  loopback hosts, so a successful loopback onboarding could never mint
  a claim or submit credits. The validators and the pinned DNS
  resolution now honor the same literal-loopback exception as invite
  parsing (shared is_loopback_host predicate); for loopback hosts the
  pinned resolution additionally requires all resolved addresses to be
  loopback. Non-loopback http, internal hostnames, and private ranges
  stay rejected, and the issuer allowlist still applies.

- Require explicit confirmation before community profile updates:
  trace_commons.profile_set now has the same hard confirmed=true input
  gate as onboarding — it short-circuits with consent_required before
  the enrollment check and any network write, since the capability is
  approval-gate-exempt in local-dev policy. Schema and manifest
  document the field.

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

* fix(merge): thread attachments field through trace-capture record construction

main added ThreadMessageRecord.attachments (Vec<AttachmentRef>); the
trace-capture reconstruction path and its two test helpers construct
records and must set it. The capture path reconstructs records from a
context window for redaction/scoring and carries no attachment refs of
its own, so Vec::new() is correct.

* fix(traces): adapt v1 autonomous capture to new Held variant shape

The merge brought in slice 1 of the held-trace-review feature, which
changed TraceClientAutonomousCaptureOutcome::Held from
{ submission_id, reason } to { kind, reason, envelope } so manual-review
holds can be retained instead of dropped. The v1 autonomous-capture path
in thread_ops.rs still matched the old shape, breaking the
`--no-default-features --features libsql` build (and default build).

Adapt the v1 path to the new shape and give it the same retain-or-drop
parity as the Reborn capture path
(ironclaw_reborn_composition::trace_capture): ManualReview holds are
retained via queue_held_envelope_for_scope (the on-disk held queue is
shared, so a v1-captured hold surfaces in the v2 review UI); policy/value
gates are dropped as before, just logged.

Behavior mirrors the tested Reborn path
(send_user_message_auto_queues_trace_for_enrolled_scope); the v1
autonomous-capture path is a detached tokio::spawn with no unit-testable
seam, so no focused regression test is added.

[skip-regression-check]

* fix(traces): set manual_review_authorized in reborn-cli test envelope fixture

The merge brought in the held-trace-review manual_review_authorized
field on TraceContributionEnvelope. The reborn-cli trace_queue test
fixture constructs the envelope directly and missed the field, breaking
`cargo clippy --all-features --tests` and `Tests (all-features)` (the
fixture is test-only, so the libsql binary build did not surface it).
Fresh queued envelopes are not yet authorized, so false is correct.

[skip-regression-check]

* test(traces): pass confirmed=true in profile_set parity step

The trace_commons first-party-tools parity test invoked profile_set
without confirmed=true and asserted the NotEnrolled enrollment-gate
result. Commit 6bc776d added the public-attribution consent gate to
dispatch_profile_set, which now short-circuits to consent_required
before the enrollment check when confirmed is unset — so the test's
NotEnrolled assertion failed (the gate output carries no error_code).

Pass confirmed=true so the call clears the consent gate and reaches the
enrollment check, deterministically returning NotEnrolled with no
network (the scope never onboarded). Matches the unit-test pattern
established for the other profile_set tests in the same change.

[skip-regression-check]

* fix(traces): onboarding-security + contribution correctness (coderabbit batch 1)

Addresses 6 coderabbit findings in ironclaw_reborn_traces:

- device_key.rs: re-assert 0o700 on pre-existing key dirs (not just on
  create), so broader perms on an existing device_keys/ or pending/ can't
  leave invite/tenant hashes enumerable.
- device_key.rs: fail closed on load when on-disk public_key/device_key_id
  don't match the loaded private key (tampered/partial files no longer load
  an inconsistent identity that only fails later at remote auth).
- invite.rs: scope the staged pending-key filename by invite ORIGIN, not
  just code, so two issuers reusing one invite code can't share a device key
  (invite_hash stays code-only as the server allowlist subject).
- onboarding/mod.rs: reject ingest_url values with embedded userinfo before
  persisting, so a malicious onboarding response can't smuggle credentials
  into policy.json + outbound requests.
- contribution.rs: preserve mount path prefixes when deriving the
  community-profile endpoint (mirrors trace_submission_status_endpoint);
  a prefixed deployment no longer 404s on profile PUT/DELETE.
- contribution.rs: fail closed in trace_autonomous_eligibility on envelopes
  with no allowed-uses (public_attribution-only) instead of relying on the
  remote to bounce them.

Updated two retry tests that encoded the cross-issuer key-sharing bug now
fixed: they retried against a second mock on a different port; a new
spawn_flaky_mock_issuer keeps the retry on the same origin so it exercises
genuine same-issuer pending-key reuse. Added regression tests for each fix.

* fix(trace-commons): address coderabbit review findings on nearai#4559

- index.html: add type="button" to the Trace Commons settings subtab to
  prevent accidental form submission.
- settings.js + i18n/en.js: route the Trace Commons credits copy through
  I18n.t(...) and register the matching locale keys (matches the existing
  surface pattern; en-only like settings.traceCommons, fallback covers rest).
- factory.rs: drive the malformed SECRETS_MASTER_KEY env case through the
  real caller resolve_local_dev_secret_master_key (via an env-parameterized
  inner) and assert the rejected key is never persisted to the cached file.
- trace_commons_dispatch_e2e.rs: give each test a distinct user/extension
  scope so onboarding state can no longer bleed across tests.
- local_dev_capability_policy.toml: exempt builtin.trace_commons.onboard
  from the REPL approval gate (it has its own confirmed=true consent gate,
  mirroring profile_set).
- docs: fix the onboard prompt-file reference, match the held-trace JSON
  shape to RebornTraceHold (submission_id + reason only), and resolve the
  wire-protocol ownership split (types live locally in onboarding/protocol.rs,
  no shared trace-commons-protocol crate).

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

* fix(traces): tenant-scoping + token leak + read-failure + unbounded scopes (coderabbit batch 2)

Addresses the coupled backend findings:

- Tenant-scope Trace Commons local state across the Reborn paths: new
  trace_scope_key(tenant, user) helper keys policy / device-key / credit /
  profile / capture state by tenant+user, so the same user id in two tenants
  no longer shares state. Applied in host_runtime trace_commons dispatchers,
  product_workflow credits/hold, and composition trace-capture (v1 stays
  user-only — legacy single-tenant). Updated the affected runtime/sink tests
  and added a non-owner attribution assertion.

- Do not return the raw profile token from the model-visible profile_token
  capability: persist it to a 0600 <scope>/profile_token.jwt and return the
  file path + instructions instead, keeping the bearer credential off the LLM
  transcript.

- Stop masking genuine local-state read failures as zero/not-enrolled: the
  status capability and the WebUI credits path now propagate a read/parse
  failure (NotFound is already softened inside read_*_for_scope) so an
  enrolled user with a corrupt policy file is not told they have nothing.

- Bound ObservedTraceScopes: the periodic flush worker now prunes drained
  scopes (new trace_scope_has_pending_queue) after each tick, so the set is
  bounded by actual pending backlog instead of growing one entry per caller
  ever seen.

Note: a v1 caller-level test for the ManualReview hold-retention path is not
included — v1 ingress blocks secrets outright and the outbound leak detector
redacts them, so the High-residual-PII condition that produces a ManualReview
hold cannot be reproduced through process_user_input. The retention logic is
identical to and covered by the Reborn-side
capture_retains_manual_review_hold_for_high_pii_trace.

* test(traces): enroll under tenant-scoped key in webui_v2_serve credits test

trace_credits_reports_enrolled_for_caller_with_enabled_policy wrote the
policy under the bare user id, but the credits route now keys local state
by trace_scope_key(tenant, user). Enroll (and clean up) under the composite
TENANT/user scope so the route sees the enrollment.

* fix(factory): fail closed on explicit-but-unusable SECRETS_MASTER_KEY

An explicitly-set-but-unusable local-dev master key silently fell through
to generating + persisting a fresh key, leaving local-dev secrets
encrypted under an unintended master key the operator never chose:

- resolve_local_dev_secret_master_key used std::env::var(...).ok(), which
  drops VarError::NotUnicode -> treated as absent. Now only NotPresent is
  absent; a non-Unicode value returns InvalidConfig.
- resolve_local_dev_secret_master_key_with_env collapsed a set-but-empty
  (or whitespace-only) value to None via .filter(). Now a set-but-empty
  value returns InvalidConfig instead of generating a key.

Added resolve_local_dev_secret_master_key_rejects_set_but_empty_env_without_persisting
asserting empty/whitespace env values fail closed and persist nothing.
(coderabbit follow-up on nearai#3794)

* fix(factory): reject empty SECRETS_MASTER_KEY before the cached-file read

Follow-up to the prior fix: the empty-env rejection lived in the env
branch, which only runs when no cached key file exists. On a rebuild
where .reborn-local-dev-secrets-master-key already exists, the cached key
was returned first, so an explicitly-set-but-empty SECRETS_MASTER_KEY was
still silently ignored. Hoist the empty/whitespace rejection (and env
normalization) above the cached-file read so it fails closed regardless
of cached state. Added
resolve_local_dev_secret_master_key_rejects_empty_env_even_with_cached_file
asserting the empty env is rejected and the cached key is left unchanged.

* fix(traces): address 14:54 coderabbit re-review (tenant-seed, IO errors, effects, test)

Four outside-diff findings from the re-review:

- runtime.rs: seed ObservedTraceScopes with the runtime owner's
  trace_scope_key(tenant, owner) composite, not the bare owner id, so
  startup pending-queue discovery matches how capture keys state; the
  enrolled-scope test cleanup now removes the composite scope dir too.
- runtime.rs: the trace-queue polling test helper no longer swallows
  read_dir errors via unwrap_or_default() — only NotFound is the expected
  pre-capture fallback; any other IO error panics instead of masking as
  'no queued traces'.
- trace_commons.rs manifests + local_dev grants: onboard (device-key
  material) and profile_token (0600 token file) now declare
  Read/WriteFilesystem effects, and the local-dev grants allow them, so
  the effect model accurately models the local secret-material writes.
- local_dev_authorization test: added local_dev_trace_commons_onboard_skips_approval_gate
  (the onboard exemption was the actual fix; the profile_set-only test
  would pass even if the onboard TOML exemption were dropped).

* fix(factory): validate non-empty SECRETS_MASTER_KEY before the cached-file read

Follow-up: the prior fix rejected an *empty* env value before the cached
read but still validated a non-empty *malformed* value only after it.
So a valid cache + SECRETS_MASTER_KEY=0000... silently ignored the
explicit bad secret config on rebuilds. Move validate_resolved_master_key
into the up-front env normalization so any explicit-but-unusable env key
(empty OR malformed) fails closed regardless of cached state. Added
resolve_local_dev_secret_master_key_rejects_malformed_env_even_with_cached_file.

* fix(traces): address 15:41 coderabbit re-review (credits read-failure + 2 test guards)

- trace_commons.rs dispatch_credits: stop masking genuine records read/parse
  failures as 'no records' (NotFound is already softened inside
  read_local_trace_records_for_scope); report RecordsReadFailed, mirroring
  dispatch_status.
- runtime.rs trace-queue polling helper: fail loud on per-ENTRY read_dir IO
  errors too (map + unwrap_or_else panic) instead of filter_map(e.ok()), so a
  broken entry can't be silently dropped while claiming the queue holds one.
- local_dev_authorization approval-gate test: assert the effects DO require
  approval without the exemption (local_dev_effects_require_approval), so the
  test can't pass via a non-gating default policy if the TOML exemption were
  dropped.

* fix(traces): address Henri review — backend findings (atomic token, error mapping, validation, egress test)

- persist_profile_token now writes atomically (unique 0600 temp + fsync +
  rename) so a reader never observes a half-written or overwritten bearer
  credential under overlapping mints (Henri perf/security Medium).
- dispatch_onboard error mapping: OnboardError::DeviceKey is reported as a
  distinct DeviceKeyError (re-run onboarding) instead of being collapsed into
  PersistError's check-disk-and-permissions guidance (Henri bugs Medium).
- parse_profile_set_input enforces the manifest's declared schema at parse
  time: handle 3-32 ASCII letters/digits/-/_, bio <= 280 bytes (Henri
  conventions Medium). Added schema-limit test.
- Added dispatch_onboard_confirmed_without_host_egress_is_network_denied
  covering the NetworkDenied host-egress-miswiring branch (Henri tests Medium).

* fix(traces): address Henri review — frontend findings (enrolled empty-state + polling)

- v1 credits: TraceCreditResponse now carries `enrolled` (read from the
  standing policy), and settings.js keys the opt-in empty state on
  `!data.enrolled` instead of `!submissions_total` — an enrolled user with
  zero submissions now sees their zero-credit view, not the not-enrolled
  prompt (Henri bugs Medium).
- useTraceCredits: each fetch rebuilds the full server-side credit view, so
  the aggressive 60s poll made an open tab steady O(history) work. Relaxed to
  a 5-min interval + staleTime + no background polling, keeping a focus
  refetch for liveness; mutation invalidation still updates promptly. Added a
  TODO to incrementalize the server-side view (Henri perf Medium).

* perf(traces): memoize server-side credit view by on-disk input signature

Bounds the trace-credits polling cost to O(new submissions) instead of
O(total history). New scoped_credit_view(scope) caches the computed credit
report + manual-review holds keyed by a cheap change signature (submissions
file mtime+len, plus a hash of the held-trace sidecars). On the steady-state
polling case (unchanged history) a request is a couple of stat()s + a clone
rather than reading/parsing the full submissions file and re-aggregating.
On any change the signature differs and it recomputes once. Cache is bounded
(4096 scopes, cleared on overflow).

Wired through the polled WebUI path (local_trace_credits_for_user) and the
model-visible credits capability (dispatch_credits). Added
scoped_credit_view_reflects_record_changes_via_signature covering the
cache-hit path and signature-based invalidation on record changes.

Completes the TODO from the Henri perf-review follow-up (#5).

* fix(traces): gate profile_set behind runtime approval (Henri #1 High)

profile_set publishes a public community profile (an external write to a
public surface). Its `confirmed=true` input is model-controlled, so a
prompt-injected or confused model could supply it. Make the runtime
approval gate the primary, user-controlled consent control:

- Drop `builtin.trace_commons.profile_set` from the local-dev
  approval-gate exemption list (keep `onboard`, which runs its own
  in-turn confirmed=true consent before the network POST).
- Set profile_set's manifest default_permission to Ask (was Allow).
- Split the local-dev authorization test into
  `local_dev_trace_commons_profile_set_requires_approval_gate` (asserts
  Decision::RequireApproval) and
  `local_dev_trace_commons_onboard_skips_approval_gate` (asserts
  Decision::Allow), via a shared `trace_commons_authorize_decision`
  helper that first asserts the effects would gate without an exemption.

Also fix a pre-existing trace_commons harness gap: onboard + profile_token
gained a WriteFilesystem effect (device-key persistence) but the
`trace_commons_tools` harness allow-set was never updated, so those
capabilities were filtered out of the model-visible surface and the
parity/visibility tests failed with driver_unavailable. Grant
WriteFilesystem in the harness allow-set.

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

* refactor(traces): extract onboarding test harness to sibling file (Henri #8)

The onboarding module's ~840-line `#[cfg(test)] mod tests` block (mock
issuer harness, retry/idempotency coverage, URL-validation tests) made
`onboarding/mod.rs` a 1319-line file dominated by test scaffolding. Move
the module body into `onboarding/tests.rs` declared `#[cfg(test)] mod
tests;`, leaving mod.rs focused on production logic (now 480 lines). No
test behavior changes; `use super::*;` still resolves to the onboarding
module.

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

* test(webui-v2): update embedded-asset assertion for incrementalized credits poll

The Henri #5 polling fix changed useTraceCredits.js from refetchInterval
60_000 to 300_000 (plus refetchIntervalInBackground: false and
staleTime: 60_000), but the embedded-asset test in assets.rs still
asserted the old 60_000 value and failed in CI. Update the assertion to
lock the new infrequent-poll + paused-while-hidden + focus-refetch shape.

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

* fix(traces): address CodeRabbit review + stale capability-policy test

CodeRabbit findings on the gating/refactor commits:
- Major: format_profile_token returned the absolute host path of the token
  file (token_file) on the model-visible surface, which violates the
  "never expose absolute paths" guideline. Replace with an opaque
  token_delivery marker; the token is still persisted 0600 for out-of-band
  retrieval by a bearer-auth UI/CLI. Update the message + test accordingly.
- Major (fail-loud): profile_token_error_value and profile_set_error_value
  collapsed "could not read policy" into NotEnrolled, sending enrolled
  users back through onboarding on unreadable/corrupt state. Split into a
  distinct PolicyReadFailed result in both formatters (matches dispatch_status).
- Minor: stale comment claiming profile_set is approval-gate-exempt (it is
  now PermissionMode::Ask and NOT exempt) — corrected.
- Minor: inaccurate harness comments (profile_token writes profile_token.jwt
  not device-key material; yolo auto-approves all Trace Commons Ask-gated
  tools, not just onboard) — corrected.

Also fix bundled_local_dev_capability_policy_parses, which still asserted the
pre-gating policy shape: profile_set as exempt (now onboard exempt /
profile_set NOT exempt), onboard's grant missing the read/write filesystem
effects, and profile_token/profile_set sharing one effect-set assertion even
though profile_token now carries WriteFilesystem and profile_set does not.

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

* style(traces): collapse single-line use block after Path import removal

rustfmt collapses `use std::{panic, path::PathBuf, sync::Arc}` to one line
once Path was dropped; the prior commit skipped re-running fmt after that
edit, reddening the Formatting CI check.

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

* fix(traces): consent-gate profile_token + drop fixed-origin profile URL (CodeRabbit)

Two Major CodeRabbit security findings on the profile tools:

- profile_token minted and persisted a bearer credential with no in-turn
  consent gate. PermissionMode::Ask can be auto-approved under local-yolo, so
  a model call could mint a credential without explicit per-conversation
  consent. Add a hard confirmed=true gate (schema + parse + consent_required
  short-circuit) before minting, mirroring dispatch_onboard / dispatch_profile_set.
- format_profile_token and profile_set_success_value hardcoded
  https://tracecommons.ai/profile. The token is scoped to the user's ENROLLED
  issuer (which may be self-hosted or loopback), so steering the user to paste
  a bearer profile-management token at a fixed origin could leak it to the
  wrong host. Drop the fixed profile_url; route through the enrolled profile
  flow / local UI/CLI out of band.

Tests: new dispatch_profile_token_without_confirmed_returns_consent_required_no_mint;
existing without-enrollment test now passes confirmed=true; profile_set success
test asserts no fixed origin; parity step mints with confirmed=true.

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

* feat(traces): route agent-invoked profile writes through host egress (CodeRabbit #3)

profile_token (upload-claim mint) and profile_set (community-profile PUT/DELETE)
previously made network writes via the ironclaw_reborn_traces crate-local reqwest
client, bypassing the host RuntimeHttpEgress pipeline (private-IP filtering,
redaction, byte accounting) that onboard already uses.

Add a `ContributionHttpSink` port (mirroring `OnboardingHttpSink`): when a sink
is injected, the mint POST and the profile PUT/DELETE run through host egress;
when `None`, the existing hardened crate-local client is used unchanged.
host_runtime supplies `HostEgressContributionSink` (wraps RuntimeHttpEgress,
sanitizes errors via stable_runtime_reason, never leaks URL/token), and
dispatch_profile_token / dispatch_profile_set fail closed with NetworkDenied if
egress is absent (after the enrollment pre-check, so a not-enrolled user still
gets NotEnrolled guidance).

The background trace-upload / status-sync worker and the CLI keep the crate-local
client (pass `None`): that lane is a durable, model-input-free internal task that
sends only already-redacted envelopes to the operator-enrolled endpoint and does
its own SSRF/private-IP validation, so host egress adds complexity without
security benefit. Justification recorded in a comment on `trace_remote_http_client`.

New public surface: ContributionHttpSink/Request/Response/Error/Method,
mint_profile_attribution_token_for_scope_via_sink,
set_community_profile_for_scope_via_sink. Existing public fns keep their
signatures (None path) so CLI/worker/tests are unchanged.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
Co-authored-by: Firat Sertgoz <firatsertgoz@Firats-Mac-mini.local>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…tion (nearai#238)

* feat: add extension registry with metadata catalog, CLI, and onboarding integration

Adds a central registry that catalogs all 14 available extensions (10 tools,
4 channels) with their capabilities, auth requirements, and artifact references.
The onboarding wizard now shows installable channels from the registry and
offers tool installation as a new Step 7.

- registry/ folder with per-extension JSON manifests and bundle definitions
- src/registry/ module: manifest structs, catalog loader, installer
- `ironclaw registry list|info|install|install-defaults` CLI commands
- Setup wizard enhanced: channels from registry, new extensions step (8 steps)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(setup): resolve workspace errors for tool crates and channels-only onboarding

Tool crates in tools-src/ and channels-src/ failed `cargo metadata` during
onboard install because Cargo resolved them as part of the root workspace.
Add `[workspace]` table to each standalone crate and extend the root
`workspace.exclude` list so they build independently.

Channels-only mode (`onboard --channels-only`) failed with "Secrets not
configured" and "No database connection" because it skipped database and
security setup. Add `reconnect_existing_db()` to establish the DB connection
and load saved settings before running channel configuration.

Also improve the tunnel "already configured" display to show full provider
details (domain, mode, command) instead of just the provider name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(registry): address PR review feedback on installer and catalog

- Use manifest.name (not crate_name) for installed filenames so
  discovery, auth, and CLI commands all agree on the stem (#1)
- Add AlreadyInstalled error variant instead of misleading
  ExtensionNotFound (#2)
- Add DownloadFailed error variant with URL context instead of
  stuffing URLs into PathBuf (#3)
- Validate HTTP status with error_for_status() before reading
  response bytes in artifact downloads (#4)
- Switch build_wasm_component to tokio::process::Command with
  status() so build output streams to the terminal (#6)
- Find WASM artifact by crate_name specifically instead of picking
  the first .wasm file in the release directory (#7)
- Add is_file() guard in catalog loader to skip directories (#8)
- Detect ambiguous bare-name lookups when both tools/<name> and
  channels/<name> exist, with get_strict() returning an error (#9)
- Fix wizard step_extensions to check tool.name for installed
  detection, consistent with the new naming (#11, #12)
- Fix redundant closures and map_or clippy warnings in changed files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(setup): restore DB connection fields after settings reload

reconnect_postgres() and reconnect_libsql() called Settings::from_db_map()
which overwrote database_url / libsql_path / libsql_url set from env vars.
Also use get_strict() in cmd_info to surface ambiguous bare-name errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix clippy collapsible_if and print_literal warnings

Collapse nested if-let chains and inline string literals in format
macros to satisfy CI clippy lint checks (deny warnings).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(registry): prefer artifacts for install-defaults and improve dir lookup

- InstallDefaults now defaults to downloading pre-built artifacts
  (matching `registry install` behavior), with --build flag for source builds.
- find_registry_dir() walks up 3 ancestor levels from the exe and adds
  a CARGO_MANIFEST_DIR fallback, matching load_registry_catalog() logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* refactor: extract shared assertion helpers to support/assertions.rs

Move 5 assertion helpers from e2e_spot_checks.rs to a shared module.
Add assert_all_tools_succeeded and assert_tool_succeeded for eliminating
false positives in E2E tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add tool output capture via tool_results() accessor

Extract (name, preview) from ToolResult status events in TestChannel
and TestRig, enabling content assertions on tool outputs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct tool parameters in 3 broken trace fixtures

- tool_time.json: add missing "operation": "now" for time tool
- robust_correct_tool.json: same fix
- memory_full_cycle.json: change "path" to "target" for memory_write

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add tool success and output assertions to eliminate false positives

Every E2E test that exercises tools now calls assert_all_tools_succeeded.
Added tool output content assertions where tool results are predictable
(time year, read_file content, memory_read content).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: capture per-tool timing from ToolStarted/ToolCompleted events

Record Instant on ToolStarted and compute elapsed duration on
ToolCompleted, wiring real timing data into collect_metrics() instead
of hardcoded zeros.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: add RAII CleanupGuard for temp file/dir cleanup in tests

Replace manual cleanup_test_dir() calls and inline remove_file() with
Drop-based CleanupGuard that ensures cleanup even if a test panics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add Drop impl and graceful shutdown for TestRig

Wrap agent_handle in Option so Drop can abort leaked tasks. Signal
the channel shutdown before aborting for future cooperative shutdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace agent startup sleep with oneshot ready signal

Use a oneshot channel fired in Channel::start() instead of a fixed
100ms sleep, eliminating the race condition on slow systems.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: replace fragile string-matching iteration limit with count-based detection

Use tool completion count vs max_tool_iterations instead of scanning
status messages for "iteration"/"limit" substrings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use assert_all_tools_succeeded for memory_full_cycle test

Remove incorrect comment about memory_tree failing with empty path
(it actually succeeds). Omit empty path from fixture and use the
standard assert_all_tools_succeeded instead of per-tool assertions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: promote benchmark metrics types to library code

Move TraceMetrics, ScenarioResult, RunResult, MetricDelta, and
compare_runs() from tests/support/metrics.rs to src/benchmark/metrics.rs.
Existing tests use re-export for backward compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Scenario and Criterion types for agent benchmarking

Scenario defines a task with input, success criteria, and resource
limits. Criterion is an enum of programmatic checks (tool_used,
response_contains, etc.) evaluated without LLM judgment.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add initial benchmark scenario suite (12 scenarios across 5 categories)

Scenarios cover tool_selection, tool_chaining, error_recovery,
efficiency, and memory_operations. All loaded from JSON with
deserialization validation test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add benchmark runner with BenchChannel and InstrumentedLlm

BenchChannel is a minimal Channel implementation for benchmarks.
InstrumentedLlm wraps any LlmProvider to capture per-call metrics.
Runner creates a fresh agent per scenario, evaluates success criteria,
and produces RunResult with timing, token, and cost metrics.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add baseline management, reports, and benchmark entry point

- baseline.rs: load/save/promote benchmark results
- report.rs: format comparison reports with regression detection
- benchmark_runner.rs: integration test with real LLM (feature-gated)
- Add benchmark feature flag to Cargo.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: apply cargo fmt to benchmark module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add multi-turn scenario types with setup, judge, ResponseNotContains

Add BenchScenario, Turn, TurnAssertions, JudgeConfig, ScenarioSetup,
WorkspaceSetup, SeedDocument types for multi-turn benchmark scenarios.
Add ResponseNotContains criterion variant. Add TurnAssertions::to_criteria()
converter for backward compat with existing evaluation engine.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add JSON scenario loader with recursive discovery and tag filter

Add load_bench_scenarios() for the new BenchScenario format with recursive
directory traversal and tag-based filtering. Create 4 initial trajectory
scenarios across tool-selection, multi-turn, and efficiency categories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): multi-turn runner with workspace seeding and per-turn metrics

Add run_bench_scenario() that loops over BenchScenario turns, seeds workspace
documents, collects per-turn metrics (tokens, tool calls, wall time), and
evaluates per-turn assertions. Add TurnMetrics to metrics.rs and
clear_for_next_turn() to BenchChannel.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add LLM-as-judge scoring with prompt formatting and score parsing

Create judge.rs with format_judge_prompt, parse_judge_score, and judge_turn.
Wire into run_bench_scenario for turns with judge config -- scores below
min_score fail the turn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add CLI subcommand (ironclaw benchmark)

Add BenchmarkCommand with --tags, --scenario, --no-judge, --timeout,
--update-baseline flags. Wire into Command enum and main.rs dispatch.
Feature-gated behind benchmark flag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): per-scenario JSON output with full trajectory

Add save_scenario_results() that writes per-scenario JSON files alongside
the run summary. Each scenario gets its own file with turn_metrics trajectory.
Update CLI to use new output format.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add ToolRegistry::retain_only and wire tool filtering in scenarios

Add a retain_only() method to ToolRegistry that filters tools down to a
given allowlist. Wire this into run_bench_scenario() so that when a
scenario specifies a tools list in its setup, only those tools are
available during the benchmark run. Includes two tests for the new
method: one verifying filtering works and one verifying empty input
is a no-op.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): wire identity overrides into workspace before agent start

Add seed_identity() helper that writes identity files (IDENTITY.md,
USER.md, etc.) into the workspace before the agent starts, so that
workspace.system_prompt() picks them up. Wire it into
run_bench_scenario() after workspace seeding. Include a test that
verifies identity files are written and readable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add --parallel and --max-cost CLI flags

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(benchmark): use feature-conditional snapshot names for CLI help tests

Prevents snapshot conflicts between default (no benchmark) and
all-features (with benchmark) builds by using separate snapshot names
per feature set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): parallel execution with JoinSet and budget cap enforcement

Replace sequential loop in run_all_bench() with parallel execution using
JoinSet + semaphore when config.parallel > 1. Add budget cap enforcement
that skips remaining scenarios when max_total_cost_usd is exceeded.
Track skipped count in RunResult.skipped_scenarios and display it in
format_report().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add tool restriction and identity override test scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: fix formatting for Phase 3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add SkillRegistry::retain_only and wire skill filtering in scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(benchmark): add --json flag for machine-readable output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: add GitHub Actions benchmark workflow (manual trigger)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(benchmark): remove in-tree benchmark harness, keep retain_only utilities

Move benchmark-specific code out of ironclaw in preparation for the
nearai/benchmarks trajectory adapter. This removes:

- src/benchmark/ (runner, scenarios, metrics, judge, report, etc.)
- src/cli/benchmark.rs and the Benchmark CLI subcommand
- benchmarks/ data directory (scenarios + trajectories)
- .github/workflows/benchmark.yml
- The "benchmark" Cargo feature flag

What remains:
- ToolRegistry::retain_only() and SkillRegistry::retain_only()
- Test support types (TraceMetrics, InstrumentedLlm) inlined into
  tests/support/ instead of re-exporting from the deleted module

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for LLM trace fixture format

Documents the trajectory JSON format, response types, request hints,
directory structure, and how to write new traces.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(test): unify trace format around turns, add multi-turn support

Introduce TraceTurn type that groups user_input with LLM response steps,
making traces self-contained conversation trajectories. Add run_trace()
to TestRig for automatic multi-turn replay. Backward-compatible: flat
"steps" JSON is deserialized as a single turn transparently.

Includes all trace fixtures (spot, coverage, advanced), plan docs, and
new e2e tests for steering, error recovery, long chains, memory, and
prompt injection resilience.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): fix CI failures after merging main

- Fix tool_json fixture: use "data" parameter (not "input") to match
  JsonTool schema
- Fix status_events test: remove assertion for "time" tool that isn't
  in the fixture (only "echo" calls are used)
- Allow dead_code in test support metrics/instrumented_llm modules
  (utilities for future benchmark tests)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Working on recording traces and testing them

* feat(test): add declarative expects to trace fixtures, split infra tests

Add TraceExpects struct with 9 optional assertion fields (response_contains,
tools_used, all_tools_succeeded, etc.) that can be declared in fixture JSON
instead of hand-written Rust. Add verify_expects() and run_recorded_trace()
so recorded trace tests become one-liners.

Split trace infra tests (deserialization, backward compat) into
tests/trace_format.rs which doesn't require the libsql feature gate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(test): add expects to all trace fixtures, simplify e2e tests

Add declarative expects blocks to all 19 trace fixture JSONs across
spot/, coverage/, advanced/, and root directories. Update all 8 e2e
test files to use verify_trace_expects() / run_and_verify_trace(),
replacing ~270 lines of hand-written assertions with fixture-driven
verification.

Tests that check things beyond expects (file content on disk, metrics,
event ordering) keep those extra assertions alongside the declarative
ones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): adapt tests to AppBuilder refactor, fix formatting

Update test files to work with refactored TestRigBuilder that uses
AppBuilder::build_all() (removing with_tools/with_workspace methods).
Update telegram_check fixture to use tool_list instead of echo.
Fix cargo fmt issues in src/llm/mod.rs and src/llm/recording.rs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(test): deduplicate support unit tests into single binary

Support modules (assertions, cleanup, test_channel, test_rig, trace_llm)
had #[cfg(test)] mod tests blocks that were compiled and run 12 times —
once per e2e test binary that declares `mod support;`. Extracted all 29
support unit tests into a dedicated `tests/support_unit_tests.rs` so they
run exactly once.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix trailing newlines in support files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(test): unify trace types and fix recorded multi-turn replay

Import shared types (TraceStep, TraceResponse, TraceToolCall, RequestHint,
ExpectedToolResult, MemorySnapshotEntry, HttpExchange*) from
ironclaw::llm::recording instead of redefining them in trace_llm.rs.

Fix the flat-steps deserializer to split at UserInput boundaries into
multiple turns, instead of filtering them out and wrapping everything
into a single turn. This enables recorded multi-turn traces to be
replayed as proper multi-turn conversations via run_trace().

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): fix CI failures - unused imports and missing struct fields

- Add #[allow(unused_imports)] on pub use re-exports in trace_llm.rs
  (types are re-exported for downstream test files, not used locally)
- Add `..` to ToolCompleted pattern in test_channel.rs to match new
  `error` and `parameters` fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): fix CI failures after merging main

- Add missing `error` and `parameters` fields to ToolCompleted
  constructors in support_unit_tests.rs
- Add `..` to ToolCompleted pattern match in support_unit_tests.rs
- Add #[allow(dead_code)] to CleanupGuard, LlmTrace impl, and
  TraceLlm impl (only used behind #[cfg(feature = "libsql")])

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Adding coverage running script

* fix(test): address review feedback on E2E test infrastructure

- Increase wait_for_responses polling to exponential backoff (50ms-500ms)
  and raise default timeout from 15s to 30s to reduce CI flakiness (#1)
- Strengthen prompt_injection_resilience test with positive safety layer
  assertion via has_safety_warnings(), enable injection_check (#2)
- Add assert_tool_order() helper and tools_order field in TraceExpects
  for verifying tool execution ordering in multi-step traces (#3)
- Document TraceLlm sequential-call assumption for concurrency (#6)
- Clean up CleanupGuard with PathKind enum instead of shotgun
  remove_file + remove_dir_all on every path (#8)
- Fix coverage.sh: default to --lib only, fix multi-filter syntax,
  add COV_ALL_TARGETS option
- Add coverage/ to .gitignore
- Remove planning docs from PR

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review - use HashSet in retain_only, improve skill test

- Use HashSet for O(N+M) lookup in SkillRegistry::retain_only and
  ToolRegistry::retain_only instead of linear scan
- Strengthen test_retain_only_empty_is_noop in SkillRegistry to
  pre-populate with a skill before asserting the no-op behavior

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(test): revert incorrect safety layer assertion in injection test

The safety layer sanitizes tool output, not user input. The injection
test sends a malicious user message with no tools called, so the safety
layer never fires. Reverted to the original test which correctly
validates the LLM refuses via trace expects. Also fixed case-sensitive
request hint ("ignore" -> "Ignore") to suppress noisy warning.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: clean stale profdata before coverage run

Adds `cargo llvm-cov clean` before each run to prevent
"mismatched data" warnings from stale instrumentation profiles.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix formatting in retain_only test

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* test: add WIT compatibility tests for all WASM tools and channels

Adds CI and integration tests to catch WIT interface breakage across
all 14 WASM extensions (10 tools + 4 channels). Previously, changing
wit/tool.wit or wit/channel.wit could silently break guest-side tools
that weren't rebuilt until release time.

Three new pieces:

1. scripts/build-wasm-extensions.sh — builds all WASM extensions from
   source by reading registry manifests. Used by CI and locally.

2. tests/wit_compat.rs — integration tests that compile and instantiate
   each .wasm binary against the current wasmtime host linker with
   stubbed host functions. Catches added/removed/renamed WIT functions,
   signature mismatches, and missing exports. Skips gracefully when
   artifacts aren't built so `cargo test` still passes standalone.

3. .github/workflows/test.yml — new wasm-wit-compat CI job that builds
   all extensions then runs instantiation tests on every PR. Added to
   the branch protection roll-up.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix rustfmt formatting in wit_compat tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback on WIT compat tests

- Switch build script from python3 to jq for JSON parsing, consistent
  with release.yml and avoids python3 dependency (#1, #7)
- Use dirs::home_dir() instead of HOME env var for portability (#2)
- Filter extensions by manifest "kind" field instead of path (#3)
- Replace .flatten() with explicit error handling in dir iteration (#4, #5)
- Split stub_tool_host_functions into stub_shared_host_functions +
  tool-only tool-invoke stub, since tool-invoke is not in channel WIT (#6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
)

* feat: add inbound attachment support to WASM channel system

Add attachment record to WIT interface and implement inbound media
parsing across all four channel implementations (Telegram, Slack,
WhatsApp, Discord). Attachments flow from WASM channels through
EmittedMessage to IncomingMessage with validation (size limits,
MIME allowlist, count caps) at the host boundary.

- Add `attachment` record to `emitted-message` in wit/channel.wit
- Add `IncomingAttachment` struct to channel.rs and re-export
- Add host-side validation (20MB total, 10 max, MIME allowlist)
- Telegram: parse photo, document, audio, video, voice, sticker
- Slack: parse file attachments with url_private
- WhatsApp: parse image, audio, video, document with captions
- Discord: backward-compatible empty attachments
- Update FEATURE_PARITY.md section 7
- Add fixture-based tests per channel and host integration tests

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: integrate outbound attachment support and reconcile WIT types (nearai#409)

Reconcile PR nearai#409's outbound attachment work with our inbound attachment
support into a unified design:

WIT type split:
- `inbound-attachment` in channel-host: metadata-only (id, mime_type,
  filename, size_bytes, source_url, storage_key, extracted_text)
- `attachment` in channel: raw bytes (filename, mime_type, data) on
  agent-response for outbound sending

Outbound features (from PR nearai#409):
- `on-broadcast` WIT export for proactive messages without prior inbound
- Telegram: multipart sendPhoto/sendDocument with auto photo→document
  fallback for files >10MB
- wrapper.rs: `call_on_broadcast`, `read_attachments` from disk,
  attachment params threaded through `call_on_respond`
- HTTP tool: `save_to` param for binary downloads to /tmp/ (50MB limit,
  path traversal protection, SSRF-safe redirect following)
- Message tool: allow /tmp/ paths for attachments alongside base_dir
- Credential env var fallback in inject_channel_credentials

Channel updates:
- All 4 channels implement on_broadcast (Telegram full, others stub)
- Telegram: polling_enabled config, adjusted poll timeout
- Inbound attachment types renamed to InboundAttachment in all channels

Tests: 1965 passing (9 new), 0 clippy warnings

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add audio transcription pipeline and extensible WIT attachment design

Add host-side transcription middleware (OpenAI Whisper) that detects audio
attachments with inline data on incoming messages and transcribes them
automatically. Refactor WIT inbound-attachment to use extras-json and a
store-attachment-data host function instead of typed fields, so future
attachment properties (dimensions, codec, etc.) don't require WIT changes
that invalidate all channel plugins.

- Add src/transcription/ module: TranscriptionProvider trait,
  TranscriptionMiddleware, AudioFormat enum, OpenAI Whisper provider
- Add src/config/transcription.rs: TRANSCRIPTION_ENABLED/MODEL/BASE_URL
- Wire middleware into agent message loop via AgentDeps
- WIT: replace data + duration-secs with extras-json + store-attachment-data
- Host: parse extras-json for well-known keys, merge stored binary data
- Telegram: download voice files via store-attachment-data, add duration
  to extras-json, add /file/bot to HTTP allowlist, voice-only placeholder
- Add reqwest multipart feature for Whisper API uploads
- 5 regression tests for transcription middleware

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: wire attachment processing into LLM pipeline with multimodal image support

Attachments on incoming messages are now augmented into user text via XML tags
before entering the turn system, and images with data are passed as multimodal
content parts (base64 data URIs) to LLM providers. This enables audio transcripts,
document text, and image content to reach the LLM without changes to ChatMessage
serialization or provider interfaces.

- Add src/agent/attachments.rs with augment_with_attachments() and 9 unit tests
- Add ContentPart/ImageUrl types to llm::provider with OpenAI-compatible serde
- Carry image_content_parts transiently on Turn (skipped in serialization)
- Update nearai_chat and rig_adapter to serialize multimodal content
- Add 3 e2e tests verifying attachments flow through the full agent loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: CI failures — formatting, version bumps, and Telegram voice test

- Fix cargo fmt formatting in attachments.rs, nearai_chat.rs, rig_adapter.rs,
  e2e_attachments.rs
- Bump channel registry versions 0.1.0 → 0.2.0 (discord, slack, telegram,
  whatsapp) to satisfy version-bump CI check
- Fix Telegram test_extract_attachments_voice: add missing required `duration`
  field to voice fixture JSON

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: bump WIT channel version to 0.3.0, fix Telegram voice test, add pre-commit hook

- Bump wit/channel.wit package version 0.2.0 → 0.3.0 (interface changed with
  store-attachment-data)
- Update WIT_CHANNEL_VERSION constant and registry wit_version fields to match
- Fix Telegram test_extract_attachments_voice: gate voice download behind
  #[cfg(target_arch = "wasm32")] so host functions aren't called in native tests,
  update assertions for generated filename and extras_json duration
- Add @0.3.0 linker stubs in wit_compat.rs
- Add .githooks/pre-commit hook that runs scripts/check-version-bumps.sh when
  WIT or extension sources are staged
- Symlink commit-msg regression hook into .githooks/

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract voice download from extract_attachments into handle_message

Move download_voice_file + store_attachment_data calls out of
extract_attachments into a separate download_and_store_voice function
called from handle_message. This keeps extract_attachments as a pure
data-mapping function with no host calls, making it fully testable
in native unit tests without #[cfg(target_arch)] gates.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Add path validation to read_attachments (restrict to /tmp/) preventing
  arbitrary file reads from compromised tools
- Escape XML special characters in attachment filenames, MIME types, and
  extracted text to prevent prompt injection via tag spoofing
- Percent-encode file_id in Telegram getFile URL to prevent query injection
- Clone SecretString directly instead of expose_secret().to_string()

Correctness fixes:
- Fix store_attachment_data overwrite accounting: subtract old entry size
  before adding new to prevent inflated totals and false rejections
- Use max(reported, stored_size) for attachment size accounting to prevent
  WASM channels from under-reporting size_bytes to bypass limits
- Add application/octet-stream to MIME allowlist (channels default unknown
  types to this)

Code quality:
- Extract send_response helper in Telegram, deduplicating on_respond and
  on_broadcast
- Rename misleading Discord test to test_parse_slash_command_interaction
- Fix .githooks/commit-msg to use relative symlink (portable across machines)

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add tool_upgrade command + fix TOCTOU in save_to path validation

Add `tool_upgrade` — a new extension management tool that automatically
detects and reinstalls WASM extensions with outdated WIT versions.
Preserves authentication secrets during upgrade. Supports upgrading a
single extension by name or all installed WASM tools/channels at once.

Fix TOCTOU in `validate_save_to_path`: validate the path *before*
creating parent directories, so traversal paths like `/tmp/../../etc/`
cannot cause filesystem mutations outside /tmp before being rejected.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: unify WIT package version to 0.3.0 across tool.wit and all capabilities

tool.wit and channel.wit share the `near:agent` package namespace, so they
must declare the same version. Bumps tool.wit from 0.2.0 to 0.3.0 and
updates all capabilities files and registry entries to match.

Fixes `cargo component build` failure: "package identifier near:agent@0.2.0
does not match previous package name of near:agent@0.3.0"

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: move WIT file comments after package declaration

WIT treats `//` comments before `package` as doc comments. When both
tool.wit and channel.wit had header comments, the parser rejected them
as "doc comments on multiple 'package' items". Move comments after the
package declaration in both files.

Also bumps tool registry versions to 0.2.0 to match the WIT 0.3.0 bump.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: display extension versions in gateway Extensions tab

Add version field to InstalledExtension and RegistryEntry types, pipe
through the web API (ExtensionInfo, RegistryEntryInfo), and render as
a badge in the gateway UI for both installed and available extensions.

For installed WASM extensions, version is read from the capabilities
file with a fallback to the registry entry when the local file has no
version (old installations). Bump all extension Cargo.toml and registry
JSON versions from 0.1.0 to 0.2.0 to keep them in sync.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add document text extraction middleware for PDF, Office, and text files

Extract text from document attachments (PDF, DOCX, PPTX, XLSX, RTF, plain text,
code files) so the LLM can reason about uploaded documents. Uses pdf-extract for
PDFs, zip+XML parsing for Office XML formats, and UTF-8 decode for text files.
Wired into the agent loop after transcription middleware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: download document files in Telegram channel for text extraction

The DocumentExtractionMiddleware needs file bytes in the attachment `data`
field, but only voice files were being downloaded. Document attachments
(PDFs, DOCX, etc.) had empty `data` and a source_url with a credential
placeholder that only works inside the WASM host's http_request.

Add `download_and_store_documents()` that downloads non-voice, non-image,
non-audio attachments via the existing two-step getFile→download flow and
stores bytes via `store_attachment_data` for host-side extraction.

Also rename `download_voice_file` → `download_telegram_file` since it's
generic for any file_id.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: allow Office MIME types and increase file download limit for Telegram

Two issues preventing document extraction from Telegram:

1. PPTX/DOCX/XLSX MIME types (application/vnd.*) were dropped by the
   WASM host attachment allowlist — add application/vnd., application/msword,
   and application/rtf prefixes.

2. Telegram file downloads over 10 MB failed with "Response body too large" —
   set max_response_bytes to 20 MB in Telegram capabilities.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: report document extraction errors back to user instead of silently skipping

- Bump max_response_bytes to 50 MB for Telegram file downloads
- When document extraction fails (too large, download error, parse error),
  set extracted_text to a user-friendly error message instead of leaving it
  None. This ensures the LLM tells the user what went wrong.
- On Telegram download failure, set extracted_text with the error so the
  user sees feedback even when the file never reaches the extraction middleware.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: store extracted document text in workspace memory for search/recall

After document extraction succeeds, write the extracted text to workspace
memory at `documents/{date}/{filename}`. This enables:
- Full-text and semantic search over past uploaded documents
- Cross-conversation recall ("what did that PDF say?")
- Automatic chunking and embedding via the workspace pipeline

Documents are stored with metadata header (uploader, channel, date, MIME type).
Error messages (extraction failures) are not stored — only successful extractions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: CI failures — formatting, unused assignment warning

- Run cargo fmt on document_extraction and agent_loop modules
- Suppress unused_assignments warning on trace_llm_ref (used only
  behind #[cfg(feature = "libsql")])

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review comments — security, correctness, and code quality

Security fixes:
- Remove SSRF-prone download() from DocumentExtractionMiddleware (#13)
- Sanitize filenames in workspace path to prevent directory traversal (#11)
- Pre-check file size before reading in WASM wrapper to prevent OOM (#2)
- Percent-encode file_id in Telegram source URLs (#7)

Correctness fixes:
- Clear image_content_parts on turn end to prevent memory leak (#1)
- Find first *successful* transcription instead of first overall (#3)
- Enforce data.len() size limit in document extraction (#10)
- Use UTF-8 safe truncation with char_indices() (#12)

Robustness & code quality:
- Add 120s timeout to OpenAI Whisper HTTP client (#5)
- Trim trailing slash from Whisper base_url (#6)
- Allow ~/.ironclaw/ paths in WASM wrapper (#8)
- Return error from on_broadcast in Slack/Discord/WhatsApp (#9)
- Fix doc comment in HTTP tool (#4)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: formatting — cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address latest PR review — doc comments, error messages, version bumps

- Fix DocumentExtractionMiddleware doc comment (no longer downloads from source_url)
- Fix error message: "no inline data" instead of "no download URL"
- Log error + fallback instead of silent unwrap_or_default on Whisper HTTP client
- Bump all capabilities.json versions from 0.1.0 to 0.2.0 to match Cargo.toml

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove unsupported profile: minimal from CI workflows [skip-regression-check]

dtolnay/rust-toolchain@stable does not accept the 'profile' input
(it was a parameter for the deprecated actions-rs/toolchain action).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: merge with latest main — resolve compilation errors and PR review nits

- Add version: None to RegistryEntry/InstalledExtension test constructors
- Fix MessageContent type mismatches in nearai_chat tests (String → MessageContent::Text)
- Fix .contains() calls on MessageContent — use .as_text().unwrap()
- Remove redundant trace_llm_ref = None assignment in test_rig
- Check data size before clone in document extraction to avoid unnecessary allocation

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* feat: full image support across all channels

End-to-end image handling: upload, generation, analysis, editing, and
rendering across web gateway, HTTP webhook, WASM (Telegram/Slack), and
REPL channels. Builds on the attachment infrastructure from nearai#596 and
draws inspiration from PR nearai#641's image pipeline approach — credit to
that PR's author for the sentinel JSON pattern and base64-in-JSON
upload design.

Key changes:
- Image upload in web UI (file picker, paste, preview strip)
- Image generation tool (FLUX/DALL-E via /v1/images/generations)
- Image edit tool (multipart /v1/images/edits with fallback)
- Image analysis tool (vision model for workspace images)
- Model detection utilities (image_models.rs, vision_models.rs)
- Sentinel JSON detection in dispatcher for generated image rendering
- StatusUpdate::ImageGenerated → SSE/WS/REPL/WASM broadcast
- HTTP webhook attachment support (base64, 5MB/file, 10MB total)
- WASM channel image download (Telegram via file API, Slack via host HTTP)
- Tool registration wiring in app.rs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR nearai#725 review comments (16 issues)

- SecretString for API keys in all image tools (image_gen, image_edit, image_analyze)
- Binary image read via tokio::fs::read instead of DB-backed workspace.read()
- Replace Arc<Workspace> with Option<PathBuf> base_dir (workspace has no filesystem API)
- ApprovalRequirement::UnlessAutoApproved for cost-sensitive image tools
- Scope sentinel detection to image_generate/image_edit tool names only
- Skip ToolResult preview broadcast for image sentinels (avoids multi-MB base64 in SSE)
- Extract shared media_type_from_path() to builtin/mod.rs
- Rename fallback_chat_edit → fallback_generate with tracing::warn
- Increase gateway body limit from 1MB to 10MB for image uploads
- Increase webhook body limit to 15MB (base64 overhead)
- Log warning on invalid base64 in images_to_attachments
- Client-side image size limits (5MB/file, 5 images max) in app.js
- aria-label on attach button for accessibility
- Update body_too_large test for new 10MB limit

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add Slack file size check before download (PR review item #15)

Skip downloading files larger than 20 MB in the Slack WASM channel to
avoid excessive memory use and slow downloads in the WASM runtime.
Logs a warning when a file is skipped. Also bumps channel versions
for Slack and Telegram (prior branch changes).

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): add path validation and approval requirement to image tools

Add sandbox path validation via validate_path() to both ImageAnalyzeTool
and ImageEditTool to prevent path traversal attacks that could exfiltrate
arbitrary files through external vision/edit APIs. Also fix
ImageAnalyzeTool::requires_approval to return UnlessAutoApproved,
consistent with ImageEditTool and ImageGenerateTool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: post-download size guards and empty data_url sentinel check

- Slack: add post-download size check on actual bytes when metadata
  size_bytes is absent, preventing bypass of the 20MB limit
- Telegram: add 20MB download size limit (matching Slack) enforced
  in download_telegram_file() after receiving response bytes
- Dispatcher: skip broadcasting ImageGenerated SSE event when
  data_url is empty from unwrap_or_default(), log warning instead

Closes correctness issues #3, #4, #5 from PR nearai#725 review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use mime_guess for media type detection, add alt attrs and media_type validation

- Replace hardcoded media type mapping with mime_guess crate (already in deps)
- Add alt attributes to img elements in web UI for accessibility
- Validate media_type starts with "image/" in images_to_attachments()
- Update bmp test assertion to match mime_guess behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Zaki <zaki@iqlusion.io>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* fix: restore libSQL vector search with dynamic embedding dimensions (nearai#655)

The V9 migration dropped the libsql_vector_idx and changed
memory_chunks.embedding from F32_BLOB(1536) to BLOB, but the
documented brute-force cosine fallback was never implemented.
hybrid_search silently returned empty vector results — search was
FTS5-only on libSQL.

Add ensure_vector_index() which dynamically creates the vector index
with the correct F32_BLOB(N) dimension, inferred from EMBEDDING_DIMENSION
/ EMBEDDING_MODEL env vars during run_migrations(). Uses _migrations
version=0 as a metadata row to track the current dimension (no-op if
unchanged, rebuilds table on dimension change).

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

* style: move safety comments above multi-line assertions for rustfmt stability

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

* refactor: remove unnecessary safety comments from test code

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

* fix: address review comments from PR nearai#1393 [skip-regression-check]

- Share model→dimension mapping via config::embeddings::default_dimension_for_model()
  instead of duplicating the match table (zmanian, Copilot)
- Add dimension bounds check (1..=65536) to prevent overflow (zmanian, Copilot)
- DROP stale memory_chunks_new before CREATE to handle crashed previous attempts
  (zmanian, Copilot)
- Use plain INSERT instead of INSERT OR IGNORE to surface constraint errors
  (Copilot)

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

* fix: add missing builder field to AgentDeps in telegram routing test [skip-regression-check]

The self-repair builder field was added to AgentDeps in nearai#712 but this
test was not updated.

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

* fix: address zmanian's second review on PR nearai#1393

- Add tracing::info when resolve_embedding_dimension returns None (#2)
- Document connection scoping for transaction safety (#1)
- Document _rowid preservation for FTS5 consistency (#4)
- Document precondition that migrations must run first (#5)
- Note F32_BLOB dimension enforcement in insert_chunk (#3)
- Add unit tests for resolve_embedding_dimension (#6)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* feat: port NPA psychographic profiling system into IronClaw

Port the complete psychographic profiling system from NPA into IronClaw,
including enriched profile schema, conversational onboarding, profile
evolution, and three-tier prompt augmentation.

Personal onboarding moved from wizard Step 9 to first assistant
interaction per maintainer feedback — the First Contact system prompt
block now instructs the LLM to conduct a natural onboarding conversation
that builds the psychographic profile via memory_write.

Changes:
- Enrich profile.rs with 5 new structs, 9-dimension analysis framework,
  custom deserializers for backward compatibility, and rendering methods
- Add conversational onboarding engine with one-step-removed questioning
  technique, personality framework, and confidence-scored profile generation
- Add profile evolution with confidence gating, analysis metadata tracking,
  and weekly update routine
- Replace thin interaction style injection with three-tier system gated on
  confidence > 0.6 and profile recency
- Replace wizard Step 9 with First Contact system prompt block that drives
  conversational onboarding during the user's first interaction
- Add autonomy progression to SOUL.md seed and personality framework to
  AGENTS.md seed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: replace chat-based onboarding with bootstrap greeting and workspace seeds

Remove the interactive onboarding_chat.rs engine in favor of a simpler
bootstrap flow: fresh workspaces get a proactive LLM greeting that
naturally profiles the user. Identity files are now seeded from
src/workspace/seeds/ instead of being hardcoded. Also removes the
identity-file write protection (seeds are now managed), adds routine
advisor integration, and includes an e2e trace for bootstrap greeting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(safety): sanitize identity file writes via Sanitizer to prevent prompt injection

Identity files (SOUL.md, AGENTS.md, USER.md, IDENTITY.md) are injected into
every system prompt. Rather than hard-blocking writes (which broke onboarding),
scan content through the existing Sanitizer and reject writes with High/Critical
severity injection patterns. Medium/Low warnings are logged but allowed.

Also clarifies AGENTS.md identity file roles (USER.md = user info, IDENTITY.md =
agent identity) and adds IDENTITY.md setup as an explicit bootstrap step.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: update profile_onboarding_completed comment to reflect current wiring

The field is now actively used by the agent loop to suppress BOOTSTRAP.md
injection — remove the stale "not yet wired" TODO.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(setup): use env_or_override for NEARAI_API_KEY in model fetch config

When the user authenticates via NEAR AI Cloud API key (option 4),
api_key_login() stores the key via set_runtime_env(). But
build_nearai_model_fetch_config() was using std::env::var() which
doesn't check the runtime overlay — so model listing fell back to
session-token auth and re-triggered the interactive NEAR AI
authentication menu.

Switch to env_or_override() which checks both real env vars and the
runtime overlay.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(agent): correct channel/user_id in bootstrap greeting persist call

persist_assistant_response was called with channel="default",
user_id="system" but the assistant thread was created via
get_or_create_assistant_conversation("default", "gateway") which owns
the conversation as user_id="default", channel="gateway". The mismatch
caused ensure_writable_conversation to reject the write with:

  WARN Rejected write for unavailable thread id user=system channel=default

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): remove all inline event handlers for CSP compliance

The Content-Security-Policy header (added in 110d219) blocks inline JS
via script-src 'self'. All onclick/onchange attributes in index.html
are replaced with getElementById().addEventListener() calls. Dynamic
inline handlers in app.js (jobs, routines, memory breadcrumb, code
blocks, TEE report) are replaced with data-action attributes and a
single delegated click handler on document.

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(agent): align bootstrap message user/channel and update fixture schema field

- Bootstrap IncomingMessage now uses ("default", "gateway") consistently
  with persist and session registration calls
- Update bootstrap_greeting.json fixture: schema_version → version to
  match current PROFILE_JSON_SCHEMA

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(safety): address PR review — expand injection scanning and harden profile sync

- BOOTSTRAP.md: fix target "profile" → "context/profile.json" so the
  write hits the correct path and triggers profile sync
- IDENTITY_FILES: add context/assistant-directives.md to the scanned
  set since it is also injected into the system prompt
- sync_profile_documents(): scan derived USER.md and assistant-directives
  content through Sanitizer before writing, rejecting High/Critical
  injection patterns
- profile_evolution_prompt(): wrap recent_messages_summary in <user_data>
  delimiters with untrusted-data instruction to mitigate indirect
  prompt injection
- routine-advisor skill: update cron examples from 6-field to standard
  5-field format for consistency with routine_create tool docs

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: cargo fmt

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(setup): detect env-provided LLM keys during quick-mode onboarding

Quick-mode wizard now checks LLM_BACKEND, NEARAI_API_KEY,
ANTHROPIC_API_KEY, and OPENAI_API_KEY env vars to pre-populate
the provider setting, so users aren't re-prompted for credentials
they already supplied. Also teaches setup_nearai() to recognize
NEARAI_API_KEY from env (previously only checked session tokens).

Includes web UI cleanup (remove duplicate event listeners) and
e2e test response count adjustment.

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

* fix(test): update routine_create_list to expect 7-field normalized cron

The cron normalizer now always expands to 7-field format, so the
stored schedule is "0 0 9 * * * *" not "0 0 9 * * *".

[skip-regression-check]

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

* feat(setup): skip LLM provider prompts when NEARAI_API_KEY is present

In quick mode, if NEARAI_API_KEY is set in the environment and the
backend was auto-detected as nearai, skip the interactive inference
provider and model selection steps. The API key is persisted to the
secrets store and a default model is set automatically.

Also simplify the static fallback model list for nearai to a single
default entry.

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

* fix: unify default model, static bootstrap greeting, and web UI cleanup

- Add DEFAULT_MODEL const and default_models() fallback list in
  llm/nearai_chat.rs; use from config, wizard, and .env.example so the
  default model is defined in one place
- Restore multi-model fallback list in setup wizard (was reduced to 1)
- Move BOOTSTRAP_GREETING to module-level const (out of run() body)
- Replace LLM-based bootstrap with static greeting (persist to DB before
  channels start, then broadcast — eliminates startup LLM call and race)
- Fix double env::var read for NEARAI_API_KEY in quick setup path
- Move thread sidebar buttons into threads-section-header (web UI)
- Remove orphaned .thread-sidebar-header CSS and fix double blank line
- Update bootstrap e2e test for static greeting (no LLM trace needed)

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

* fix(safety): move prompt injection scanning into Workspace write/append

Addresses PR nearai#927 review comments (#1, #3) — identity file write
protection and unsanitized profile fields in system prompt.

Instead of scanning at the tool layer (memory.rs) or the sync layer
(sync_profile_documents), injection scanning now lives in
Workspace::write() and Workspace::append() for all files that are
injected into the system prompt. This ensures every code path that
writes to these files is protected, including future ones.

- Add SYSTEM_PROMPT_FILES const and reject_if_injected() in workspace
- Add WorkspaceError::InjectionRejected variant
- Add map_write_err() in memory.rs to convert InjectionRejected to
  ToolError::NotAuthorized
- Remove redundant IDENTITY_FILES/Sanitizer from memory.rs
- Remove redundant sanitizer calls from sync_profile_documents()
- Move sanitization tests to workspace::tests
- Existing integration test (test_memory_write_rejects_injection)
  continues to pass through the new path

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

* style: cargo fmt

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

* fix: address Copilot review — merge marker order, orphan thread, stale fixture

- merge_profile_section: search for END marker after BEGIN position to
  avoid matching a stray END earlier in the file
- Bootstrap phase 2: use get_or_create_session + Thread::with_id instead
  of resolve_thread(None) to avoid creating an orphan thread
- setup_nearai: use env_or_override for NEARAI_API_KEY consistency with
  runtime overlay
- Delete orphaned bootstrap_greeting.json fixture (no test references it)
- Add test_merge_end_marker_must_follow_begin regression test

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

* style: cargo fmt

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

* style: fmt agent_loop.rs (CI stable rustfmt)

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

* fix: lazy-init sanitizer, check profile non-empty before skipping bootstrap

Address Copilot review:
- Use LazyLock<Sanitizer> to avoid rebuilding Aho-Corasick + regexes
  on every workspace write
- has_profile check now requires non-empty content, not just file
  existence, to prevent empty profile.json from suppressing onboarding
- Add seed_tests integration tests (libsql-backed) verifying:
  - Empty profile.json does not suppress BOOTSTRAP.md seeding
  - Non-empty profile.json correctly suppresses bootstrap for upgrades

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

* style: cargo fmt

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

* fix: duplicate language handler, empty LLM_BACKEND, test_rig style

Address Copilot review on PR nearai#927:
- Remove duplicate language-option click listeners (delegated
  data-action handler already covers them)
- Guard LLM_BACKEND env prefill against empty string to prevent
  suppressing API-key-based auto-detection
- Use destructured local `keep_bootstrap` instead of `self.keep_bootstrap`
  in test_rig for consistency after destructure

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

* fix: update stale BOOTSTRAP.md write-protection comment [skip-regression-check]

BOOTSTRAP.md is now in SYSTEM_PROMPT_FILES and gets injection scanning
on write. The old comment incorrectly stated it was not write-protected.

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

* fix: replace debug_assert panics with graceful error returns [skip-regression-check]

debug_assert! in execute_tool_with_safety and JobContext::transition_to
panicked in test builds before the graceful error path could run.
Existing tests (test_cancel_job_completed, test_execute_empty_tool_name_returns_not_found)
already cover these paths — they were the ones failing.

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

* fix: address Copilot review — schema label, env var check, path normalization, profile validation

1. Label ANALYSIS_FRAMEWORK and PROFILE_JSON_SCHEMA sections separately
   in bootstrap prompt so the LLM knows which blob is the target structure.

2. Wizard quick-mode backend auto-detection now rejects empty env vars
   (std::env::var().is_ok_and(|v| !v.is_empty())) to avoid selecting the
   wrong backend when e.g. NEARAI_API_KEY="" is set.

3. Normalize the target path before comparing with paths::PROFILE in
   memory_write so non-canonical variants like "context//profile.json"
   still trigger profile sync.

4. seed_if_empty now requires valid JSON parse of context/profile.json
   before treating it as a populated profile. Corrupted content no longer
   permanently suppresses bootstrap seeding.

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

* style: cargo fmt

* fix: address Copilot review — append scan, profile validation, env_or_override

1. Workspace::append() now scans the combined content (existing + new)
   for prompt injection, not just the appended chunk. Prevents split-
   injection evasion across multiple appends.

2. seed_if_empty() now deserializes into PsychographicProfile instead of
   serde_json::Value for profile validation. Stray/legacy JSON that
   doesn't match the expected schema no longer suppresses bootstrap.

3. Wizard quick-mode backend auto-detection now uses env_or_override()
   to honor runtime overlays and injected secrets. LLM_BACKEND value
   is trimmed before storage.

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

* test: add bootstrap_onboarding_clears_bootstrap E2E trace test

Exercises the full onboarding flow end-to-end:
1. Bootstrap greeting fires automatically on fresh workspace
2. User converses for 3 turns (name, tools, work style)
3. Agent writes psychographic profile to context/profile.json
4. Profile sync generates USER.md and assistant-directives.md
5. Agent writes IDENTITY.md (chosen persona)
6. Agent clears BOOTSTRAP.md via memory_write(target: "bootstrap")

Verifies:
- BOOTSTRAP.md is non-empty before onboarding, empty after
- bootstrap_completed flag is set
- Profile contains expected user data (name, profession, interests)
- USER.md contains profile-derived content (name, tone, profession)
- Assistant-directives.md references user and communication style
- IDENTITY.md contains agent's chosen persona name
- All memory_write calls succeed

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

* fix: address Copilot review — slash collapse, env_or_override, cron trim [skip-regression-check]

1. memory.rs path normalization now uses the same char-by-char loop as
   Workspace::normalize_path() to fully collapse consecutive slashes
   (e.g. "context///profile.json" → "context/profile.json").

2. Quick-mode NEARAI_API_KEY check (line 239) now uses env_or_override()
   consistently with the backend auto-detection block above it.

3. normalize_cron_expression() trims input before field counting so the
   passthrough branch (7+ fields) also strips whitespace.

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

---------

Co-authored-by: Jay Zalowitz <jayzalowitz@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* feat(agent): queue and merge messages during active turns

Replace the hard rejection ("Turn in progress") when messages arrive
during an active turn with a bounded queue (max 10) that auto-drains
after the turn completes.

Queued messages are merged with newlines into a single turn so the LLM
receives full context from rapid consecutive inputs instead of producing
fragmented responses from partial context.

Key changes:
- Thread.pending_messages (VecDeque) with queue_message/drain_pending_messages
- Drain loop in agent_loop.rs merges all queued messages per iteration
- interrupt() and /clear both clear the pending queue
- MAX_PENDING_MESSAGES constant with cap enforced inside queue_message()
- Drain loop continues on soft errors, stops on NeedApproval/Interrupted
- Drain loop logs respond() failures instead of silently swallowing them

Fixes nearai#259 — debounces rapid inbound messages during processing
Fixes nearai#826 — drain loop is bounded by MAX_PENDING_MESSAGES cap

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

* fix: address PR review — drain loop busy-loop guard and stale state re-check

- Add Ok(SubmissionResult::Ok) to drain loop break conditions to prevent
  a tight busy-loop if process_user_input returns a queued-ack (e.g. from
  a corrupted/hydrated session stuck in Processing state)
- Re-check thread.state under the mutable lock in the Processing arm to
  guard against the turn completing between the snapshot read and the
  queue operation

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

* fix: clear attachments on drain-loop queued message processing

Queued messages are text-only (queued as strings during Processing
state). The drain loop was reusing the original IncomingMessage
reference which carried the first message's attachments, causing
augment_with_attachments to incorrectly re-apply them to unrelated
queued text. Clone the message with cleared attachments for drain-loop
turns.

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

* fix: address PR review round 2 — stale state fallthrough and thread-not-found guard

- Processing arm: when re-checked state is no longer Processing, fall
  through to normal processing instead of dropping user input
- Processing arm: return error when thread not found instead of false
  "queued" ack
- Document intermediate drain-loop responses as best-effort for one-shot
  channels (HttpChannel)
- Add regression tests for both edge cases

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

* fix: address PR review feedback for message queue drain loop

[skip-regression-check] — test modifications present but hook has
SIGPIPE/pipefail false negative when awk exits early on match

- Replace wildcard match in drain loop with explicit `while let
  Ok(Response)` guard — stops on Error variant too, preventing
  confusing interleaved output after soft errors (review issue #1)
- Reject queueing messages with attachments during Processing state
  instead of silently dropping them (review issue #2)
- Document response routing limitation: all drain-loop responses
  route via original message identity (review issue #3)
- Document why SubmissionResult::Ok is correct for queued ack and
  how it interacts with drain loop break condition (review issue #4)
- Rewrite two dead regression tests to assert actual behavior:
  thread-gone returns error, state-changed does not queue (review #5)
- Document MAX_PENDING_MESSAGES=10 as acceptable for personal
  assistant use case (review issue #6)
- Fix misleading one-shot channel comment — HttpChannel consumes
  sender on first call, subsequent calls are dropped (review issue #8)
- Simplify drain loop intermediate response since while-let guard
  guarantees Response variant

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

* fix: add missing extension_manager field in webhook EngineContext

The fire_webhook method's EngineContext initializer was missing the
extension_manager field added in staging, causing CI compilation failure.

[skip-regression-check]

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

* fix: gate TestRig::session_manager() behind libsql feature flag

The field is #[cfg(feature = "libsql")] so the accessor must match.
All callers are already inside #[cfg(feature = "libsql")] blocks.

[skip-regression-check]

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

* fix: re-queue drained messages on drain loop failure

If process_user_input fails after drain_pending_messages() removed
all queued content, that user input was permanently lost. Now the
merged content is re-queued at the front of pending_messages on any
non-Response result so it will be processed on the next successful
turn.

Adds Thread::requeue_drained() helper and unit test.

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

* fix: remove unreachable!() from drain loop, add lock-drop comments

- Extract content binding in `while let` pattern instead of using a
  separate match with unreachable!() — satisfies the no-panic-in-
  production convention (zmanian review item #1)
- Add comment clarifying session lock is dropped at Processing arm
  boundary before fall-through (zmanian review item #5)
- Document bounded cap overshoot on requeue_drained (review item #2)

[skip-regression-check]

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

* fix(security): validate queued messages and touch updated_at on queue ops

- Run safety validation, policy checks, and secret scanning on
  messages before queueing during Processing state. Previously,
  content with leaked secrets could be stored in pending_messages
  and serialized without hitting the inbound scanner.
- Touch updated_at in queue_message(), drain_pending_messages(),
  and requeue_drained() so thread timestamps reflect queue activity.

[skip-regression-check] — safety validation requires full Agent;
updated_at is a data-level fix on existing tested methods

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…i-tenant isolation (nearai#1626)

* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling

Finishes the remaining isolation work from phases 2–4 of #59:

Phase 2 (DB scoping): Fix /status and /list commands to use _for_user
DB variants instead of global queries that leaked cross-user job data.

Phase 3 (Runtime isolation): Per-user workspace in routine engine's
spawn_fire so lightweight routines run in the correct user context.
Per-user daily cost tracking in CostGuard with configurable budget via
MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles
through all users with routines, auto-detected from GATEWAY_USER_TOKENS.

Phase 4 (Provider/tools): Per-user model selection via preferred_model
setting — looked up from SettingsStore on first iteration, threaded
through ReasoningContext.model_override to CompletionRequest. Works
with providers that support per-request model overrides (NearAI).

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

* fix: use selected_model setting key to match /model command persistence

The dispatcher was reading "preferred_model" but the /model command
(merged from staging) persists to "selected_model". Since set_setting
is already per-user scoped, using the same key makes /model work as
the per-user model override in multi-tenant mode.

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

* fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override

Three follow-up fixes for multi-tenant isolation:

1. Multi-user heartbeat now runs memory hygiene per user before each
   heartbeat check, matching single-user heartbeat behavior.

2. /model command in multi-tenant mode only persists to per-user
   settings (selected_model) without calling set_model() on the shared
   LlmProvider. The per-request model_override in the dispatcher reads
   from the same setting. Added multi_tenant flag to AgentConfig
   (auto-detected from GATEWAY_USER_TOKENS).

3. RigAdapter now supports per-request model overrides by injecting the
   model name into rig-core's additional_params. OpenAI/Anthropic/Ollama
   API servers use last-key-wins for duplicate JSON keys, so the override
   takes effect via serde's flatten serialization order.

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

* fix: address PR review — cost model attribution, heartbeat concurrency, pruning

Fixes from review comments on nearai#1614:

- Cost tracking now uses the override model name (not active_model_name)
  when a per-user model override is active, for accurate attribution.
- Multi-user heartbeat runs per-user checks concurrently via JoinSet
  instead of sequentially, preventing one slow user from blocking others.
- Per-user failure counts tracked independently; users exceeding
  max_failures are skipped (matching single-user semantics).
- per_user_daily_cost HashMap pruned on day rollover to prevent
  unbounded growth in long-lived deployments.
- Doc comment fixed: says "routines" not "active routines".

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

* fix: /status ownership, model persistence scoping, heartbeat robustness

Addresses second round of PR review on nearai#1614:

- /status <job_id> DB path now validates job.user_id == requesting user
  before returning data (was missing ownership check, security fix).

- persist_selected_model takes user_id param instead of owner_id, and
  skips .env/TOML writes in multi-tenant mode (these are shared global
  files). handle_system_command now receives user_id from caller.

- JoinSet collection handles Err(JoinError) explicitly instead of
  silently dropping panicked tasks.

- Notification forwarder extracts owner_id from response metadata in
  multi-tenant mode for per-user routing instead of broadcasting to
  the agent owner.

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

* fix: cost pricing, fire_manual workspace, heartbeat concurrency cap

Round 3 review fixes:

- Cost tracking passes None for cost_per_token when model override is
  active, letting CostGuard look up pricing by model name instead of
  using the default provider's rates (serrrfirat).

- fire_manual() now uses per-user workspace, matching spawn_fire()
  pattern (serrrfirat).

- Removed MULTI_TENANT env var — multi-tenant mode is auto-detected
  solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot).

- Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding
  the LLM provider (serrrfirat + Copilot).

- Fixed inject_model_override doc comment accuracy (Copilot).

- Added comment explaining multi-tenant notification routing priority
  (Copilot).

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

* feat: user-scoped webhook endpoint for multi-tenant isolation

Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook
endpoint that filters the routine lookup by user_id, preventing
cross-user webhook triggering when paths collide.

The existing /api/webhooks/{path} endpoint remains unchanged for
backward compatibility in single-user deployments.

Changes:
- get_webhook_routine_by_path gains user_id: Option<&str> param
- Both postgres and libsql implementations add AND user_id = ? filter
  when user_id is provided
- New webhook_trigger_user_scoped_handler extracts (user_id, path)
  from URL and passes to shared fire_webhook_inner logic
- Route registered on public router (webhooks are called by external
  services that can't send bearer tokens)

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

* feat(db): add UserStore trait with users, api_tokens, invitations tables

Foundation for DB-backed user management (nearai#1605):

- UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs
- UserStore sub-trait (17 methods) added to Database supertrait
- PostgreSQL migration V14__users.sql (users, api_tokens, invitations)
- libSQL schema + incremental migration V14
- Full implementations for both PgBackend (via Store delegation) and
  LibSqlBackend (direct SQL in libsql/users.rs)
- authenticate_token JOINs api_tokens+users with active/non-revoked
  checks; has_any_users for bootstrap detection

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

* feat(web): DB-backed auth, user/token/invitation API handlers

Adds the web gateway layer for DB-backed user management (nearai#1605):

Auth refactor:
- CombinedAuthState wraps env-var tokens (MultiAuthState) + optional
  DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL,
  1024 max entries)
- auth_middleware tries env-var tokens first, then DB fallback
- From<MultiAuthState> impl for backward compatibility
- main.rs wires with_db_auth when database is available

API handlers (12 new endpoints):
- /api/admin/users — CRUD: create, list, detail, update, suspend, activate
- /api/tokens — create (returns plaintext once), list, revoke
- /api/invitations — create, list, accept (creates user + first token)

Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored.
Invitation accept: validates hash + pending + not expired, creates
user record and first API token atomically.

All test files updated for CombinedAuthState type change.

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

* feat: startup env-var user migration + UserStore integration tests

Completes the DB-backed user management feature (nearai#1605):

- Startup migration: when GATEWAY_USER_TOKENS is set and the users
  table is empty, inserts env-var users + hashed tokens into DB.
  Logs deprecation notice when DB already has users.
- hash_token made pub for reuse in migration code.
- 10 integration tests for UserStore (libsql file-backed):
  - has_any_users bootstrap detection
  - create/get/get_by_email/list/update user lifecycle
  - token create → authenticate → revoke → reject cycle
  - suspended user tokens rejected
  - wrong-user token revoke returns false
  - invitation create → accept → user created
  - record_login and record_token_usage timestamps
- libSQL migration: removed FK constraints from V14 (incompatible
  with execute_batch inside transactions). Tables in both base SCHEMA
  and incremental migration for fresh and existing databases.

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

* refactor: remove GATEWAY_USER_TOKENS, fix review feedback

GATEWAY_USER_TOKENS never went to production — replaced entirely by
DB-backed user management via /api/admin/users and /api/tokens.

Removed:
- UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing
- user_tokens field from GatewayConfig
- GatewayChannel::new_multi_auth() constructor
- Env-var user migration block in main.rs (~90 lines)
- multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime
  via db.has_any_users() in app.rs)

Review fixes (zmanian):
- User ID generation: UUID instead of display-name derivation (#1)
- Invitation accept moved to public router (no auth needed) (#3)
- libSQL get_invitation_by_hash aligned with postgres: filters
  status='pending' AND expires_at > now (#4)
- UUID parse: returns DatabaseError::Serialization instead of
  unwrap_or_default (#7)
- PostgreSQL SELECT * replaced with explicit column lists (#8)
- Sort order aligned (both backends use DESC) (#6)

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

* feat: add role-based access control (admin/member)

Adds a `role` field (admin|member) to user management:

Schema:
- `role TEXT NOT NULL DEFAULT 'member'` added to users table in both
  PostgreSQL V14 migration and libSQL schema/incremental migration
- UserRecord gains `role: String` field
- UserIdentity gains `role: String` field, populated from DB in
  DbAuthenticator and defaulting to "admin" for single-user mode

Access control:
- AdminUser extractor: returns 403 Forbidden if role != "admin"
- /api/admin/users/* handlers: require AdminUser (create, list,
  detail, update, suspend, activate)
- POST /api/invitations: requires AdminUser (only admins can invite)
- User creation accepts optional "role" param (defaults to "member")
- Invitation acceptance creates users with "member" role

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

* feat(web): add Users admin tab to web UI

Adds a Users tab to the web gateway UI for managing users, tokens,
and roles without needing direct API calls.

Features:
- User list table with ID, name, email, role, status, created date
- Create user form with display name, email, role selector
- Suspend/activate actions per user
- Create API token for any user (shows plaintext once with copy button)
- Role badges (admin highlighted, member muted)
- Non-admin users see "Admin access required" message
- Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab

CSS:
- Reuses routines-table styles for the user list
- Badge, token-display, btn-small, btn-danger, btn-primary components

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

* fix: move Users to Settings subtab, bootstrap admin user on first run

- Moved Users from top-level tab to Settings sidebar subtab (under
  Skills, before Theme toggle)
- On first startup with empty users table, automatically creates an
  admin user from GATEWAY_USER_ID config with a corresponding API
  token from GATEWAY_AUTH_TOKEN. This ensures the owner appears in
  the Users panel immediately.

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

* fix: user creation shows token, + Token works, no password save popup

Three UI/UX fixes:

1. Create user now generates an initial API token and shows it in a
   copy-able banner instead of triggering the browser's password save
   dialog. Uses autocomplete="off" and type="text" for email field.

2. "+ Token" button works: exposed createTokenForUser/suspendUser/
   activateUser on window for inline onclick handlers in dynamically
   generated table rows. Token creation uses showTokenBanner helper.

3. Admin token creation: POST /api/tokens now accepts optional
   "user_id" field when the requesting user is admin, allowing
   token creation for other users from the Users panel.

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

* fix: use event delegation for user action buttons (CSP compliance)

Inline onclick handlers are blocked by the Content-Security-Policy
(script-src 'self' without 'unsafe-inline'). Switched to data-action
attributes with a delegated click listener on the users table.

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

* fix: add i18n for Users subtab, show login link on user creation

- Added 'settings.users' i18n key for English and Chinese
- Token banner now shows a full login link (domain/?token=xxx)
  with a Copy Link button, plus the raw token below
- Login link works automatically via existing ?token= auto-auth

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

* fix: token hash mismatch — hash hex string, not raw bytes

Critical auth bug: token creation hashed the raw 32 bytes
(hasher.update(token_bytes)) but authentication hashed the hex-encoded
string (hash_token(candidate) where candidate is the hex string the
user sends). This meant newly created tokens could never authenticate.

Fixed all 4 token creation sites (users, tokens, invitations create,
invitations accept) to use hash_token(&plaintext_token) which hashes
the hex string consistently with the auth lookup path.

Removed now-unused sha2::Digest imports from handlers.

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

* refactor: remove invitation system

The invitation flow is redundant — admin create user already generates
a token and shows a login link. Invitations add complexity without
value until email integration exists.

Removed:
- InvitationRecord struct and 4 UserStore trait methods
- invitations table from V14 migration (postgres + both libsql schemas)
- PostgreSQL Store methods (create/get/accept/list invitations)
- libSQL UserStore invitation methods + row_to_invitation helper
- invitations.rs handler file (212 lines)
- /api/invitations routes (create, list, accept)
- test_invitation_lifecycle test

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

* feat: user deletion, self-service profile, per-user job limits, usage API

Four multi-tenancy improvements:

1. User deletion cascade (DELETE /api/admin/users/{id}):
   Deletes user and all data across 11 user-scoped tables (settings,
   secrets, routines, memory, jobs, conversations, etc.). Admin only.

2. Self-service profile (GET/PATCH /api/profile):
   Users can read and update their own display_name and metadata
   without admin privileges.

3. Per-user job concurrency (MAX_JOBS_PER_USER env var):
   Scheduler checks active_jobs_for(user_id) before dispatch.
   Prevents one user from exhausting all job slots.

4. Usage reporting (GET /api/admin/usage?user_id=X&period=day|week|month):
   Aggregates LLM costs from llm_calls via agent_jobs.user_id.
   Returns per-user, per-model breakdown of calls, tokens, and cost.

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

* feat: add TenantCtx for compile-time tenant isolation

Implements zmanian's architectural proposal from nearai#1614 review:
two-tier scoped database access (TenantScope/AdminScope) so handler
code cannot accidentally bypass tenant scoping.

TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds
user_id on every operation. ID-based lookups return None for cross-
tenant resources. No escape hatch — forgetting to scope is a compile
error.

AdminScope (explicit opt-in): cross-tenant access for system-level
components (heartbeat, routine engine, self-repair, scheduler, worker).

TenantCtx bundles TenantScope + workspace + cost guard + per-user
rate limiting. Constructed once per request in handle_message, threaded
through all command handlers and ChatDelegate.

Key changes:
- New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx,
  TenantRateState, TenantRateRegistry
- All command handlers: user_id: &str → ctx: &TenantCtx
- ChatDelegate: cost check/record/settings via self.tenant
- System components: store field changed to AdminScope
- Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars
- Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered)

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

* fix: address PR nearai#1626 review feedback — bounded LRU cache, admin auth, FK cleanup

- Replace HashMap with lru::LruCache in DbAuthenticator so the token
  cache is hard-bounded at 1024 entries (evicts LRU, not just expired)
- Gate admin user endpoints (list/detail/update/suspend/activate) with
  AdminUser extractor so members get 403 instead of full access
- Add api_tokens to libSQL delete_user cleanup list to prevent orphaned
  tokens (libSQL has no FK cascade)
- Add regression tests for all three fixes

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

* fix: update CA certificates in runtime Docker image

Ensures the root certificate bundle is current so TLS handshakes
to services like Supabase succeed on Railway.

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

* fix: resolve CI failures — formatting, no-panics check

- Run cargo fmt on test code
- Replace .expect() with const NonZeroUsize in DbAuthenticator
- Add // safety: comments for test-only code in multi_tenant.rs

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

* fix: switch PostgreSQL TLS from rustls to native-tls

rustls with rustls-native-certs fails TLS handshake on Railway's
slim container (empty or stale root cert store). native-tls delegates
to OpenSSL on Linux which handles system certs more reliably.

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

* Adding user management api

* feat: admin secrets provisioning API + API documentation

- Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for
  application backends to provision per-user secrets (AES-256-GCM encrypted)
- Add secrets_store field to GatewayState with builder wiring
- Create docs/USER_MANAGEMENT_API.md with full API spec covering users,
  secrets, tokens, profile, and usage endpoints
- Update web gateway CLAUDE.md route table

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

* fix: add CatchPanicLayer to capture handler panics

Without this, panics in async handlers silently drop the connection
and the edge proxy returns a generic 503. Now panics are caught,
logged, and returned as 500 with the panic message.

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

* fix: address second-round review — transactional delete, overflow, error logging

- C1: Wrap PostgreSQL delete_user() in a transaction so partial cleanup
  can't leave users in a half-deleted state
- M2: Add job_events to delete cleanup (both backends) — FK to
  agent_jobs without CASCADE would cause FK violation
- H1/M4: Cap expires_in_days to 36500 before i64 cast (tokens + secrets)
- H2: Validate target user exists before creating admin token to prevent
  orphan tokens on libSQL
- H3: Log DB errors in DbAuthenticator::authenticate() instead of
  silently swallowing them as 401

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

* fix: revert to rustls with webpki-roots fallback for PostgreSQL TLS

native-tls/OpenSSL caused silent crashes (segfaults in C code) during
DB writes on Railway containers. Switch back to rustls but add
webpki-roots as a fallback when system certs are missing, which was
the original TLS handshake failure on slim container images.

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

* chore: update Cargo.lock for rustls + webpki-roots

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

* debug: add /api/debug/db-write endpoint to diagnose user insert failure

Temporary diagnostic endpoint that tests DB INSERT to users table
with full error logging. No auth required. Will be removed after
debugging.

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

* perf: use cargo-chef in Dockerfile for dependency caching

Splits the build into planner/deps/builder stages. Dependencies are
only recompiled when Cargo.toml or Cargo.lock change. Source-only
changes skip straight to the final build stage.

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

* debug: add tracing to users_create_handler

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

* fix: guard created_by FK in user creation handler

The auth identity user_id (from owner_id scope) may not match any
user row in the DB, causing a FK violation on the created_by column.
Check that the referenced user exists before setting created_by.

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

* refactor: collapse GATEWAY_USER_ID into IRONCLAW_OWNER_ID

Remove the separate GATEWAY_USER_ID config. The gateway now uses
IRONCLAW_OWNER_ID (config.owner_id) directly for auth identity,
bootstrap user creation, and workspace scoping.

Previously, with_owner_scope() rebinds the auth identity to owner_id
while keeping default_sender_id as the gateway user_id. This caused
a FK constraint violation when creating users because the auth
identity ("default") didn't match any user in the DB ("nearai").

Changes:
- Remove GATEWAY_USER_ID env var and gateway_user_id from settings
- Remove user_id field from GatewayConfig
- Add owner_id parameter to GatewayChannel::new()
- Remove with_owner_scope() method
- Remove default_sender_id from GatewayState
- Remove sender override logic in chat/approval handlers
- Remove debug endpoint and tracing from prior debugging
- Update all tests and E2E fixtures

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

* fix: hide Users tab for non-admins, remove auth hint text

- Fetch /api/profile after login and hide the Users settings tab
  when the user's role is not admin
- Remove the "Enter the GATEWAY_AUTH_TOKEN" hint from the login page
  since tokens are now managed via the admin panel, not .env files

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

* fix: address review feedback (auth 503, token expiry, CORS PATCH)

- DB auth errors now return 503 instead of 401 so outages are
  distinguishable from invalid tokens (serrrfirat H3)
- Cap expires_in_days to 36500 before i64 cast to prevent negative
  duration from u64 overflow (serrrfirat H1)
- Add PATCH to CORS allowed methods for profile/user update
  endpoints (Copilot)
- Stop leaking panic details in CatchPanicLayer response body

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

* fix: harden multi-tenant isolation — review fixes from nearai#1614

- Add conversation ownership checks in TenantScope: add_conversation_message,
  touch_conversation, list_conversation_messages (+ paginated),
  update_conversation_metadata_field, get_conversation_metadata now return
  NotFound for conversations not owned by the tenant (cross-tenant data leak)
- Fix multi-user heartbeat: clear notify_user_id per runner so notifications
  persist to the correct user, not the shared config target
- Move hygiene tasks into bounded JoinSet instead of unbounded tokio::spawn
- Revert send_notification to private visibility (only used within module)
- Use effective_model_name() for cost attribution in dispatcher so providers
  that ignore per-request model overrides report the actual model used
- Fix inject_model_override doc comment; add 3 unit tests
- Fix heartbeat doc comment ("routines" not "active routines")

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

* feat: add Jobs, Cost, Last Active columns to admin Users table

Add UserSummaryStats struct and user_summary_stats() batch query to the
UserStore trait (both PostgreSQL and libSQL backends). The admin users
list endpoint now fetches per-user aggregates (job count, total LLM
spend, most recent activity) in a single query and includes them inline
in the response. The frontend Users table displays three new columns.

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

* fix: address review comments and CI formatting failures

CI fixes:
- cargo fmt fixes in cli/mod.rs and db/tls.rs

Security/correctness (from Copilot + serrrfirat + pranavraja99 reviews):
- Token create: reject expires_in_days > 36500 with 400 instead of silent clamp
- Token create: return 404 when admin targets non-existent user
- User create: map duplicate email constraint violations to 409 Conflict
- User create: remove unnecessary DB roundtrip for created_by (use AdminUser directly)
- DB auth: log warn on DB lookup failures instead of silently swallowing errors
- libSQL: add FK constraints on users.created_by and api_tokens.user_id

Config fixes:
- agent.multi_tenant: resolve from AGENT_MULTI_TENANT env var instead of hardcoding false
- heartbeat.multi_tenant: fix doc comment to match actual env-var-based behavior

UI fix:
- showTokenBanner: pass correct title ("Token created!" vs "User created!")

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

* fix: address remaining review comments (round 2)

- Secrets handlers: normalize name to lowercase before store operations,
  validate target user_id exists (returns 404 if not found)
- libSQL: propagate cost parsing errors instead of unwrap_or_default()
  in both user_usage_stats and user_summary_stats
- users_list_handler: propagate user_summary_stats DB errors (was
  silently swallowed with unwrap_or_default)
- loadUsers: distinguish 401/403 (admin required) from other errors
- Docs: fix users.id type (TEXT not UUID), remove "invitation flow"
  from V14 migration comment

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

* feat: i18n for Users tab, atomic user+token creation, transactional delete_user

i18n:
- Add 31 translation keys for all Users tab strings (en + zh-CN)
- Wire data-i18n attributes on HTML elements (headings, buttons, inputs,
  table headers, empty state)
- Replace all hard-coded strings in app.js with I18n.t() calls

Atomic user+token creation:
- Add create_user_with_token() to UserStore trait
- PostgreSQL: wraps both INSERTs in conn.transaction() with auto-rollback
- libSQL: wraps in explicit BEGIN/COMMIT with ROLLBACK on error
- Handler uses single atomic call instead of two separate operations

Transactional delete_user for libSQL:
- Wrap multi-table DELETE cascade in BEGIN/COMMIT transaction
- ROLLBACK on any error to prevent partial cleanup / inconsistent state
- Matches the PostgreSQL implementation which already used transactions

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

* fix: revert V14 migration to match deployed checksum [skip-regression-check]

Refinery checksums applied migrations — editing V14__users.sql after
it was already applied causes deployment failures. Revert the cosmetic
comment changes (added in df40b22) to restore the original checksum.

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

* fix: bootstrap onboarding flow for multi-tenant users

The bootstrap greeting and workspace seeding only ran for the owner
workspace at startup, so new users created via the admin API never
received the welcome message or identity files (BOOTSTRAP.md, SOUL.md,
AGENTS.md, USER.md, etc.).

Three fixes:
- tenant_ctx(): seed per-user workspace on first creation via
  seed_if_empty(), which writes identity files and sets
  bootstrap_pending when the workspace is truly fresh
- handle_message(): check take_bootstrap_pending() on the tenant
  workspace (not the owner workspace) and persist the greeting to
  the user's own assistant conversation + broadcast via SSE
- WorkspacePool: seed new per-user workspaces in the web gateway
  so memory tools also see identity files immediately

The existing single-user bootstrap in Agent::run() is preserved for
non-multi-tenant deployments.

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

* fix: address remaining PR review comments (round 3)

- Docs: fix metadata description from "merge patch" to "full replacement"
- Secrets: reject expires_in_days > 36500 with 400 (was silently clamped)
- libSQL: CAST(SUM(cost) AS TEXT) in user_usage_stats and user_summary_stats
  to prevent SQLite numeric coercion from crashing get_text() — this was
  the root cause of the Copilot "SUM returns numeric type" comments
- Add 3 regression tests: user_summary_stats (empty + with data) and
  user_usage_stats (multi-model aggregation)

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

* feat: add role change support for users (admin/member toggle)

- Add update_user_role() to UserStore trait + both backends (PostgreSQL
  and libSQL)
- Extend PATCH /api/admin/users/{id} to accept optional "role" field
  with validation (must be "admin" or "member")
- Add "Make Admin" / "Make Member" toggle button in Users table actions
- Add i18n keys for role change (en + zh-CN)
- Update API docs to document the role field on PATCH
- Fix test helpers to use fmt_ts() for timestamps (was using SQLite
  datetime('now') which produces incompatible format for string comparison)

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

* fix: show live LLM spend in Users table instead of only DB-recorded costs [skip-regression-check]

Chat turns record LLM cost in CostGuard (in-memory) but don't create
agent_jobs/llm_calls DB rows — those are only written for background
jobs. The Users table was querying only from DB, so it showed $0.00
for users who only chatted.

Now supplements DB stats with CostGuard.daily_spend_for_user() —
the same source displayed in the status bar token counter. Shows
whichever is larger (DB historical total vs live daily spend).

Also falls back to last_login_at for "Last Active" when no DB job
activity exists.

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

* fix: persist chat LLM calls to DB and fix usage stats query

Two root causes for zero usage stats:

1. ChatDelegate only recorded LLM costs to CostGuard (in-memory) —
   never to the llm_calls DB table. Added DB persistence via
   TenantScope.record_llm_call() after each chat LLM call, with
   job_id=NULL and conversation_id=thread_id.

2. user_summary_stats query only joined agent_jobs→llm_calls, missing
   chat calls (which have job_id=NULL). Redesigned query to start from
   llm_calls and resolve user_id via COALESCE(agent_jobs.user_id,
   conversations.user_id) — covers both job and chat LLM calls.

Both PostgreSQL and libSQL queries updated. TenantScope gets
record_llm_call() method. Tests updated for new query semantics.

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

* fix: address review comments — input validation, cost semantics, panic safety [skip-regression-check]

- Validate display_name: trim whitespace, reject empty strings (create + update)
- Validate metadata: must be a JSON object, return 400 if not (admin + profile)
- secrets_list_handler: verify target user_id exists before listing
- Cost display: use DB total directly (chat calls now persist to DB),
  remove confusing max(db,live) CostGuard fallback
- CatchPanicLayer: truncate panic payload to 200 chars in log to limit
  potential sensitive data exposure

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

* fix: address Copilot round 5 — docs, secrets consistency, token name, provider field [skip-regression-check]

- Docs: users.id note updated to "typically UUID v4 strings (bootstrap
  admin may use a custom ID)"
- secrets_list_handler: return 503 when DB store is None (was falling
  through to list secrets without user validation)
- tokens_create: trim + reject empty token name (matching display_name
  pattern)
- LlmCallRecord.provider: use llm_backend ("nearai","openai") instead
  of model_name() which returns the model identifier
- user_summary_stats zero-LLM users: acceptable — handler already falls
  back to 0 cost and last_login_at for missing entries

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

* fix: DB auth returns 503 on outage, scheduler counts only blocking jobs

From serrrfirat review:
- DB auth: return Err(()) on database errors so middleware returns 503
  instead of silently returning Ok(None) → 401 (auth miss)
- Scheduler: add parallel_blocking_count_for() that uses
  is_parallel_blocking() (Pending/InProgress/Stuck) instead of
  is_active() for per-user concurrency — Completed/Submitted jobs
  no longer count against MAX_JOBS_PER_USER

From Copilot:
- CLAUDE.md: fix secrets route paths from {id} to {user_id}
- token_hash: use .as_slice() instead of .to_vec() to avoid
  heap allocation on every token auth/creation call

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

* fix: immediate auth cache invalidation on security-critical actions (zmanian review #6)

Add DbAuthenticator::invalidate_user() that evicts all cached entries
for a user. Called after:
- Suspend user (immediate lockout, was 60s delay)
- Activate user (immediate access restoration)
- Role change (admin↔member takes effect immediately)
- Token revocation (revoked token can't be reused from cache)

The DbAuthenticator is shared (via Clone, which Arc-clones the cache)
between the auth middleware and GatewayState, so handlers can evict
entries from the same cache the middleware reads.

Also from zmanian's review:
- Items 1-5, 7-11 were already resolved in prior commits
- Item 12 (String→enum for status/role) is deferred as a broader refactor

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

* fix: last-admin protection, usage stats for chat calls, UTF-8 safe panic truncation

Last-admin protection:
- Suspend, delete, and role-demotion of the last active admin now
  return 409 Conflict instead of succeeding and locking out the admin API
- Helper is_last_admin() checks active admin count before destructive ops

Usage stats:
- user_usage_stats() now includes chat LLM calls (job_id=NULL) by
  joining via conversations.user_id, matching user_summary_stats()
- Both PostgreSQL and libSQL queries updated

Panic handler:
- Use floor_char_boundary(200) instead of byte-index [..200] to
  prevent panic on multi-byte UTF-8 characters in panic messages

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

* fix: workspace seed race, bootstrap atomicity, email trim, secrets upsert response [skip-regression-check]

- WorkspacePool: await seed_if_empty() synchronously after inserting
  into cache (drop lock first to avoid blocking), so callers see
  identity files immediately instead of racing a background task
- Bootstrap admin: use create_user_with_token() for atomic user+token
  creation, matching the admin create endpoint
- Email: trim whitespace, treat empty as None to prevent " " being
  stored and breaking uniqueness
- Secrets PUT: report "updated" vs "created" based on prior existence
- Last token_hash.to_vec() → .as_slice() in authenticate_token

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

* fix: disable unscoped webhook endpoint in multi-tenant mode [skip-regression-check]

The original /api/webhooks/{path} endpoint looks up routines across all
users. In multi-tenant mode, anyone who knows the webhook path + secret
could trigger another user's routine. Now returns 410 Gone with a
message pointing to the scoped endpoint /api/webhooks/u/{user_id}/{path}.

Detection uses state.db_auth.is_some() — present only when DB-backed
auth is enabled (multi-tenant). Single-user deployments are unaffected.

From: standardtoaster review comment

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

* fix: webhook multi-tenant check, secrets error propagation, stale doc comment [skip-regression-check]

- Webhook: use workspace_pool.is_some() instead of db_auth.is_some()
  for multi-tenant detection — db_auth is set for any DB deployment,
  workspace_pool is only set when has_any_users() was true at startup
- Secrets: propagate exists() errors instead of unwrap_or(false) so
  backend outages surface as 500 rather than incorrect "created" status
- Config: fix stale workspace_read_scopes comment referencing user_id

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…nearai#1125)

* feat(context): add approval_context field to JobContext

Add approval_context to JobContext so tools can propagate approval
information when executing sub-tools. This enables tools like
build_software to properly check approvals for shell, write_file, etc.

- Add approval_context: Option<ApprovalContext> field to JobContext
- Add with_approval_context() builder method
- Add check_approval_in_context() helper for tools to verify permissions
- Default JobContext now includes autonomous approval context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(worker): check job-level approval context before executing tools

Move job context fetch before approval check and add job-level
approval context checking. Job-level context takes precedence over
worker-level, allowing tools like build_software to set specific
allowed sub-tools while maintaining the fallback to worker-level
approval for normal operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(scheduler): propagate approval_context to JobContext

Store approval_context from dispatch into JobContext so it's
available to tools during execution. This completes the chain:
scheduler -> job context -> tools -> sub-tools.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(builder): use approval context for sub-tool execution

Update build_software to create a JobContext with build-specific
approval permissions and check approval before executing sub-tools.
This allows the builder to work in autonomous contexts (web UI, routines)
while maintaining security by only allowing specific build-related tools.

Allowed tools: shell, read_file, write_file, list_dir, apply_patch, http

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(db): initialize approval_context as None in job restoration

When restoring jobs from database, set approval_context to None.
The context will be populated by the scheduler on next dispatch if needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add comprehensive approval context tests

Add tests for:
- JobContext default includes approval_context
- with_approval_context() builder method
- Autonomous context blocks Always-approved tools unless explicitly allowed
- autonomous_with_tools allows specific tools
- Builder tool approval context configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address critical approval context security issues

This commit addresses all security concerns raised in PR review:

1. Revert JobContext::default() to approval_context: None
   - Previously set ApprovalContext::autonomous() which was too permissive
   - Secure default requires explicit opt-in for autonomous execution
   - Any code using JobContext::default() now correctly blocks non-Never tools

2. Fix check_approval_in_context() to match worker behavior
   - Previously returned Ok(()) when approval_context was None (insecure)
   - Now uses ApprovalContext::is_blocked_or_default() for consistency
   - Prevents privilege escalation through sub-tool execution paths

3. Remove "http" from builder's allowed tools
   - Building software doesn't require direct http tool access
   - Shell commands (cargo, npm, pip) handle dependency fetching
   - Reduces attack surface for builder tool execution

4. Update tests to reflect new secure defaults
   - Tests now verify JobContext::default() blocks non-Never tools
   - New test added for secure default behavior

Security review references:
- Issue #1: JobContext::default() behavioral change
- Issue #3: check_approval_in_context more permissive than worker check
- Issue #4: Builder allows http without justification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(worker): implement additive approval semantics for job + worker checks

This addresses the remaining security review concern from PR nearai#1125.

Previously, the worker used "precedence" semantics where job-level approval
context would completely bypass worker-level checks. This meant a tool's
job-level context could potentially override worker-level restrictions.

Changes:
- Worker now checks BOTH job-level AND worker-level approval contexts
- Tool is blocked if EITHER level blocks it (additive/intersection semantics)
- Maintains defense in depth: job-level cannot bypass worker-level restrictions

Tests added:
- test_additive_approval_semantics_both_levels_must_approve: verifies job-level
  blocks take effect even when worker-level allows
- test_additive_approval_worker_block_overrides_job_allow: verifies worker-level
  blocks take effect even when job-level allows
- test_additive_approval_both_levels_allow: verifies tool is allowed only when
  both levels approve

Security review reference:
- Issue #3 from @G7CNF: "document or enforce additive semantics for job + worker
  approval checks"
- Issue #2 from @zmanian: "Job-level context bypasses worker-level entirely"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(security): address PR nearai#1125 review feedback

- Restore requirement-aware is_blocked() semantics: Never and
  UnlessAutoApproved tools pass in autonomous context, Only Always
  tools require explicit allowlist entry
- Use AutonomousUnavailable error (with descriptive reason) instead
  of generic AuthRequired for approval blocking in worker
- Deduplicate approval_context propagation in scheduler dispatch
  (single update_context_and_get call instead of duplicated blocks)
- Remove http from builder tool allowlist (shell handles network)
- Add TODO comments for serde(skip) losing approval_context on DB
  restore in both libsql and postgres backends
- Add tests: Never tools in additive model, builder unlisted tool
  blocking

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

* fix(worker): remove duplicate approval check and use normalized params

- Remove pre-existing worker-level-only approval check (lines 561-567)
  that duplicated the new additive check, using a different error type
  and missing job-level context
- Use normalized_params (not raw params) for requires_approval() so
  parameter-dependent approval (e.g. shell destructive detection) works
  correctly with coerced values
- Remove unused autonomous_unavailable_error import
- Add comment documenting unreachable else branch in scheduler

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: ilblackdragon@gmail.com <ilblackdragon@gmail.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…B-backed pairing, and OwnershipCache (nearai#1898)

* feat(ownership): add OwnerId, Identity, UserRole, can_act_on types

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

* fix(ownership): private OwnerId field, ResourceScope serde derives, fix doc comment

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

* refactor(tenant): replace SystemScope::db() escape hatch with typed workspace_for_user(), fix stale variable names

- Add SystemScope::workspace_for_user() that wraps Workspace::new_with_db
- Remove SystemScope::db() which exposed the raw Arc<dyn Database>
- Update 3 callers (routine_engine.rs x2, heartbeat.rs x1) to use the new method
- Fix stale comment: "admin context" -> "system context" in SystemScope
- Rename `admin` bindings to `system` in agent_loop.rs for clarity

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

* fix(tenant): rename stale admin binding to system_store in heartbeat.rs

* refactor(tenant): TenantScope/TenantCtx carry Identity, add with_identity() constructor and bridge new()

- TenantScope: replace `user_id: String` field with `identity: Identity`; add `with_identity()` preferred constructor; keep `new(user_id, db)` as Member-role bridge; add `identity()` accessor; all internal method bodies use `identity.owner_id.as_str()` in place of `&self.user_id`
- TenantCtx: replace `user_id: String` field with `identity: Identity`; update constructor signature; add `identity()` accessor; `user_id()` delegates to `identity.owner_id.as_str()`; cost/rate methods updated accordingly
- agent_loop: split `tenant_ctx(&str)` into bridge + new `tenant_ctx_with_identity(Identity)` which holds the full body; bridge delegates to avoid duplication

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

* feat(db): add V16 tool scope, V17 channel_identities, V18 pairing_requests migrations

- PostgreSQL: V16__tool_scope.sql adds scope column to wasm_tools/dynamic_tools
- PostgreSQL: V17__channel_identities.sql creates channel identity resolution table
- PostgreSQL: V18__pairing_requests.sql creates pairing request table replacing file-based store
- libSQL SCHEMA: adds scope column to wasm_tools/dynamic_tools, channel_identities, pairing_requests tables
- libSQL INCREMENTAL_MIGRATIONS: versions 17-19 for existing databases
- IDEMPOTENT_ADD_COLUMN_MIGRATIONS: handles fresh-install/upgrade dual path for scope columns
- Runner updated to check ALL idempotent columns per version before skipping SQL
- Test: test_ownership_model_tables_created verifies all new tables/columns exist after migrations

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

* fix(db): use correct RFC3339 timestamp default in libSQL, document version sequence offset

Replace datetime('now') with strftime('%Y-%m-%dT%H:%M:%fZ', 'now') in the
channel_identities and pairing_requests table definitions (both in SCHEMA and
INCREMENTAL_MIGRATIONS) to match the project-standard RFC 3339 timestamp format
with millisecond precision. Also add a comment clarifying that libSQL incremental
migration version numbers are independent from PostgreSQL VN migration numbers.

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

* feat(ownership): bootstrap_ownership(), migrate_default_owner, V19 FK migration, replace hardcoded 'default' user IDs

- Add V19__ownership_fk.sql (programmatic-only, not in auto-migration sweep)
- Add `migrate_default_owner` to Database trait + both PgBackend and LibSqlBackend
- Add `get_or_create_user` default method to UserStore trait
- Add `bootstrap_ownership()` to app.rs, called in init_database() after connect_with_handles
- Replace hardcoded "default" owner_id in cli/config.rs, cli/mcp.rs, cli/mod.rs, orchestrator/mod.rs
- Add TODO(ownership) comments in llm/session.rs and tools/mcp/client.rs for deferred constructors

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

* fix(ownership): atomic get_or_create_user, transactional migrate_default_owner, V19 FK inline constant, fix remaining 'default' user IDs

- Delete migrations/V19__ownership_fk.sql so refinery no longer auto-applies FK constraints before bootstrap_ownership runs; add OWNERSHIP_FK_SQL constant with TODO for future programmatic application
- Remove racy SELECT+INSERT default in UserStore::get_or_create_user; both PostgreSQL (ON CONFLICT DO NOTHING) and libSQL (INSERT OR IGNORE) now use atomic upserts
- Wrap migrate_default_owner in explicit transactions on both backends for atomicity
- Make bootstrap_ownership failure fatal (propagate error instead of warn-and-continue)
- Fix mcp auth/test --user: change from default_value="default" to Option<String> resolved from configured owner_id
- Replace hardcoded "default" user IDs in channels/wasm/setup.rs with config.owner_id
- Replace "default" sentinel in OrchestratorState test helper with "<unset>" to make the test-only nature explicit

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

* fix(ownership): remove default user_id from create_job(), change sentinel strings to <unset>

- Gate ContextManager::create_job() behind #[cfg(test)]; production code must
  use create_job_for_user() with an explicit user_id to prevent DB rows with
  user_id = 'default' being silently created on the production write path.
- Change the placeholder user_id in McpClient::new(), new_with_name(), and
  new_with_config() from "default" to "<unset>" so accidental secrets/settings
  lookups surface immediately rather than silently touching the wrong DB partition.
- Same sentinel change for SessionManager::new() and new_async() in session.rs;
  these are overwritten by attach_store() at startup with the real owner_id.
- Update tests that asserted the old "default" sentinel to expect "<unset>", and
  switch test_list_jobs_tool / test_job_status_tool to create_job_for_user("default")
  to keep ownership alignment with JobContext::default().

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

* feat(db): add ChannelPairingStore sub-trait with resolve_channel_identity, upsert/approve pairing, PostgreSQL + libSQL implementations

Adds PairingRequestRecord, ChannelPairingStore trait (5 methods), and
generate_pairing_code() to src/db/mod.rs; implements for PgBackend in
postgres.rs and LibSqlBackend in libsql/pairing.rs; wires ChannelPairingStore
into the Database supertrait bound; all 6 libSQL unit tests pass.

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

* fix(db): atomic libSQL approve_pairing with BEGIN IMMEDIATE, add case-insensitive/expired/double-approve tests

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

* feat(ownership): add OwnershipCache for zero-DB-read identity resolution on warm path

Converts src/ownership.rs to src/ownership/ module directory and adds
src/ownership/cache.rs with a write-through in-process cache mapping
(channel, external_id) -> Identity. Wired as Arc<OwnershipCache> on
AppComponents for Task 8 pairing integration. All 7 cache unit tests pass.

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

* test(e2e): add ownership model E2E tests and extend pairing tests for DB-backed store

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

* fix(e2e): remove unused asyncio import, add fallback assertion in test_pairing_response_structure

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

* test(tenant): unit tests for TenantScope::with_identity and AdminScope construction

Adds 5 focused unit tests verifying TenantScope::with_identity stores the
full Identity (owner_id + role), TenantScope::new creates a Member-role
identity, and AdminScope::new returns Some for Admin and None for Member.
Uses LibSqlBackend::new_memory() as the test DB stub.

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

* fix(ownership): recover from RwLock poison instead of expect() in OwnershipCache

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

* test(ownership): integration tests for bootstrap, tenant isolation, and ChannelPairingStore

Adds tests/ownership_integration.rs covering migrate_default_owner idempotency,
TenantScope per-user setting isolation (including Admin role bypass check),
and the full ChannelPairingStore lifecycle (upsert, approve, remove, multi-channel isolation).

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

* fix(test): remove duplicate pairing tests and flaky random-code assertion from integration suite

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

* feat(pairing): rewrite PairingStore to DB-backed async with OwnershipCache

Replaces the file-based pairing store (~/.ironclaw/*-pairing.json,
*-allowFrom.json) with a DB-backed async implementation that delegates
to ChannelPairingStore and writes through to OwnershipCache on reads.

- PairingStore::new(db, cache) uses the DB; new_noop() for test/no-DB
- resolve_identity() cache-first lookup via OwnershipCache
- approve(code, owner_id) removes channel arg (DB looks up by code)
- All WASM host functions updated: pairing_upsert_request uses block_in_place,
  pairing-is-allowed renamed to pairing-resolve-identity returning Option<String>,
  pairing-read-allow-from deprecated (returns empty list)
- Signal channel receives PairingStore via new(config, db) constructor
- Web gateway pairing handlers read from state.store (DB) directly
- extensions.rs derive_activation_status drops PairingStore dependency;
  derives status from extension.active and owner_binding flag instead
- All test call sites updated to use new_noop()

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

* fix(pairing): add missing pairing_store field to all GatewayState initializers, fix disk-full post-edit compile

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

* feat(channels): remove owner_id from IncomingMessage, user_id is the canonical resolved OwnerId

`owner_id` on `IncomingMessage` was always a duplicate of `user_id` —
both fields held the same value at every call site. Remove the field and
`with_owner_id()` builder, update the four WASM-wrapper and HTTP test
assertions to use `user_id`, and drop the redundant struct literal field
in the routine_engine test helper.

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

* fix(channels): remove stale owner_id param from make_message test helper

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

* test(e2e): add browser/Playwright tests for ownership model — auth screen, chat UI, owner login

Adds five Playwright-based browser tests to the ownership model E2E suite
verifying the web UI experience: authenticated owner sees chat input, unauthenticated
browser sees auth screen, owner can send a message and receive a response, settings
tab renders without errors, and basic page structure is correct after login.

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

* feat(settings): migrate channel credentials from plaintext settings to encrypted secrets store

Moves nearai.session_token from the plaintext DB settings table to the
AES-256-GCM encrypted secrets store (key: nearai_session_token).

- SessionManager gains an `attach_secrets()` method that wires in the
  secrets store; `save_session` writes to it when available and
  `load_session_from_secrets` is called preferentially over settings
- `migrate_session_credential()` runs idempotently on each startup in
  `init_secrets()`, reading the JSON session from settings, writing it
  to secrets, then deleting the plaintext copy
- Wizard's `persist_session_to_db` now writes to secrets first, falling
  back to plaintext settings only when secrets store is unavailable
- Plaintext settings path is preserved as fallback for installs without
  a secrets store (no master key configured)

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

* fix(settings): settings fallback only when no secrets store, verify decryption before deleting plaintext

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

* fix(ownership): ROLLBACK in libSQL migrate_default_owner, shared OwnershipCache across channels, add dynamic_tools to migration, fix doc comment

- libSQL migrate_default_owner: wrap UPDATE loop in async closure + match to emit ROLLBACK on any mid-transaction failure (mirroring approve_pairing pattern)
- Both backends: add dynamic_tools to the migrate_default_owner table list so agent-built tools are migrated on first pairing
- setup_wasm_channels: accept Arc<OwnershipCache> parameter instead of allocating a fresh cache, share the AppComponents cache
- SignalChannel::new: accept Arc<OwnershipCache> parameter and pass it to PairingStore instead of allocating a new cache
- PairingStore: fix module-level and struct-level doc comments to accurately describe lazy cache population after approve()

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

* fix(web): use can_act_on for authorization in job/routine handlers instead of raw string comparisons

Replace 12 raw `user_id != user.user_id` / `user_id == user.user_id` string comparisons
in jobs.rs and 4 in routines.rs with calls through the canonical `can_act_on` function
from `crate::ownership`, which is the spec-mandated authorization mechanism.

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

* chore: include remaining modified files in ownership model branch

* fix: add pairing_store field to test GatewayState initializers, update PairingStore API calls in integration tests

Add missing `pairing_store: None` to all GatewayState struct initializers
in test files. Migrate old file-based PairingStore API calls
(PairingStore::new(), PairingStore::with_base_dir()) to the new DB-backed
API (PairingStore::new_noop()). Rewrite pairing_integration.rs to use
LibSqlBackend with the new async DB-backed PairingStore API.

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

* chore: cargo fmt

* fix(pairing): truly no-op PairingStore noop mode, ensure owner user in CLI, fix signal safety comments

- PairingStore::upsert_request now returns a dummy record in noop mode instead of
  erroring, and approve silently succeeds (matching the doc promise of "writes
  are silently discarded").
- PairingStore::approve now accepts a channel parameter, matching the updated
  DB trait signature and propagated to all call sites (CLI, web server, tests).
- CLI run_pairing_command ensures the owner user row exists before approval to
  satisfy the FK constraint on channel_identities.owner_id.
- Signal channel block_in_place safety comments corrected from "WASM channel
  callbacks" to "Signal channel message processing".

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

* fix(pairing): thread channel through approve_pairing, add created flag, retry on code collision, remove redundant indexes

Addresses PR review comments:
- approve_pairing validates code belongs to the given channel
- PairingRequestRecord.created replaces timing heuristic
- upsert retries on UNIQUE violation (up to 3 attempts)
- redundant indexes removed (UNIQUE creates implicit index)

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

* fix(ownership): migrate api_tokens, serialize PG approvals, propagate resolved owner_id

Addresses PR review P1/P2 regressions:

- api_tokens included in migrate_default_owner (both backends)
- PostgreSQL approve_pairing uses FOR UPDATE to prevent concurrent approvals
- Signal resolve_sender_identity returns owner_id, set as IncomingMessage.user_id
  with raw phone number preserved as sender_id for reply routing
- Feishu uses resolved owner_id from pairing_resolve_identity in emitted message
- PairingStore noop mode logs warning when pairing admission is impossible

[skip-regression-check]

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

* fix(pr-review): sanitize DB errors in pairing handlers, fix doc comments, add TODO for derive_activation_status

- Pairing list/approve handlers no longer leak DB error details to clients
- NotFound errors return user-friendly 'Invalid or expired pairing code' message
- Module doc in pairing/store.rs corrected (remove -> evict, no insert method)
- wit_compat.rs stub comment corrected to match actual Val shape
- TODO added for derive_activation_status has_paired approximation

* fix(pr-review): propagate libSQL query errors in approve_pairing, round-trip validate session credential migration, fix test doc comment

- libSQL approve_pairing: .ok().flatten() replaced with .map_err() to propagate DB errors
- migrate_session_credential: round-trip compares decrypted secret against plaintext before deleting
- ownership_integration.rs: doc comment corrected to match actual test coverage

* fix(pairing): store meta, wrap upserts in transactions, case-insensitive role/channel, log Signal DB errors, use auth role in handlers

- Store meta JSONB/TEXT column in pairing_requests (PG migration V18, libSQL schema + incremental migration 19)
- Wrap upsert_pairing_request in transactions (PG: client.transaction(), libSQL: BEGIN IMMEDIATE/COMMIT/ROLLBACK)
- Case-insensitive role parsing: eq_ignore_ascii_case("admin") in both backends
- Case-insensitive channel matching in approve_pairing: LOWER(channel) = LOWER($2)
- Log DB errors in Signal resolve_sender_identity instead of silently discarding
- Use auth role from UserIdentity in web handlers (jobs.rs, routines.rs) via identity_from_auth helper
- Fix variable shadowing: rename `let channel` to `let req_channel` in libsql approve_pairing

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

* fix(security): add auth to pairing list, cache eviction on deactivate, runtime assert in Signal, remove default fallback, warn on noop pairing codes

Addresses zmanian's review:
- #1: pairing_list_handler requires AuthenticatedUser
- #2: OwnershipCache.evict_user() evicts all entries for a user on suspension
- #3: debug_assert! for multi-thread runtime in Signal block_in_place
- #9: Noop PairingStore warns when generating unredeemable codes
- #10: cli/mcp.rs default fallback replaced with <unset>

* fix(pairing): consistent LOWER() channel matching in resolve_channel_identity, fix wizard doc comment, fix E2E test assertion for ActionResponse convention

* fix(pairing): apply LOWER() consistently across all ChannelPairingStore queries (upsert, list_pending, remove)

All channel matching now uses LOWER() in both PostgreSQL and libSQL backends:
- upsert_pairing_request: WHERE LOWER(channel) = LOWER($1)
- list_pending_pairings: WHERE LOWER(channel) = LOWER($1)
- remove_channel_identity: WHERE LOWER(channel) = LOWER($1)

Previously only resolve_channel_identity and approve_pairing used LOWER(),
causing inconsistent matching when channel names differed by case.

* fix(pairing): unify code challenge flow and harden web pairing

* test: harden pairing review follow-ups

* fix: guard wasm pairing callbacks by runtime flavor

* fix(pairing): normalize channel keys and serialize pg upserts

* chore(web): clean up ownership review follow-ups

* Preserve WASM pairing allowlist compatibility

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…ner (nearai#2042)

* test: add Slack E2E tests, Rust integration tests, and smoke runner

Replicate the Telegram test infrastructure for the Slack WASM channel:
- Add Slack URL rewriting in wrapper.rs for test API redirection
- Create fake_slack_api.py mock server for E2E tests
- Add 12 Python E2E tests covering setup, DM, mentions, auth, threads, files
- Add 12 Rust integration tests for WASM channel behavior
- Add conftest.py fixtures for isolated Slack test instances
- Add local smoke test runner for pre-release validation with real Slack

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: wrap env::set_var/remove_var in unsafe blocks for Rust 1.83+

CI uses Rust 1.94 which requires unsafe blocks for std::env::set_var
and std::env::remove_var. Wrap the test-only calls in unsafe blocks
with safety comments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR review feedback

- Replace fragile time.time()-1 fallback with explicit SmokeError in
  run_smoke.py attachment case (reviewer finding #1)
- Add OnceLock<Mutex> guard around env var mutation in wrapper.rs unit
  test to prevent parallel test races (reviewer finding #2)
- Extract duplicated git-worktree discovery into find_project_file()
  helper in slack_auth_integration.rs (reviewer finding #3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(channels): generalize WASM HTTP test rewrites

* fix(channels): gate Slack test URL rewrites from release builds

* fix(ci): update wrapper test pairing store ctor

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* fix(channels): wire up pairing approval, polling restart, and onboarding state

The Telegram channel setup flow via the gateway was broken end-to-end.
Four interconnected bugs prevented pairing/ownership from completing:

1. pairing_approve_handler only wrote to channel_identities DB — the
   running WasmChannel's owner_actor_id was never updated, so the
   owner was never recognized and broadcast metadata was never stored.

2. refresh_active_channel() re-ran on_start() but never called
   ensure_polling(), leaving polling in a stale state on repeated
   tool_activate calls and causing Telegram 409 conflicts.

3. activate_wasm_channel() had a TOCTOU race on active_channel_names
   that allowed duplicate polling loops, and hot_add() didn't await
   old polling task termination.

4. onboarding_state was always None in extension API responses and
   PairingRequired SSE was never emitted, so the frontend could
   never render the pairing card.

Changes:
- approve_pairing (DB trait + both backends) now returns external_id
- WasmChannel.owner_actor_id wrapped in RwLock with set_owner_actor_id()
- ExtensionManager.complete_pairing_approval() orchestrates: persist
  owner_id → update running channel → restart polling
- pairing_approve_handler calls complete_pairing_approval and emits
  PairingCompleted SSE (scoped to approving user)
- refresh_active_channel() calls ensure_polling() and syncs owner
- Per-channel activation mutex prevents TOCTOU race
- hot_add() drops write lock before awaiting shutdown
- Extension list handlers populate onboarding_state when Pairing
- derive_onboarding() helper in handlers/extensions.rs
- Regression tests for derive_onboarding and resolve_message_scope

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

* fix(bridge): eliminate dual card + text emission for gate-paused flows

When the v2 engine hits a gate-paused state (approval needed, auth
required), the web gateway was sending BOTH an interactive card (via
send_status → SSE) AND a redundant text message (via AppEvent::Response).
Users saw a duplicate prompt.

Root cause: v2 bridge functions returned Ok(Some(text)) for gate-paused
outcomes, which mapped via from_legacy to HandleOutcome::Respond — sending
both the card and the text. The v1 path correctly used HandleOutcome::Pending.

Fix:
- Gate-paused paths in router.rs now return Ok(None) instead of text
- New bridge_to_outcome() checks has_any_pending_gate() after each v2
  bridge call — if a gate exists, returns Pending (suppresses text + Done)
- New from_bridge() maps None → NoResponse (not Shutdown) for v2 paths
- Removed pending_gate_prompt_message() — the function that generated
  the duplicate text
- notify_pending_gate() no longer emits GateRequired SSE directly
  (redundant with send_pending_gate_status per-channel routing)
- Updated 3 tests to assert None return + StatusUpdate delivery

Each channel renders the approval/auth card natively via send_status:
web → SSE card, TUI → widget, relay → buttons.

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

* fix: address PR review comments

- bridge_to_outcome: only return Pending when handler returned None
  (preserves legitimate text responses for ambiguous gate messages)
- process_emitted_messages: clone owner_actor_id out of read lock
  before awaiting resolve_message_scope_with_pairing
- Normalize channel_name to lowercase in complete_pairing_approval
  and pairing_approve_handler for consistent webhook/store lookups
- cargo fmt

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

* fix: address self-review — BridgeOutcome enum, ExternalId newtype, pairing extraction

- Replace Option<String> bridge handler returns with typed BridgeOutcome
  enum (Respond/NoResponse/Pending), eliminating post-hoc has_any_pending_gate
  query and the None→NoResponse mapping that swallowed v2 shutdown signals
- Add ExternalId newtype for approve_pairing return (was bare String)
- Fix noop PairingStore::approve to return NotFound instead of Ok("")
- Extract pairing approval orchestration to src/pairing/approval.rs
- Clone RwLock<owner_actor_id> before awaiting in respond()
- Downgrade warn! to debug! in pairing handlers (TUI logging rule)
- Gate TELEGRAM_TEST_API_BASE_ENV const behind cfg(test/debug_assertions)
- Remove hardcoded Telegram auth instructions; use capabilities prompt
- Fix unused mut receiver in test

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

* fix(channels): remove dead Telegram verification flow, consolidate to generic pairing

The Telegram-specific verification challenge (/start CODE deep link flow)
blocked the generic pairing flow from ever running — configure() returned
early with activated:false when the challenge was pending, so the channel
never started polling and users couldn't generate pairing codes.

Removed ~1200 lines:
- TelegramBindingResult, TelegramBindingData, TelegramOwnerBindingState,
  TelegramVerificationMeta, PendingTelegramVerificationChallenge types
- configure_telegram_binding, resolve_telegram_binding,
  issue_telegram_verification_challenge, notify_telegram_owner_verified
  and all Telegram API response types (getUpdates polling loop, etc.)
- ConfigureResult.verification field + VerificationChallenge re-export
- All verification-related test fixtures and 6 test functions
- Dead RecordingChannel test helper, unused set_channel_owner_id method
- Gated send_telegram_text_message + helpers behind cfg(test)

Replaced with:
- validate_telegram_token() — lightweight getMe call for token validation
  + bot_username extraction (persisted for mention detection)
- All channels now follow: credentials → validate → activate → pairing

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

* fix(channels): broadcast PairingRequired SSE after activation in pairing mode

After a channel activates with no owner binding, broadcast a per-user
PairingRequired SSE event so the web UI shows the pairing card without
requiring a manual refresh. Also populate pairing_required, onboarding_state,
and onboarding fields on ConfigureResult so callers know the channel
needs pairing.

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

* fix(agent): don't persist auth instructions as turn response

When a tool triggers an auth gate (awaiting_token), the dispatcher
already sends an AuthRequired card and puts the thread in auth mode.
The thread_ops handler was then calling complete_turn(&instructions)
which overwrote auth mode back to Idle AND persisted the auth prompt
("Enter your Telegram Bot API token...") as the turn response — rendering
a redundant text bubble alongside the auth card.

Fix: skip complete_turn and persist_assistant_response for AuthPending.
The turn is paused (not complete), and the auth card is the only
user-facing signal. Tool calls are still persisted for history.

Also removes the now-unused `instructions` field from
AgenticLoopResult::AuthPending — the instructions were already sent
via the AuthRequired status event before AuthPending is returned.

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

* fix(channels): resume agent turn after auth + pairing completion

After the web UI submits a token via /api/chat/auth-token or approves
pairing via /api/pairing/{channel}/approve, the agent's turn was stuck
at Pending forever — these HTTP handlers configured the extension
directly but never signaled the agent loop to resume.

Fix: inject a follow-up message through msg_tx (the agent's message
channel) after successful auth/pairing. This uses the same pattern as
the OAuth callback handler — the LLM picks up the injected message,
sees the activation/pairing result, and produces a natural response.
The response goes through the full agent pipeline (hooks, safety,
history persistence, Done event).

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

* fix(channels): also resume agent turn on auth cancel

When the user dismisses the auth card, the frontend calls
/api/chat/auth-cancel which clears auth mode. But the original agent
turn was still paused at Pending with no Done event. The UI stayed
stuck at "Processing..." forever.

Fix: inject a cancellation message through msg_tx so the LLM can
acknowledge the cancellation and the turn completes naturally.

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

* fix(channels): pass thread_id in pairing approve for proper routing

The injected follow-up message after pairing approval had no thread_id,
causing the gateway to fail with "missing a routing target." The
response from the LLM was produced but couldn't be delivered.

Fix: add optional thread_id to PairingApproveRequest. The frontend
passes currentThreadId so the agent responds in the same conversation.

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

* test(e2e): add Playwright tests for channel pairing flow

Covers:
- Auth-token/cancel handlers don't 500
- Pairing approve accepts optional thread_id field
- Backward compatibility: approve without thread_id works
- PairingRequired SSE shows pairing card
- PairingCompleted SSE dismisses pairing card
- Frontend sends currentThreadId in pairing approve request body

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

* fix(agent): transition thread to Idle on AuthPending

The AuthPending handler was not calling complete_turn() (to avoid
persisting redundant auth instructions as the response), but this
also skipped the ThreadState::Processing → Idle transition. The
thread stayed stuck in Processing forever, so the follow-up message
injected through msg_tx after auth/pairing was silently rejected.

Fix: explicitly set thread.state = Idle in both AuthPending arms
without calling complete_turn().

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

* test(e2e): remove dead verification challenge branch from telegram e2e

The Telegram verification challenge flow was removed — channels now
go straight to activation and use the generic pairing flow. The
conditional verification retry in setup_telegram() was dead code.

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

* fix: address PR review comments

- Gate TELEGRAM_TEST_API_BASE_ENV and telegram_api_base_url() behind
  cfg(any(test, debug_assertions)) to prevent production env var override
  (serrrfirat HIGH — ship blocker)
- Sanitize validate_telegram_token() error messages to avoid leaking bot
  tokens via reqwest Display (Copilot)
- Log failed msg_tx sends instead of silently dropping (ilblackdragon)
- Forward thread_id in PairingCompleted SSE event (Copilot)
- Fix stale doc comment on persist_numeric_owner_id (Copilot)
- Hoist duplicate parse::<i64>() in propagate_approval (ilblackdragon)
- Delete dead _removed_telegram_verification_test (ilblackdragon)
- Fix always-passing E2E thread_id assertion (Copilot)
- Add V24 migration checksum to checksums.lock

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

* fix: update PairingStore::approve doc for noop mode

The doc said "silently succeeds" but the implementation returns
NotFound when no database is configured.

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

* fix: sanitize extension names in agent prompts + live owner_actor_id in spawned tasks

Two hardening fixes from PR review deferrals:

1. Extension names from HTTP request bodies were interpolated directly into
   format strings that become IncomingMessage content fed to the agent loop.
   Add sanitize_extension_name() that strips non-alphanumeric chars and apply
   it at the two prompt injection points in chat_auth_token_handler and
   chat_auth_cancel_handler.

2. start_polling() and start_websocket_runtime() captured owner_actor_id as
   an owned Option<String> at spawn time. After pairing approval, WebSocket
   channels kept using the stale pre-approval value. Change to pass
   Arc<RwLock<Option<String>>> so spawned tasks read the current owner on
   each tick/event.

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

* fix: address PR review findings — TurnOutcome refactor, security hardening, WS parity

Structural changes:
- Replace Thread::complete_turn/fail_turn/interrupt with single
  conclude_turn(TurnOutcome) that makes it impossible to forget the
  turn state. Fixes AuthPending arms leaving Turn stuck at Processing.
- Add TurnOutcome::CompletedSilently for auth-card-only turns.

Security:
- Sanitize channel name in pairing_approve_handler (missed injection site)
- Fix bot token leak in validate_telegram_token — log safe fields
  (is_timeout, is_connect, status) instead of reqwest error display
  which includes the URL containing the token
- Consume stale fallback auth gate before replaying message to prevent
  duplicate agentic runs on repeated OAuth callbacks
- Sanitize channel_name in derive_onboarding user-visible strings
- Add #[must_use] to BridgeOutcome enum

WS/REST parity:
- Add thread_id to WsClientMessage::AuthToken and AuthCancel
- WS AuthToken handler now injects follow-up message via msg_tx
  (matching REST chat_auth_token_handler behavior)
- WS AuthCancel handler now clears engine pending auth and injects
  cancellation message (matching REST chat_auth_cancel_handler)

Cleanup:
- Deduplicate build_runtime_config_updates (manager.rs imports from
  approval.rs instead of maintaining its own copy)
- Downgrade info! to debug! for auto-generated secret log
- Downgrade warn! to debug! for OAuth fallback diagnostic
- Upgrade debug! to warn! for on_start failure in propagate_approval
- Rename misleading e2e test to match what it actually tests
- Add mixed-character truncation test for sanitize_extension_name

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

* test(e2e): add critical coverage for auth flow security and msg_tx injection

New e2e tests:
- test_auth_cancel_injects_follow_up_message_via_sse: verifies the msg_tx
  injection path actually delivers messages end-to-end (SSE response event
  appears after auth-cancel)
- test_sanitize_extension_name_in_auth_cancel: verifies injection characters
  in extension_name are stripped before reaching the agent loop
- test_pairing_approve_sanitizes_channel_name: verifies channel path param
  is sanitized in pairing approve handler
- test_ws_auth_token_accepts_thread_id: verifies WS auth_token messages
  accept the new thread_id field
- test_ws_auth_cancel_accepts_thread_id: verifies WS auth_cancel messages
  accept thread_id and connection stays alive

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

* fix: always inject follow-up message after auth token submission

When result.activated was false, the chat_auth_token_handler skipped
the msg_tx injection. This left the paused turn (Pending with Done
suppressed) permanently stuck — the UI showed "Running tool_install..."
forever.

Now both REST and WS handlers always:
1. Clear auth mode
2. Broadcast AuthCompleted (with success=true/false)
3. Inject a follow-up message via msg_tx

The message content varies based on activation status so the LLM
can respond appropriately.

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

* fix: revert hot_add to clone-then-shutdown to preserve message_tx receiver

The previous fix (drop write lock before shutdown) removed the channel
from the map before calling shutdown(). This dropped the last strong
Arc reference in the channel manager, killing the forwarding task's
receiver. The router holds its own Arc to the inner WasmChannel, so
propagate_approval's ensure_polling() could still send via message_tx
— but the receiver was dead, causing "channel closed" errors.

Revert to the staging pattern: read-lock to clone the Arc, drop the
lock, shutdown the clone, then write-lock to insert the replacement.
The old entry stays in the map (keeping the forwarding task alive)
until the insert atomically replaces it.

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

* fix: log bot_username set_setting failure instead of silently dropping

Copilot review: the set_setting result for bot_username was silently
dropped with `let _ =`. Now logs at debug level if the DB write fails,
giving visibility into mention detection degradation.

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

* fix: repair message_tx when Channel::start() fails at boot

When a WASM channel is loaded at boot without credentials (fresh DB),
on_start fails (e.g., Telegram deleteWebhook returns 404 with unresolved
{TELEGRAM_BOT_TOKEN}). Previously, message_tx was set BEFORE on_start,
so the sender survived but the receiver (rx) was dropped on error return.
Later, refresh_active_channel restarted polling which cloned the orphaned
sender — every send failed with "channel closed".

Fixes:
- Move message_tx creation AFTER on_start succeeds in Channel::start()
- Add WasmChannel::ensure_message_channel() that creates (tx, rx) if
  message_tx is None or closed, returning the stream for forwarding
- refresh_active_channel calls ensure_message_channel() after on_start
  succeeds and wires up a forwarding task if needed

Also:
- Revert hot_add to match staging exactly (no behavior change needed)
- Remove temporary debug logging (message_tx state before dispatch)

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

* fix: address remaining review comments — stale doc, websockets import

- Update AuthPending doc to reflect TurnOutcome::CompletedSilently
  (was "turn NOT completed", now accurately describes conclude_turn)
- Move `import websockets` inside try block so ImportError is caught
  by the except handler when the package isn't installed

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

* fix: address review comments — propagate on_start error, dedupe helpers, tighten tests

- propagate_approval: propagate on_start() error as ActivationFailed
  instead of swallowing it (zmanian review #1)
- router.rs: move test-only HashMap import into mod tests (zmanian #2)
- chat.rs: remove duplicate clear_auth_mode (Copilot review #1)
- e2e: strengthen auth-token assertion to check status 200 + success
  field, remove overlapping test_auth_cancel_returns_success (Copilot #2/#3)

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

* fix: address serrrfirat review — TOCTOU race, missing v2 auth clear, warn log

- ensure_message_channel: single write lock for atomic check-and-create
  (fixes TOCTOU race where concurrent callers could orphan a forwarding task)
- chat_auth_token_handler: add missing clear_engine_pending_auth() call
  (REST/WS parity — WS and REST cancel already had it, REST token did not)
- pairing_approve_handler: debug! → warn! for complete_pairing_approval
  failure (operationally significant — channel won't route until restart)

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

* fix(web,extensions): address review — sanitize agent messages, fix approve propagation, skip double Telegram getMe (nearai#2432)

- Sanitize result.message before interpolation into synthetic agent input
  to prevent prompt injection via crafted validation errors (server.rs + ws.rs)
- Surface complete_pairing_approval() failure to frontend with success=false
  SSE event and ActionResponse::fail instead of silently succeeding
- Return ActionResponse::ok when auth_url is present even if activated=false
  so OAuth flows can progress through the frontend popup
- Skip generic validation_endpoint check for Telegram (validate_telegram_token
  already calls getMe and extracts bot_username — avoids double API round-trip)
- Sanitize generic validation_endpoint error messages to avoid leaking
  sensitive URL paths (e.g. bot tokens) via reqwest::Error Display

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

* Unify gateway onboarding and pairing flows

* Fix gateway message metadata scoping

* Clean up web gateway warnings

* Fix auth and onboarding regression fallout

* Fix gate resolution and pairing rollback trust boundaries

* Guard legacy agent loop from v2 submissions

* Fix PR review follow-ups for onboarding flow

* Fix CI clippy failure in pairing tests

* Fix onboarding review follow-ups

* Fix clippy warning in skills catalog

* Tighten pairing flow e2e assertions

* Fix onboarding auth review follow-ups

* Fix auth routing and tui clippy lint

* Fix pairing gate handoff in onboarding flow

* Fix clippy guard in mission event scan

* Fix merged clippy regressions

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* feat(setup): prompt for local profile on first run

* refactor: encapsulate DB config backup/restore into Settings helpers

Extract backup_database_config() and restore_database_config() on Settings
to replace inline field-by-field save/restore in the setup wizard. Cleaner
interface, consistent with project encapsulation standards.

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

* fix(setup): address henrypark133 + zmanian review — reorder flow, remove backup/restore (nearai#2389)

- Remove `DatabaseConfigBackup` struct and backup/restore methods;
  reorder quick-mode flow so profile selection runs before
  `auto_setup_database()`, letting the existing clone→try_load→merge_from
  pattern preserve wizard-chosen DB settings naturally (henrypark133).
- Add comment explaining the cfg-gated `loaded` variable shadowing in
  `try_load_existing_settings` (zmanian #1).
- Change catch-all `_ =>` to explicit `1 => ... _ => unreachable!()`
  in profile match arm (zmanian #3).
- Add caller-level test verifying profile application preserves DB config
  through the merge_from cycle (henrypark133 testing feedback).

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
* feat(engine-v2): mount-backend abstraction for per-project sandbox (Phase 1)

Adds the engine-side `MountBackend` trait + minimal `WorkspaceMounts` registry
and a host-side bridge interceptor that routes sandbox-eligible tool calls
(`file_read`, `file_write`, `list_dir`, `apply_patch`, `shell`) through a
backend when their path argument starts with `/project/`. Default behavior is
unchanged: until `EffectBridgeAdapter::set_workspace_mounts(Some(...))` is
called (Phase 6), the interception path is dormant.

This is the first phase of the per-project sandbox plan
(`docs/plans/2026-04-10-engine-v2-sandbox.md`) and a deliberately small subset
of the unified Workspace VFS proposed in nearai#1894 — just enough
abstraction so the sandbox can be a `MountBackend` rather than a special case
in the bridge. When nearai#1894's full mount table lands, the sandbox backend slots
in unchanged.

Engine crate (`crates/ironclaw_engine/src/workspace/`):
- `mount.rs` — `MountBackend` trait, `MountError` (NotFound / InvalidPath /
  PermissionDenied / Io / Tool / Backend / Unsupported), `DirEntry`,
  `EntryKind`, `ShellOutput`
- `filesystem.rs` — `FilesystemBackend`: passthrough host-fs implementation
  with two-layer path validation (lexical reject of absolute / `..`, then
  symlink-escape canonicalization). `read`/`write`/`list` fully implemented;
  `patch`/`shell` return `Unsupported` so the bridge falls through to the
  host tool until Phase 5
- `registry.rs` — `WorkspaceMounts` per-project registry with lazy
  `ProjectMountFactory`, longest-prefix-match resolution, cached and
  invalidatable

Bridge (`src/bridge/sandbox/`):
- `intercept.rs` — `maybe_intercept` and `SANDBOX_TOOL_NAMES`. Returns
  `Handled(json)` on a successful backend dispatch, `FellThrough` for
  non-sandbox tools, host paths, missing path params, or `Unsupported`
  backend ops
- `effect_adapter.rs` — `workspace_mounts` field + `set_workspace_mounts`
  setter; interception block in `execute_action_internal` right before
  `execute_tool_with_safety`, gated on the optional mount table

Tests (31 new):
- 17 engine workspace unit tests covering trait error mapping, path safety
  (lexical + symlink), longest-prefix routing, and lazy factory caching
- 9 bridge sandbox unit tests including `intercept_actually_dispatches_into_backend`
  (counting backend) which proves the interceptor reaches the backend
- 5 integration tests in `tests/engine_v2_sandbox_integration.rs` driving
  `EffectBridgeAdapter::execute_action()` end-to-end per the
  "Test Through the Caller" rule (`.claude/rules/testing.md`), including
  a host-path-falls-through test that asserts the sandbox tempdir was
  not touched, and a `..`-escape test that verifies no `/etc/passwd`
  content leaks even after safety-layer redaction

Drive-by: feature-gate two pre-existing dead-code helpers in
`crates/ironclaw_skills/src/parser.rs` on `#[cfg(feature = "registry")]` to
match their only call site, fixing a pre-existing clippy warning that blocked
the workspace's `-D warnings` policy when `ironclaw_skills` is built with
`default-features = false` (as the engine crate does).

Verification:
- `cargo fmt --check` clean
- `cargo clippy --all --benches --tests --examples --all-features` zero warnings
- 31 / 31 new tests passing; no existing tests broken

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

* feat(engine-v2): per-project sandbox — Phases 2–7 + live Docker e2e test

Completes the per-project sandbox plan (docs/plans/2026-04-10-engine-v2-sandbox.md
Phases 2–7), building on Phase 1's mount-backend abstraction (nearai#2211).

Phase 2 — Project workspace folder:
- `Project.workspace_path: Option<PathBuf>` field + `with_workspace_path()`
- Host-side `project_workspace_path()`, `ensure_project_workspace_dir()` (creates
  `~/.ironclaw/projects/<id>/` mode 0700, idempotent)
- `FilesystemMountFactory` taking a `ProjectPathResolver` closure (decoupled from
  `Store`); wired into `EffectBridgeAdapter` via `set_workspace_mounts()`

Phase 3 — Standalone daemon binary:
- `src/bin/sandbox_daemon.rs` — NDJSON over stdin/stdout, health/shutdown/execute_tool
- Constructs ReadFileTool/WriteFileTool/ListDirTool/ApplyPatchTool/ShellTool with
  `base_dir=/project` (override via `IRONCLAW_SANDBOX_BASE_DIR`)

Phase 4 — Dockerfile.sandbox:
- Multi-stage build: rust-slim builder (+ python3 for pyo3) compiles sandbox_daemon;
  debian-slim runtime with tini PID 1, common build tools, `/project` mount target

Phase 5 — ProjectSandboxManager + ContainerizedFilesystemBackend:
- protocol.rs: Request/Response/RpcError matching daemon wire format
- transport.rs: `SandboxTransport` trait (seam for testing without Docker)
- containerized_backend.rs: `ContainerizedFilesystemBackend` impls `MountBackend`,
  translates relative→`/project/<rel>`, maps tool-error→MountError
- docker_transport.rs: real bollard exec session, serialized Mutex, lazy reconnect
- lifecycle.rs: deterministic `ironclaw-sandbox-<pid>` naming, ensure_running/stop/remove
- manager.rs: `ProjectSandboxManager` per-project transport cache

Phase 6 — Router gating on ENGINE_V2_SANDBOX:
- `engine_v2_sandbox_enabled()` helper (truthy: 1/true/yes/on)
- Router selects `ContainerizedMountFactory` when enabled + Docker reachable;
  falls back to `FilesystemMountFactory` with warning otherwise

Live e2e bugs caught and fixed:
- Shell without explicit `workdir` defaulted to host (not sandbox); fixed by
  defaulting to `/project/` in `extract_path_param`
- `ContainerizedFilesystemBackend::shell` parsed `stdout`/`stderr` but host
  ShellTool returns merged `output` field; fixed with fallback key lookup
- SANDBOX_TOOL_NAMES only had v2 names (`file_read`/`file_write`) but host
  registry uses v1 names (`read_file`/`write_file`); added both aliases

Tests (62 sandbox-related, all green):
- 27 bridge sandbox unit tests (intercept, workspace_path, factory, protocol,
  lifecycle, containerized_backend with ScriptedTransport mock)
- 7 containerized-backend tests (including 2 regression tests for the shell bugs)
- 5 engine v2 sandbox integration tests (EffectBridgeAdapter end-to-end)
- 5 daemon binary smoke tests (real subprocess + NDJSON I/O)
- 17 engine workspace unit tests
- 1 live Docker e2e test: agent clones nearai/ironclaw into sandbox, renames
  to megaclaw via sed, verifies with grep — 70s, $0.09, recorded trace committed

Verification:
- `cargo fmt --check` clean
- `cargo clippy --all --benches --tests --examples --all-features` zero warnings
- All 62 sandbox tests passing; no existing tests broken

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

* fix: replace .expect() with Result in DockerTransport::ensure_session

CI's no-panics checker flagged the .expect("just inserted") in production
code. Replace with .ok_or_else() returning MountError::Backend.

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

* fix: multi-tenant project paths + unify sandbox env var with v1

Two issues addressed:

1. Project workspace paths now namespace by user_id:
   `~/.ironclaw/projects/<user_id>/<project_id>/` instead of
   `~/.ironclaw/projects/<project_id>/`. Prevents filesystem collisions
   in multi-tenant deployments where two users could theoretically have
   the same project UUID.

2. Sandbox enablement now reads `SANDBOX_ENABLED` (same env var as v1
   sandbox) in addition to `ENGINE_V2_SANDBOX`. Either being truthy
   enables the per-project sandbox. This means a single flag governs
   sandbox behavior regardless of engine version, while the v2-specific
   override remains available for transitional setups.

Tests: 30 bridge sandbox unit tests passing (added multi-tenant path
tests + env var combination tests).

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

* fix: address PR review — TOCTOU race, shell env passthrough, canonicalize guard

Three issues flagged by the code review bot on nearai#2211:

1. TOCTOU race in WorkspaceMounts::resolve (HIGH): Added double-checked
   locking — re-check the cache after acquiring the write lock so two
   threads racing on the same project's first access don't both call
   factory.build(). The second thread finds the insert from the first.

2. Shell intercept ignores env parameter (MEDIUM): The shell arm in
   maybe_intercept was passing HashMap::new() instead of forwarding
   the tool call's env map. Fixed to parse parameters["env"] and pass
   it through to backend.shell().

3. Canonicalization fails when root doesn't exist (MEDIUM): When
   self.root hasn't been created yet (first write to a new project),
   canonicalize_under_root would walk up to a real ancestor and the
   starts_with check against the non-existent root would always fail.
   Now skips canonicalization entirely when root doesn't exist — lexical
   safety is already guaranteed by safe_join.

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

* fix: address PR review round 2 — apply_patch schema, content validation, dir perms, docs

- Fix apply_patch schema mismatch: MountBackend::patch now takes
  (old_string, new_string, replace_all) matching ApplyPatchTool's
  actual contract. Previously sent {patch: diff} which would fail
  with invalid_params in the containerized daemon.
- Validate file_write content param: return error instead of silently
  writing empty string when content is missing.
- Log stderr frames from sandbox daemon at debug! instead of silently
  discarding them in docker_transport StreamReader.
- Tighten permissions on intermediate directories created by
  ensure_project_workspace_dir (projects/, <user_id>/) to 0o700,
  not just the leaf.
- Fix stale module doc in sandbox/mod.rs (referenced "Phase 5 will
  add" but all phases shipped).
- Fix doc path mismatch: workspace path is <user_id>/<project_id>/,
  not <project_id>/ (workspace_path.rs, CLAUDE.md, design plan).

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

* fix: address PR review round 3 — symlink safety, visibility, debug logging

- Close TOCTOU window in canonicalize_under_root: re-canonicalize and
  verify containment when the reassembled path exists on disk
- Fix list_dir_recursive: use symlink_metadata (lstat) so symlinks are
  detected instead of followed; validate directories against root before
  recursive traversal
- Tighten is_mountable_path to /project/, /memory/, /home/ prefixes
  instead of any absolute path (defense-in-depth)
- Narrow sandbox module visibility to pub(crate) and remove unused
  pub use re-exports
- Remove concrete types (FilesystemBackend, DirEntry, EntryKind,
  ShellOutput) from engine crate top-level re-exports; access via
  ironclaw_engine::workspace:: module path
- Add debug! tracing to sandbox intercept routing decisions
- Add read_file/write_file v1 aliases to daemon SUPPORTED_TOOLS health
  response
- Remove developer-local path from sandbox mod.rs doc comment
- Merge staging to fix CI (user_timezone field on ThreadExecutionContext)

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

* fix: address PR review round 4 — safety validation, network isolation, binary writes

- Add pre-intercept safety param validation so sandbox-dispatched calls
  go through the same checks as host-dispatched calls (#1)
- Set network_mode: "none" on sandbox containers to prevent outbound
  network access (#3)
- Reject binary content in containerized write instead of silently
  corrupting via from_utf8_lossy (#5)
- Cap list_dir depth to 10 to prevent unbounded traversal (#8)
- Change container creation log from info! to debug! to avoid breaking
  REPL/TUI output (#10)
- Make is_truthy case-insensitive so SANDBOX_ENABLED=True works (#11)
- Return error instead of unwrap_or_default for missing container ID (#12)
- Propagate set_permissions errors instead of silently ignoring (#13)
- Return error for missing daemon output key instead of defaulting to
  empty object (#14)
- Add env mutex guard in sandbox_live_e2e test (#15)
- Fix rustfmt formatting for let-chain in canonicalize_under_root

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

* fix: address review round 5 — path traversal, error types, tests

Security fixes:
- Sanitize user_id in workspace path to prevent directory traversal via
  malicious user IDs containing `..` or `/`
- Add Component::ParentDir check in ContainerizedFilesystemBackend::container_path
  matching the defense-in-depth approach of FilesystemBackend::safe_join

Correctness:
- Use MountError::Tool instead of MountError::InvalidPath for missing
  tool parameters (content, old_string, new_string) — fixes confusing
  LLM-visible error messages
- Fix clippy sort_by_key suggestion in registry.rs

Cleanup:
- Remove spurious Notify import and dead _notify_link function

New tests:
- ContainerizedFilesystemBackend path traversal rejection (read + write)
- container_path unit tests for safe and unsafe paths
- Adversarial user_id test in workspace_path
- Daemon-side path traversal test in sandbox_daemon_smoke

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

* fix: address review round 6 — param normalization, error types, edge cases

- Normalize sandbox params via prepare_tool_params() before validation,
  matching the host execution path (fixes inconsistent validation)
- Return ToolError::InvalidParameters instead of EngineError::Effect for
  sandbox param validation failures (consistent error surface)
- ensure_dir checks path.is_dir() not path.exists() (rejects files)
- Empty user_id returns "_anonymous" sentinel instead of empty hex string
  that would drop the tenant namespace via PathBuf::join("")
- Restore ENGINE_V2_SANDBOX env var after sandbox live E2E test
- Tighten is_mountable_path to /project/ only (no mounts for /memory/
  or /home/ yet)
- Add v1 tool name aliases (read_file, write_file) to SUPPORTED_TOOLS

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

* refactor: unify sandbox env var — remove ENGINE_V2_SANDBOX, use SANDBOX_ENABLED only

Single env var controls sandboxing for both engine versions. The
transitional ENGINE_V2_SANDBOX override is removed from code, tests,
docs, and Dockerfile.

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

* fix: double-checked locking in transport_for, explicit stdin close in smoke test

- ProjectSandboxManager::transport_for no longer holds the mutex across
  the Docker ensure_running await. Uses double-checked locking so
  concurrent projects initialize in parallel.
- sandbox_daemon_smoke: explicitly take() stdin before wait_with_output
  so EOF is sent even without a shutdown request.

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

* fix: address review — network mode, error types, race, protocol dedup

- Change sandbox container network_mode from "none" to default bridge
  so git clone / cargo build / pip install work inside the container
- Fix binary content rejection to use MountError::Tool instead of
  MountError::InvalidPath (semantic mismatch)
- Fix list depth: use actual depth value instead of depth.max(1)
- Fix orphan container race in transport_for by holding lock across
  container creation instead of double-checked locking
- Deduplicate protocol types: daemon now imports from shared
  bridge::sandbox::protocol instead of defining its own copies
- Make bridge::sandbox pub (narrow exposure: only protocol and
  workspace_path sub-modules are pub)

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

* docs: update plan doc — sandbox uses bridge networking, not network_mode=none

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…easoning-augmented recall (nearai#2336)

* feat(memory): configurable insights interval, session summary hook, reasoning-augmented recall

Three memory enrichment features:

1. Configurable conversation insights interval via MISSION_INSIGHTS_INTERVAL
   env var (default: 5, min: 1) with MissionsConfig + MissionSettings wiring
2. SessionSummaryHook that writes LLM-generated conversation summaries to
   workspace daily logs on session end (fail-open, 30s timeout)
3. Optional reasoning parameter on memory_search that synthesizes raw chunks
   via cheap LLM before returning, controlled by SEARCH_REASONING_ENABLED

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(memory): address PR nearai#2336 review feedback and CI failures

Critical fixes:
- Use DB-first config system for MissionsConfig instead of raw
  std::env::var in router.rs (issue #1)
- SessionSummaryHook now uses thread_ids from HookEvent::SessionEnd
  to summarize the correct conversation instead of guessing via
  recency; falls back to most-recent for backward compatibility (#2)
- Add per-user rate limiter (10/min, 60/hr) and 15s timeout on
  reasoning LLM calls in MemorySearchTool to prevent unbounded
  usage (#3)

Test coverage:
- Caller-level tests for reasoning-augmented recall (LLM wiring,
  disabled config, and failure fallback paths) (#4)
- SessionSummaryHook LLM failure path test confirming fail-open
  behavior (#5)
- reasoning_enabled config field tests (default, env, DB override) (#6)
- MissionSettings and SearchSettings round-trip assertions in
  comprehensive_db_map_round_trip (#11)

Convention fixes:
- Remove double env-var parsing in MissionsConfig::resolve (#7)
- Use ChatMessage::system()/user() constructors in
  SessionSummaryHook (#8)
- Add TODO comments for inline prompt strings (#9)
- Add timeout on reasoning LLM call (#10)

CI fixes:
- Remove 4 stale wasmtime advisory entries from deny.toml
- Add RUSTSEC-2026-0097 (rand 0.8.5) to advisory ignore list

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

* fix(memory): address henrypark133 + ilblackdragon review — safety, concurrency, prompts (nearai#2336)

- Move inline prompt templates to prompts/*.md per project convention
  (session_summary.md, memory_reasoning_synthesis.md) — resolves TODOs
- Add Arc<Semaphore> to SessionSummaryHook to cap concurrent LLM calls
  on mass session expiry (follows OutboundWebhookHook pattern)
- Sanitize LLM-generated summaries via ironclaw_safety::Sanitizer before
  writing to workspace (mitigates stored prompt injection vector)

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

* fix(memory): CI compile fix + reasoning sanitizer parity + harden test

- Add live_state / live_state_started_at fields to ConversationSummary
  literals in three session-summary test sites; staging added these
  fields after the branch was created and clippy/test builds were
  failing on missing-field errors.
- Replace silent unwrap_or_default on MissionsConfig::resolve in
  bridge::router::init_engine with an explicit warn-and-default match,
  so a misconfigured MISSION_INSIGHTS_INTERVAL surfaces in logs instead
  of being absorbed into the default.
- Run the reasoning-synthesis output through ironclaw_safety::Sanitizer
  before persisting it to the tool result, matching the parity already
  applied in SessionSummaryHook. Memory chunks fed into synthesis can
  carry attacker-controlled text and the synthesis flows back into
  future LLM contexts via memory_search results.
- Strengthen reasoning_enabled_fires_llm_and_returns_synthesis: add a
  preflight assertion that FTS returns the seeded doc, then
  unconditionally assert the LLM was called once and that synthesis
  matches the mocked response. Removes the prior `if llm.calls() > 0`
  guard that made the synthesis assertions vacuous when search returned
  empty.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
…earai#3045) (nearai#3243)

* feat(host-api): add runtime policy vocabulary (PR 1 of nearai#3045)

First PR in the runtime-presets stack. Adds the shared contract
vocabulary the resolver, host runtime planner, settings/blueprint
surfaces, and audit log will consume. No resolver logic, no behavior
changes — vocabulary only.

New `ironclaw_host_api::runtime_policy` module:

- `DeploymentMode`: where IronClaw is running and who owns the
  machine boundary (LocalSingleUser / HostedMultiTenant /
  EnterpriseDedicated).
- `RuntimeProfile`: the operator/user-selected preset (12 variants
  spanning SecureDefault, Local{Safe,Dev,Yolo}, Hosted{Safe,Dev,
  YoloTenantScoped}, Enterprise{Safe,Dev,YoloDedicated}, Sandboxed,
  Experiment). Family predicates `is_local`/`is_hosted`/
  `is_enterprise`/`is_yolo` are partition-checked in tests.
- `EffectiveRuntimePolicy`: aggregate of resolved backend + mode
  choices, plus both requested and resolved profile so audit can
  render "you asked for X, you got Y" when policy reduced authority.
  `was_reduced()` predicate flags the narrowing case.
- Backend/mode enums consumed by `EffectiveRuntimePolicy`:
  `FilesystemBackendKind`, `ProcessBackendKind`, `NetworkMode`,
  `SecretMode`, `ApprovalPolicy`, `AuditMode`. All snake_case on the
  wire; `as_str()` matches the serde wire name.

Module documentation explains the boundary against `RuntimeKind`
(execution lane, not authority — the issue forbids
`RuntimeKind::Local`) and `TrustClass` (per-invocation authority
ceiling, composes with runtime policy rather than replacing it).
The resolver itself, settings/CLI selection, capability surface
filter, and host runtime planner integration land in subsequent
PRs (PR 2-7 of nearai#3045).

Test plan:

- [x] `cargo test -p ironclaw_host_api` — 38/38 (31 existing + 7 new
      vocabulary tests covering family predicates, yolo predicate,
      serde round-trips, `was_reduced` flag, and `as_str` ↔ wire-name
      consistency).
- [x] `cargo clippy --workspace --all-features --tests` — zero
      warnings.
- [x] `cargo fmt --all -- --check` — clean.
- [x] `cargo test -p ironclaw_architecture
      reborn_crate_dependency_boundaries_hold` — pass.

Closes part of nearai#3045 (PR 1 of 8).

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

* feat(runtime-policy): add resolver crate (PR 2 of nearai#3045)

New `ironclaw_runtime_policy` crate. Pure-logic resolver that turns
the operator's request — `(DeploymentMode, RuntimeProfile, OrgPolicy)`
— into an `EffectiveRuntimePolicy` consumed by the host runtime
planner.

Safety invariants enforced:

- **Monotonic**: deployment mode and tenant/org policy may *reduce*
  the requested profile's authority; they may never *increase* it.
  Resolved profile is `min(requested, ceiling)` within the same
  family; ceilings with headroom keep the request, ceilings that
  narrow produce the ceiling.
- **Fail-closed by default**: invalid `(deployment, profile)` pairs
  are typed errors, not silent downgrades. Hosted multi-tenant
  rejects every Local* profile; enterprise rejects every Local*/
  Hosted* profile; etc. The `(deployment, profile)` compatibility
  matrix is a single readable `match`.
- **Yolo opt-in**: any `*Yolo*` profile requires
  `ResolveRequest::yolo_disclosure_acknowledged = true` —
  the resolver never sets this itself; CLI/settings/blueprint must
  capture explicit operator confirmation. `EnterpriseYoloDedicated`
  additionally requires `OrgPolicy::admin_approves_dedicated_yolo`.
- **Hosted boundary**: hosted multi-tenant resolution never produces
  `FilesystemBackendKind::HostWorkspace` or
  `ProcessBackendKind::LocalHost`. A regression test enumerates every
  hosted profile (including Sandboxed and the disclosure-acknowledged
  yolo variant) and asserts the property.

Per-profile backend mapping is centralised in `backends_for(deployment,
profile)` so the matrix is reviewable in one place. `Sandboxed`/
`Experiment` reuse the deployment-appropriate workspace backend
(ScopedVirtual/TenantWorkspace/OrgDedicatedWorkspace) so they can run
under any deployment without leaking provider-host paths.

Output is deterministic and round-trips through serde so audit logs
can record the exact policy that gated an invocation.
`EffectiveRuntimePolicy::was_reduced` flags the narrowing case.

Test plan:

- [x] `cargo test -p ironclaw_runtime_policy` — 17/17 covering
      compatibility matrix, yolo disclosure, org admin approval,
      ceiling narrowing within family, ceiling-with-headroom
      preserving request, family-mismatch and deployment-agnostic
      ceiling rejections, hosted multi-tenant boundary property,
      determinism, serde round-trip, and the full valid-pairs matrix.
- [x] `cargo test -p ironclaw_architecture
      reborn_crate_dependency_boundaries_hold` — pass (new crate
      depends only on `ironclaw_host_api`).
- [x] `cargo clippy -p ironclaw_runtime_policy --all-features --tests`
      — zero warnings.
- [x] `cargo fmt --all -- --check` — clean.

Builds on PR 1 (nearai#3243) — runtime policy vocabulary in
`ironclaw_host_api`. Settings/CLI selection (PR 3), capability surface
filter (PR 4), and host runtime planner integration (PR 5) consume
this resolver.

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

* feat(config): runtime profile selection from CLI + env (PR 3 of nearai#3045)

Adds the configuration surface for nearai#3045's runtime profile system.
Operators can now select deployment mode + runtime profile via:

- CLI flags: `--deployment-mode`, `--runtime-profile`,
  `--yolo-disclosure` (all global).
- Environment vars: `IRONCLAW_DEPLOYMENT_MODE`,
  `IRONCLAW_RUNTIME_PROFILE`, `IRONCLAW_YOLO_DISCLOSURE`.
- Defaults: `LocalSingleUser` + `SecureDefault` — the safest
  combination, never grants provider-host authority.

Selection precedence: CLI > env > default. The DB-backed setting
layer is reserved for a follow-up (the existing settings store has
its own coexistence story with `IRONCLAW_PROFILE` TOML overlay that
deserves its own change).

Implementation:

- `crates/ironclaw_host_api/src/runtime_policy.rs`: add
  `FromStr` impls for `DeploymentMode` and `RuntimeProfile` matching
  their snake_case wire names, plus `ParseRuntimePolicyEnumError`.
  Tests round-trip every variant against `as_str` to lock in the
  identity contract.
- `src/config/runtime.rs`: new module with `RuntimeConfig` (resolved)
  and `RuntimeConfigOverrides` (raw CLI inputs). `resolve_from`
  layers CLI > env > default, calls `ironclaw_runtime_policy::resolve`,
  and surfaces resolver failures as typed `ConfigError::InvalidValue`.
  `safe_default()` is the test/`for_testing` constructor and never
  fails.
- `src/config/mod.rs`: add `runtime: RuntimeConfig` to `Config`. The
  production `build` path resolves from env only; the new
  `Config::with_runtime_overrides` re-resolves with CLI overrides
  layered on top after `from_env*` returns. `for_testing` uses
  `RuntimeConfig::safe_default()`.
- `src/cli/mod.rs`: three new global args. The host_api enums'
  `FromStr` impls let clap derive parse them automatically.
- `src/main.rs`: wire CLI overrides into `Config::from_env_with_toml`
  via `.and_then(|c| c.with_runtime_overrides(&overrides))`.

Legacy env vars (`ALLOW_LOCAL_TOOLS`, `SANDBOX_POLICY`,
`SANDBOX_ALLOW_FULL_ACCESS`) are intentionally untouched — the
planner integration in PR 5 is the right place to reconcile them
against the resolved policy. This PR adds the surface; nothing
consumes the resolved policy yet.

Test plan:

- [x] `cargo test --lib config::runtime` — 8/8 covering: defaults,
      CLI > env precedence, env-driven resolution, yolo disclosure
      requirement, hosted-multi-tenant + Local* fail-closed, invalid
      env value typed error, `safe_default` construction.
- [x] `cargo test -p ironclaw_host_api` — 39/39 (31 existing + 8 in
      runtime_policy mod) including the new `FromStr` round-trip.
- [x] `cargo clippy --workspace --all-features --tests` — zero
      warnings.
- [x] `cargo fmt --all -- --check` — clean.

Stacks on top of PR 1 (vocabulary, nearai#3243) and PR 2 (resolver,
`ironclaw_runtime_policy`). PR 4 (capability surface filter) and PR 5
(host runtime planner) are the consumers.

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

* feat(tools): visible capability surface filter (PR 4 of nearai#3045)

Adds the visible-capability-surface filter from nearai#3045's "Visible
capability surface" rules. Profile-impossible affordances are hidden
from the model-facing tool list before the model call; everything
else stays visible and continues to fail structurally at action time
if authorization/approval/resource checks reject it.

This PR adds the filter mechanism + the registry seam, plus one tool
(`shell`) declaring its affordance as a worked example. Bulk
migration of other tools to declare their affordances is a deliberate
follow-up so each migration gets a focused review.

Implementation:

- `src/tools/tool.rs`: new `ToolRuntimeAffordance` enum (`None`,
  `AnyProcess`, `LocalShell`, `HostFilesystem`, `DirectNetwork`) and
  default `Tool::runtime_affordance() -> ToolRuntimeAffordance::None`.
- `src/tools/runtime_filter.rs`: `is_visible_under(policy,
  affordance) -> bool` with the per-variant matching documented on
  the affordance enum. Six unit tests pin the matrix:
  `None`-affordance always visible (even under
  `process_backend == None`), `AnyProcess` hidden only under `None`,
  `LocalShell` visible only under `ProcessBackendKind::LocalHost`,
  `HostFilesystem` visible only under
  `FilesystemBackendKind::HostWorkspace`, `DirectNetwork` visible
  under `NetworkMode::{Direct, DirectLogged}`.
- `src/tools/registry.rs`: new `ToolRegistry::list_visible_under` and
  `all_visible_under` methods, gated by both the existing
  engine-version filter and the new affordance filter. Existing
  `list` / `all` are unchanged.
- `src/tools/builtin/shell.rs`: `runtime_affordance` returns
  `AnyProcess`. Profiles that resolve to `process_backend == None`
  (e.g. `SecureDefault`) now hide shell from the model entirely.
  Tenant- and org-dedicated process backends satisfy the affordance —
  shell runs inside the matching sandbox, not on the provider host.

This is **visibility, not authorization** — per-invocation
authorization (capability grants, approvals, resource checks) still
runs on every call regardless of visibility. Reviewer guardrail in
`crates/ironclaw_host_runtime/src/lib.rs` explicitly punts the
caller-authority filtering decision to upper layers; this PR places
it at the tool registry, the closest "upper layer" boundary today.

Test plan:

- [x] `cargo test --lib tools::runtime_filter` — 6/6 covering the
      five affordance variants × the relevant backend/mode axes.
- [x] `cargo clippy --workspace --all-features --tests` — zero
      warnings.
- [x] `cargo fmt --all -- --check` — clean.

Stacks on PR 1 (vocabulary, nearai#3243), PR 2 (resolver), and PR 3
(settings/CLI). PR 5 will wire `list_visible_under` into the agent
loop's tool projection so the filter has end-to-end effect.

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

* fix(host-api): use exhaustive match in RuntimeProfile family predicates

Address gemini-code-assist review on PR nearai#3243 (4 inline comments,
all the same shape on lines 197 / 205 / 213 / 225 of
runtime_policy.rs).

The four `is_local` / `is_hosted` / `is_enterprise` / `is_yolo`
predicates used `matches!()` with a single arm, which silently
defaults a new variant to the negative case. These predicates gate
security-critical deployment boundary checks — a new `RuntimeProfile`
variant added without explicit categorization here would not be
flagged by the compiler and could land on the wrong side of the
hosted/local/enterprise/yolo axis.

Replace each `matches!()` with an exhaustive `match` that names every
variant. A new variant now produces a compile error until the author
makes a deliberate yes/no decision in each of the four predicates.

Verified: 17/17 resolver tests + 8/8 runtime_policy unit tests +
31/31 host_api crate tests still pass; workspace clippy and fmt
clean.

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

* fix(config): annotate `safe_default` expect with safety comment

The `No panics in production code` CI check rejected the
`.expect()` in `RuntimeConfig::safe_default()`. The expect is
load-bearing-safe: `(LocalSingleUser, SecureDefault, default
OrgPolicy, no yolo disclosure)` is structurally guaranteed to
resolve — `SecureDefault` is deployment-agnostic in the resolver's
compatibility matrix, isn't a yolo profile, and the empty
`OrgPolicy` never narrows. The `every_valid_deployment_profile_pair_resolves`
test in `ironclaw_runtime_policy::resolver::tests` locks this in.

Annotate the expect with the inline `// safety: ...` comment the
no-panics script recognizes, and explain the invariant in a
preceding doc-style comment for human readers.

Verified locally: `python3 scripts/check_no_panics.py --base
origin/reborn-integration --head HEAD` reports
`OK: No panic-inducing calls in changed production code.`

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

* feat(runtime-policy): planner + visibility wire-up + zmanian fixes (PR 5/6/7 of nearai#3045)

Closes the gap between the resolver-level policy substrate (PRs 1-4
already in this branch) and the model-facing tool list. After this commit,
PRs 5, 6, and 7 of the nearai#3045 decomposition are implemented in this PR;
PR 8 (blueprint/harness integration with nearai#3036) is explicitly excluded.

PR 5 — Host runtime planner integration

- New `ironclaw_host_runtime::planner` module: pure function
  `plan_capability(&CapabilityDescriptor, &EffectiveRuntimePolicy) ->
  Result<ExecutionPlan, PlannerError>`. The plan names the concrete
  filesystem/process/network/secret backend kinds the host runtime will
  dispatch against. Planner fails closed when a capability declares
  effects (`SpawnProcess`, `Network`, `UseSecret`) that the resolved
  policy disables.
- Public re-exports on `ironclaw_host_runtime`: `ExecutionPlan`,
  `PlannerError`, `plan_capability`.

PR 6 — Local profile vertical slice

- Integration tests in
  `crates/ironclaw_host_runtime/tests/runtime_policy_planner_contract.rs`
  drive `LocalSingleUser + LocalDev` through resolver → planner and
  assert HostWorkspace + LocalHost selection for the canonical
  filesystem.read / shell.cargo_test / shell.npm_test / shell.ripgrep /
  shell.git_status coding aliases.
- `LocalSafe` approval-preset assertion (`AskWrites`) locks in the
  resolver's mapping for the cautious local mode.

PR 7 — Hosted/enterprise enforcement + regression tests

- Hosted regressions: `HostedDev` + shell.run plans against
  `TenantSandbox`, never `LocalHost`. Filesystem write plans against
  `TenantWorkspace`, never `HostWorkspace`. `HostedYoloTenantScoped`
  with disclosure ack still cannot reach LocalHost / HostWorkspace.
- Enterprise regressions: `EnterpriseDev` plans against
  `OrgDedicatedRunner`. `EnterpriseYoloDedicated` requires both
  `EnterpriseDedicated` deployment AND
  `org_policy.admin_approves_dedicated_yolo = true` — without admin
  approval the resolver fails closed.
- `Experiment + package install` resolves to `SmolVm` / `Allowlist`.

Wiring the visibility filter into the model-facing tool list

- New `ToolRegistry::tool_definitions_visible_under(policy)` mirrors
  `tool_definitions_for_engine` but additionally filters by
  `runtime_filter::is_visible_under(policy, tool.runtime_affordance())`.
- `AgentDeps` now carries `runtime_policy:
  Option<EffectiveRuntimePolicy>`. `Some` in production
  (`Config::runtime.effective_policy.clone()`); `None` in tests.
- `dispatcher.rs` routes the chat turn's tool list through
  `tool_definitions_visible_under` when a policy is present, otherwise
  through the legacy unfiltered path.
- New integration test
  `tests/runtime_policy_tool_visibility_integration.rs` proves the chain
  Config → resolver → tool filter end-to-end (zmanian gap #3 — binding
  test that `is_visible_under` is actually called from the model-facing
  path). Includes pipeline test for
  `RuntimeConfig::resolve_from(overrides)` so the production wire matches.

zmanian review address

- `#[non_exhaustive]` on all eight wire-stable enums (`DeploymentMode`,
  `RuntimeProfile`, `FilesystemBackendKind`, `ProcessBackendKind`,
  `NetworkMode`, `SecretMode`, `ApprovalPolicy`, `AuditMode`). Resolver
  match arms updated with fail-loud wildcard panics so a forgotten
  variant fails in development rather than silently defaulting.
- Tightened `was_reduced()` doc to "tenant/org policy ceiling reduced
  authority within the same family" — deployment-mode reduction is
  impossible by construction (resolver returns
  `IncompatibleDeployment`).
- Renamed struct `OrgPolicy` → `OrgPolicyConstraints` to disambiguate
  from the wire-stable enum variants `ApprovalPolicy::OrgPolicy` /
  `AuditMode::OrgPolicy`. Enum variants stay as-is.
- `ParseRuntimePolicyEnumError` derives `Hash` (matching the parsed
  enums); `Copy` is intentionally not implemented because the type
  carries an owned `String`.
- `EnterpriseYoloDedicated` doc note + dedicated test
  (`enterprise_yolo_dedicated_uses_org_policy_approvals_not_minimal`)
  locks in the "yolo ⇒ Minimal approvals" exception: this profile uses
  `OrgPolicy` because it runs against a shared org boundary.

zmanian test gap fills

- Gap #1: `precedence_chain_cli_over_env_over_default_is_resolved_per_field`
  walks all three precedence layers per field in one scenario.
- Gap #2: full pipeline `Config → policy → tool filter` covered in the
  integration test above.
- Gap #3: binding test that `is_visible_under` is called from the
  model-facing tool-list path (above).
- Gap #4: serde round-trip tests for `OrgPolicyConstraints` and
  `ResolveRequest`. `ResolveRequest` now derives `Serialize`/`Deserialize`
  so settings/blueprint can persist a full request.

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

* fix(runtime-policy): close 6 zmanian review items (nearai#3243 iter-2 review)

Each fix has a regression test driving the bug shape.

HIGH: thread visibility filter through every model-facing call site

Iteration 1's `tool_definitions` build correctly used the
policy-filtered variant. After that, four other code paths reverted to
the unfiltered `tool_definitions()` — so under hosted-multi-tenant the
LLM saw the unfiltered tool list on iteration 2 onwards, on every
background-job worker turn, and on every routine-driven LLM iteration.
The "hosted multi-tenant cannot expose provider-host shell to the
model" property only held for iteration 1.

- `src/agent/dispatcher.rs:465` — the per-iteration refresh in
  `before_llm_call` now uses `tool_definitions_visible_under(policy)`
  when a policy is configured.
- `src/worker/job.rs:343, 1439` — background-job worker. Plumbed
  `runtime_policy` through `WorkerDeps` (with test fixture updates) →
  `Scheduler::set_runtime_policy` → `Agent` propagates it after
  scheduler creation.
- `src/agent/routine_engine.rs:1906` — routine-driven LLM iterations.
  Plumbed through `RoutineEngine::set_runtime_policy` →
  `EngineContext::runtime_policy` → the filtered call site.
- `src/worker/container.rs:163, 415` — scope-limited follow-up.
  Container worker runs inside its own Docker sandbox and registers a
  pre-attenuated tool set via `register_container_tools()`; the
  sandbox boundary is the primary security-property enforcer for the
  container path. Threading policy through orchestrator → worker
  HTTP handshake is intentionally deferred. Documented in-place.
- `src/channels/web/features/extensions/mod.rs:233` — observability
  listing. Shows registered tools to admins, not to the LLM. Action-
  time auth gates user-driven invocations. Documented in-place.

MED: deleted dormant `list_visible_under` / `all_visible_under`

Both methods on `ToolRegistry` had zero callers anywhere in the tree
(`git grep` confirmed). Only `tool_definitions_visible_under` actually
escaped the dormant set in the original PR. Deleted.

MED: extended tool affordance coverage beyond shell

Previously only `ShellTool` declared a runtime affordance. Added:
- `read_file`, `write_file`, `list_dir`, `apply_patch` →
  `HostFilesystem` (hidden under TenantWorkspace and
  OrgDedicatedWorkspace policies — the model sees memory_* tools
  instead, which are deployment-portable).
- `grep`, `glob` → `HostFilesystem` (same rationale; both walk the
  local filesystem).
- `http` → `DirectNetwork` (hidden under Brokered/Allowlist network
  modes; brokered HTTP is the supported route under hosted/enterprise
  deployments).

Three new integration regressions in
`tests/runtime_policy_tool_visibility_integration.rs` prove the host-
filesystem tools are hidden under HostedDev and visible under
LocalDev, and that `http` is hidden under both HostedDev and
HostedSafe.

LOW: renamed `every_valid_deployment_profile_pair_resolves` →
`every_non_yolo_deployment_profile_pair_resolves`

Test name now matches its scope. Yolo profiles need disclosure
acknowledgement (and `EnterpriseYoloDedicated` also needs admin
approval), and are covered by the dedicated yolo-gating tests.

LOW: tightened `EnterpriseYoloDedicated` doc to admit the
`DirectLogged` network widening

Profile uses `NetworkMode::DirectLogged` (wider than `EnterpriseDev`'s
`Allowlist`), and `ApprovalPolicy::OrgPolicy` (not `Minimal` like the
other yolo variants). Doc now lists those exceptions explicitly.

LOW: documented the planner's fail-close scope

Planner fails closed on `SpawnProcess`/`ExecuteCode`, `Network`,
`UseSecret`. `WriteFilesystem` against `ScopedVirtual` is *not*
rejected at plan time — write authorization is gated per-mount by
`CapabilityHost` + `MountView`, and per-mount granularity is richer
than a single profile-level boolean. Module rustdoc now explains the
non-goal.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
theredspoon pushed a commit that referenced this pull request Jun 21, 2026
Consolidated fixes for serrrfirat's 10 unresolved review threads plus
zmanian's CHANGES_REQUESTED review (3 blockers + 7 mediums + 3 follow-up
items + title typo).

## Blockers

1. CI now runs the package tests (zmanian blocker 2 / serrrfirat #7).
   The matrix `cargo test ${{ matrix.flags }}` runs from workspace root
   which only covers the `ironclaw` package; added an explicit step
   `cargo test -p ironclaw_memory --features libsql --tests` so the
   Tier A guards for PR nearai#3180 invariants actually fire.

2. `#[ignore]` markers converted to `#[cfg_attr(not(feature =
   "pr3180-ready"), ignore = ...)]` (zmanian blocker 1 / serrrfirat #1).
   Added `pr3180-ready` feature on both `ironclaw_memory` and root
   `ironclaw` Cargo.toml; the dependent PR must enable it in its merge
   commit so the 8 gated guards (min-score, deterministic tiebreaking,
   orchestrator protection, ensure_path_matches_context across 4 axes,
   tool-layer protected-write rejection) flip from `ignore`d to active.

3. Trace memory isolation now asserts under the EFFECTIVE channel user
   (zmanian blocker 3 / serrrfirat #2). Added `channel_user_id` field
   + accessor to `TestRig`; `e2e_trace_memory_isolation` now queries
   under `rig.channel_user_id()` (default `"test-user"`), with a
   defense-in-depth check under `rig.owner_id()` for mis-routing
   regressions.

## Test-correctness mediums

4. Min-score test pins `with_query_embedding([1,0,0])` to favor
   hybrid.md (serrrfirat #3 / zmanian #4). Removed the permissive
   `* 0.99` fallback — FTS-only-below-hybrid is now a hard assertion.

5. Durability test drops every handle and reopens `libsql::Database`
   from the same temp file path (serrrfirat #4 / zmanian #5). Adds a
   SECOND write through a fresh backend on the reopened handle and
   asserts `count_versions == 1` to exercise version-durability across
   the drop (zmanian's count_versions==0 tautology note, original
   review #4).

6. Append versioning asserts exact row count `== 1`, not `!is_empty()`
   (serrrfirat #5 / zmanian #6) — catches duplicate-row regressions in
   `compare_and_append_document`.

7. Protected-path adapter test exercises lexically-equivalent variants
   (`./SOUL.md`, `//SOUL.md`) in addition to canonical (serrrfirat #6 /
   zmanian #7). VirtualPath rejects `..` so no `..` variant; in-loop
   `count_documents_total == 0` after EACH variant.

8. Hybrid search isolation now varies all four scope axes (serrrfirat
   #8 / zmanian #8): tenant, user, agent, project. 5 documents seeded;
   search from caller scope must return exactly one.

9. Tool round-trip asserts EXACT persisted content via direct DB read
   (serrrfirat #9 / zmanian #9). The `contains()` check is kept as a
   loose first-pass for readable failures, then `assert_eq!` on the
   exact byte string is the load-bearing assertion.

10. Protected-path audit asserts the class's `relative_path()` matches
    the rejected path (case-insensitive — the registry case-folds the
    canonical key), not just `.is_some()` (serrrfirat #10 / zmanian #10).
    A regression that emits the wrong path class now fails.

## zmanian follow-ups

Z1. Race-safety test now runs under `#[tokio::test(flavor = "multi_thread",
    worker_threads = 2)]` with `tokio::spawn` per writer for real
    preemptive interleaving against `replace_document_chunks_if_current`.
    Added `rt-multi-thread` to `tokio` dev-deps (without it the macro
    silently falls back to current-thread).

Z2. `write_to_protected_path_rejected.json` trace fixture sets
    `all_tools_succeeded: false` explicitly. Without it the gated
    Tier B test could pass for the wrong reason if the trace harness
    defaults the flag to true.

Z3. Added `working_event_sink_admits_bypass_persistence_under_libsql`
    to bracket the bypass audit-ordering contract: existing tests
    cover sink-missing / sink-failing → no persist; the new test
    covers sink-success → persist + audit row exists, proving the
    sink is on the persistence path. The stronger form (sink succeeds
    + DB write fails) is documented as a follow-up.

## Cleanup

- Removed `_link_in_memory_repo_for_unused_imports` shim and the
  `InMemoryMemoryDocumentRepository` import that only existed to feed
  it (zmanian original-review #3).
- Fixed PR title typo `momery` → `memory` via gh.

Helper-consolidation into `tests/common/libsql_helpers.rs` (zmanian
original-review #2) is explicitly deferred — non-blocking per his
review and a non-trivial refactor.

## Verified

- `cargo fmt --all -- --check` clean
- `cargo clippy -p ironclaw_memory --features libsql --all-targets -- -D warnings` zero warnings
- `cargo test -p ironclaw_memory --features libsql` all suites green
  (gated tests stay `ignored` without `--features pr3180-ready`)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment