Skip to content

fix(actuation): preserve original origin through confirmation re-entry#167

Merged
ai-hpc merged 1 commit into
GeniePod:mainfrom
galuis116:fix/confirmation-preserves-original-origin
May 25, 2026
Merged

fix(actuation): preserve original origin through confirmation re-entry#167
ai-hpc merged 1 commit into
GeniePod:mainfrom
galuis116:fix/confirmation-preserves-original-origin

Conversation

@galuis116
Copy link
Copy Markdown
Contributor

Summary

Preserve the originating channel (Voice, Telegram, Dashboard, Api, Repl) when a pending sensitive home action is executed via the confirmation flow. Previously, ToolDispatcher::confirm_pending_home_action() rebuilt the ToolExecutionContext with request_origin: RequestOrigin::Confirmation, which silently bypassed [core.actuation_safety].max_actions_per_minute_by_origin, hid the originating channel in safety/actuation-audit.jsonl, and made [core.actuation_safety].allowed_origins overrides all-or-nothing for the entire confirmation flow. The confirmed: true flag (added in PR #141) is now the only signal the policy gate needs to know the action is pre-approved.

Fixes #166 .

Changes

  • crates/genie-core/src/tools/dispatch.rs::confirm_pending_home_action (L786-823): pass pending.requested_by as the re-entry request_origin instead of the hardcoded RequestOrigin::Confirmation. Added a multi-line comment explaining the invariant so future readers don't re-introduce the override.
  • crates/genie-core/src/tools/dispatch.rs::exec_home_control_inner (L609-615): when exec_ctx.confirmed == true, skip ActuationRateLimiter::check_and_record. The original-origin bucket already paid one slot on the request that returned ConfirmationRequired; the confirmation re-entry must not double-charge the same logical action.
  • crates/genie-core/src/tools/dispatch.rs tests: added a SensitiveHomeProvider fixture (returns voice_safe: false, domain: "lock") plus two helpers (extract_confirmation_token, audit_events_matching) so the confirmation path can be exercised end-to-end inside #[tokio::test].
  • Three new regression tests in the tests module:
    • confirm_preserves_original_origin_in_audit_log — reads back the actuation audit JSONL and asserts the Executed row's origin == "telegram" (was "confirmation").
    • confirm_does_not_bypass_per_origin_rate_limit — with max_actions_per_minute_by_origin = { telegram = 1 }, the second Telegram-initiated sensitive request inside the window is rejected with a "rate limit" message; only the first action reaches the HA provider.
    • confirm_does_not_double_charge_when_already_paid — with max_actions_per_minute_by_origin = { telegram = 2 }, request → confirm → request succeeds, proving the confirm did not consume the second slot.

No production-config defaults change. The behaviour of direct (non-confirmation) actuations is unchanged.

Real Behavior Proof

  • I have built and run the affected code locally.
  • I have NOT verified on Jetson hardware. The change is in pure dispatcher logic (no audio, voice, ALSA, CUDA, or Home Assistant runtime path), exercised end-to-end by #[tokio::test] against a mock HomeAutomationProvider. The equivalent verification path is the new in-process tests below plus the existing home_control_rate_limits_by_origin, home_control_respects_configured_allowed_origins, and action_history_hydrates_from_audit_log tests, all running green.

What I ran

# Build the workspace from a clean main + this branch:
cargo build
# → Finished `dev` profile [unoptimized + debuginfo] target(s) in 1m 26s
# (5 binaries produced under target/debug/)

# Targeted: the three new regression tests
cargo test -p genie-core --lib tools::dispatch::tests::confirm
# → running 3 tests
# → test tools::dispatch::tests::confirm_does_not_bypass_per_origin_rate_limit ... ok
# → test tools::dispatch::tests::confirm_does_not_double_charge_when_already_paid ... ok
# → test tools::dispatch::tests::confirm_preserves_original_origin_in_audit_log ... ok
# → test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 448 filtered out

# Targeted: the full dispatch test module (29 tests, includes the existing
# direct-path rate-limit, ACL, and history-hydration tests this PR must not regress)
cargo test -p genie-core --lib tools::dispatch::tests::
# → test result: ok. 29 passed; 0 failed; 0 ignored; 0 measured; 422 filtered out

# Full workspace test suite
cargo test
# → TOTAL passed: 608  failed: 0  ignored: 3
# (same as main baseline + the 3 new tests added by this PR)

What I observed

  1. Audit row now carries the originating channel. Inside confirm_preserves_original_origin_in_audit_log, a Telegram-initiated sensitive home_control { entity: "front door", action: "lock" } is issued, then confirmed via confirm_pending_home_action(token). The actuation audit JSONL contains exactly one "status": "executed" row and its origin is "telegram" — the value the test asserts. Before this patch the same setup produced origin: "confirmation", which is what the bug report attached to this PR documents.

  2. Per-origin rate limit is now honoured through the confirmation flow. Inside confirm_does_not_bypass_per_origin_rate_limit, with max_actions_per_minute_by_origin = { telegram: 1 }:

    • First Telegram request → ConfirmationRequired (charges slot 1).
    • Confirm → executes (no recharge, per the confirmed-guard in exec_home_control_inner).
    • Second Telegram request → rejected by the rate limiter with output.to_lowercase().contains("rate limit"). Only one action reached the SensitiveHomeProvider::execute. Before this patch the second request succeeded because it would have been routed through the Confirmation bucket (default global 12/min).
  3. No double-charge on confirm. Inside confirm_does_not_double_charge_when_already_paid, with telegram = 2: request → confirm → request succeeds, proving the confirm did not push a second timestamp into the Telegram bucket. Before this patch the second request would have been rejected (bucket would be at 2/2 after the confirm: 1 for the issue, 1 for the re-entry).

  4. No direct-path regression. The pre-existing home_control_rate_limits_by_origin (two RequestOrigin::Dashboard calls with max_actions_per_minute_by_origin = { dashboard: 1 }, second must be rate-limited) still passes, as does home_control_respects_configured_allowed_origins and home_control_records_action_history. Full cargo test matches the main-baseline 608 / 0 / 3.

Test plan

A reviewer can re-verify on any Rust 1.85+ host (no Jetson, no Home Assistant, no audio needed):

  • Check out this branch and run cargo test -p genie-core --lib tools::dispatch::tests::confirm — three tests, ~1s, all green.
  • Run the broader cargo test -p genie-core --lib tools::dispatch::tests:: — 29 tests, all green.
  • Optional end-to-end manual proof against a real genie-core:
    1. Add a non-voice-safe entity in your dev Home Assistant (any lock.*, cover.* garage, or alarm_control_panel.*).
    2. In geniepod.toml, set [core.actuation_safety] with enabled = true, max_actions_per_minute_by_origin = { telegram = 1 }, default allowed_origins.
    3. Start genie-core against your dev HA (HA_TOKEN=… cargo run --bin genie-core).
    4. Trigger the sensitive action via the Telegram bot (or whichever channel you wired); you'll get a Pending token: act-… back.
    5. curl -X POST "http://127.0.0.1:3000/api/actuation/confirm?token=act-…" — the action executes.
    6. tail -n 2 data/safety/actuation-audit.jsonl — the "status":"executed" row carries "origin":"telegram" (was "confirmation" on main).
    7. Trigger the same Telegram action again inside the 60s window. It's now rate-limited (was previously accepted because the confirmation re-entry recharged the Confirmation bucket, not the Telegram bucket).

Notes for reviewers

  • RequestOrigin::Confirmation is now unwritten in production code — the only writer was the hardcoded re-entry this PR removes. The variant is left in the enum for serde-backward-compat with any historical entries that may already be on disk in safety/actuation-audit.jsonl from operators running pre-fix builds. A follow-up PR to delete the variant + its Display/as_policy_key branches + the "confirmation" entry in default allowed_origins can land cleanly once we're comfortable that no one needs to read those historical rows back through the enum. Kept out of this PR to minimise rebase churn against test(home): regression for confirmed sensitive action at policy gate (closes #162) #163 and to keep the diff focused on the behavioural fix.
  • Timing. Best to land after PR test(home): regression for confirmed sensitive action at policy gate (closes #162) #163 (test(home): regression for confirmed sensitive action at policy gate) merges. That PR scaffolds the gate-side test for the same flow; rebasing this PR on top of it is cheaper than the reverse and keeps the new dispatcher tests adjacent to test(home): regression for confirmed sensitive action at policy gate (closes #162) #163's gate test in git log.
  • Audit-trail back-compat. This PR does NOT rewrite historical audit rows. Operators upgrading from a previous build will continue to see origin: "confirmation" on rows their old build wrote; only NEW confirmed executions carry the original channel. If a "rewrite old rows" migration is wanted, it should be its own PR.
  • No config schema change, no default behavioural change for direct (non-confirmation) calls. Operators who never used [core.actuation_safety] overrides see no difference. Operators who did configure max_actions_per_minute_by_origin now have those limits actually enforced through the confirmation flow — that is the bug fix, and is the only operator-visible behavioural change.

@ai-hpc ai-hpc merged commit a46222b into GeniePod:main May 25, 2026
6 checks passed
@ai-hpc
Copy link
Copy Markdown
Contributor

ai-hpc commented May 25, 2026

Reviewed and merged: #166 is valid, and this preserves the original actuation origin through confirmation while avoiding double-charge regressions covered by dispatcher tests. Merged at a46222b; thanks @galuis116.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[bug] [bug] actuation: confirm_pending_home_action overrides request_origin with Confirmation — bypasses per-origin rate limit, ACL, and audit trail

2 participants