Skip to content

feat!: a run acts as its invoker — remove shared-route subject binding (#7157 follow-ups) - #7377

Merged
BenKurrek merged 38 commits into
mainfrom
run-acts-as-invoker
Aug 8, 2026
Merged

BenKurrek merged 38 commits into
mainfrom
run-acts-as-invoker

Conversation

@BenKurrek

@BenKurrek BenKurrek commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-ups to #7157, applying one decision — a run acts as the user who invoked it — in the three places it was still split, plus the full review-hardening pass from the 2026-08-08 multi-agent audit (every must-fix and follow-up finding is folded into this PR; see "Review hardening" below):

  • Scope = invoker, everywhere. Shared-route subject binding is removed end to end: a shared channel conversation binds one thread per (conversation, user), each owned by the actor who invoked the bot. The subject half of channel configuration is retired; the admission half stays, fail-closed (*_allowed_channels connected-channel lists, checked on resolve, lookup, and reset through the new SharedConversationAdmission port — the reset checkpoint is now test-pinned too). Channels whose actor identity is not per-user (no OAuth vendor, no pairing strategy) never receive an admission resolver at all: an operator-identity channel admitting a group would run every participant as the operator, so that class is now closed structurally rather than by manifest inventory.
  • The approval-gate dance is scoped as the acting user — via one contract derivation. The interim acting_user_id/effective_user_id split in outbound_delivery.rs is unified onto LoopRunContext::acting_user_id, and the audit finished the job: the whole scope recipe is now LoopRunContext::acting_resource_scope on the contract type, and every remaining hand-rolled ladder (composition's owner-first resource_scope_for_run for workspace/skill mounts, the inline grant-minting copy, project_create_capability's effective_user_id) delegates to it. On the only run shape where owner and actor can differ — legacy runs parked across this deploy — mounts and grants now follow the ACTOR, matching gates, settings, and deliveries (pin-change recorded in visible_capability_request_uses_acting_user_for_runtime_scope).
  • Every parked gate is announced — on both delivery lanes. Gate notices are keyed by their gate ref in the live observer AND the triggered/background lane (the sibling instance of the same collapse: a background run's second gate previously minted the identical projection id, deduped to AlreadyDelivered, recorded the delivery Failed, and was never announced or reply-routable). Over-long discriminators are hash-bounded so a maximal legal TurnGateRef can never make a notice undeliverable at the ProjectionUpdateRef cap.
  • Telegram groups get a connect affordance. telegram_allowed_channels added (same generic convention as Slack). Previously any group the bot was added to ran as the deployment operator.

The thread-model decision (owner call)

Per-invoker scope cannot preserve the one-canonical-thread-per-channel model. The owner chose (a) one thread per (channel, user): stable ownership, participants fully isolated. User-visible consequences:

  • Each shared-channel participant gets their own persistent thread; there is no cross-user shared context (Carol cannot follow up on Bob's summary).
  • Every participant must be paired, and unpaired participants never run as the operator. In a shared channel an unpaired participant gets no reply at all — the host-authored one-person pairing prompt deliberately never lands in a shared room; pairing happens through Extensions, and direct messages still offer the connect prompt. (The operator docs, CHANGELOG, and the silence pin in run_delivery_contract.rs all state this; a group-safe pairing affordance, e.g. DM-ing the pinger, is possible follow-up work.)
  • The invoker sees and approves their own runs' gates; the invoker's notification preferences and approval settings govern their runs.
  • Shared channels are no longer offered as per-user notification delivery targets (their per-user ownership came from the retired subject map); DM targets are unchanged, and stored channel-target preferences fail closed at resolution. Re-introducing channel targets under a participation rule is possible follow-up work.

What happened to persisted bindings with an operator subject

Ignored-but-retained, pinned with a restart-path test (legacy_subject_owned_shared_binding_is_retained_but_unreachable_after_reopen): the domain key for shared routes gained a serde-defaulted per-actor component, so

  • legacy Direct keys deserialize byte-identically — DM threads keep full continuity;
  • legacy shared rows (keyed by conversation alone, owned by the configured subject) deserialize to a key no per-actor lookup ever builds — retained in durable state untouched (LLM data is never deleted; the old thread stays visible to its owner), and every participant's next message starts a fresh thread they own. The one key shape a legacy shared row CAN collide with — a Direct request for the same conversation — is now refused outright as a route-kind mismatch (BindingRequired) on resolve, lookup, reset, and link, so no adapter re-classification can ever resurrect a legacy subject-owned thread (Direct-probe leg added to the restart-path test);
  • the forward half is pinned too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a NEW per-actor shared binding resolves the same thread after a restart (a deserialize-side regression on the key component would otherwise orphan every group thread silently);
  • legacy saved *_subject_routes config values are inert (pinned by test); channels admitted only through them must be re-admitted via *_allowed_channels (CHANGELOG + operator docs updated).

The deploy boundary (owner ≠ actor runs parked across this deploy)

Runs raised before this deploy with owner ≠ actor and resumed after it are the one surviving owner≠actor population. Three failure modes, all terminal-and-recoverable, all deliberate (the alternative was a permanent dual-derivation fallback):

  1. Resume-payload miss (documented from the start): the scope-keyed replay payload was saved under the owner-derived scope; the resume loads under the actor and misses once, failing closed (replay payload is unavailable). Re-requesting approval recovers. Now pinned on BOTH ports: approval_resume_with_missing_replay_payload_fails_closed (runtime port) and approval_resume_misses_owner_scoped_replay_payload_and_fails_closed + its acting-scope positive control (synthetic port).
  2. New-raise evidence miss: a NEW gate raised post-deploy inside such a resumed run saves its approval row under the actor while the blocked-exit evidence check reads the owner-side scope — the park is rejected and the run fails once; the participant's next message lands in a fresh owner==actor thread.
  3. One-time gate-prompt re-announcement: a gate prompt delivered but not yet acknowledged at deploy time re-announces once afterwards — its durable delivery identity is now keyed by the gate ref (both lanes), so the pre-deploy AlreadyDelivered record no longer matches. Refless kinds (FinalReplyReady etc.) keep their historical id shape byte-for-byte (pinned).

The approval raise/resume coverage (Task 2)

Written first, at the capability-host tier: notification_channels_set_approval_raise_and_resume_stay_scope_matched_when_owner_differs_from_actor drives the full dance — raise, replay payload, approve (lease minted from the stored row's own scope/grantee/fingerprint, as production click-approval does), approved resume, lease claim, dispatch, lease consume — on a run whose thread owner differs from its acting user.

  • It passed on the interim split (commit 2dd50af3d, gate scoped to the owner), and passes after unification (gate scoped to the actor) — the identity pin flipped as a recorded decision in the unification commit.
  • In between, it caught a real bug: the resume-side replay-payload load lives in ironclaw_loop_host's synthetic-capability wrap — a different crate from the raise-side save — and still derived owner-first, stranding every approved resume with "replay payload is unavailable". The acting-identity ladder now has exactly one definition (LoopRunContext::acting_user_id, with the full scope recipe as LoopRunContext::acting_resource_scope) and every derivation in the workspace delegates to it — the hand-synced-copy class is closed, not re-synced. The ladder itself is unit-pinned in its owning crate (all three rungs, including the configured-fallback rung).
  • Tier note: it sits at the capability-host tier rather than tests/integration/ because this PR makes owner ≠ actor unconstructible through every product front door — that run shape survives only as legal kernel state (runs parked across the deploy). The owner == actor dance stays covered end-to-end at the integration tier (outbound_target.rs).
  • Paying for the shared ladder: loop_contracts' size ceiling was originally re-captured downward via the run_context/tests.rs split; the ladder tests and acting_resource_scope re-capture it with dated provenance.

Sabotage verification (Task 3, gate-notice collision)

observer_delivers_a_prompt_for_each_distinct_approval_gate drives the real DeliveryCoordinator over the real outbound store through two scripted gates and asserts two delivered prompts plus a recorded reply route for each. Verified live by sabotage: reverting the discriminator to None fails exactly this test; restored and green. The audit added the missing legs: observer_dedupes_a_reannounced_gate_and_still_delivers_the_next (g1 → g2 → g1: the repeat dedupes, nothing double-posts), observer_delivers_a_prompt_for_each_distinct_auth_gate (the BlockedAuth arm has its own discriminator wire), and triggered_second_gate_announces_instead_of_deduping_against_the_first (the background lane, where the un-fixed collapse recorded the delivery Failed and left gate two unroutable).

Review hardening (2026-08-08 audit — all findings folded in)

  • Both red CI pins fixed: the extension_manager wire-shape pin gained the telegram_allowed_channels row (production projection was correct), and the identity-parity root bin was re-pinned to the per-actor model (distinct threads, per-actor owners — its identity-prompt isolation payload is retained and now the same-room multi-actor end-to-end lock).
  • Triggered-lane gate keying fixed + pinned (above); projection-id discriminators hash-bounded with shape pins (run-notification:{kind}:{run_id} refless shape byte-stable, FinalReplyReady included).
  • Identity unification completed across all surviving ladders (above); the same-name/opposite-precedence resource_scope_for_run trap is gone.
  • widen_binding_route_access and ReplyRouteAccess::allow_shared deleted (structurally unreachable under per-actor keying — every Shared-keyed row is born shared; the persisted flag stays for legacy reads).
  • SharedChannelAdmissionHandles collapsed to the one declared handle (Option<String> at scan, plain String in the resolver — "installed but handle-less" is no longer representable); no-auth-vendor channels never build an admission resolver (structural closure, above).
  • docs/reborn/contracts/conversation-binding.md amended (rules 8/14/24 + the admission and retention semantics — it is the owning contract for this behavior); operator docs and CHANGELOG corrected to the real unpaired-participant behavior; CHECKLIST.md state contradiction reconciled.
  • Vocabulary sweep: retired "subject" phrasing removed from live identifiers, test names, assertion messages, and doc comments across test support and host seams (canonical_actor_user, resolve_default_binding_actor_user, re-narrated legacy-shape tests, reworded workflow_request/ChannelHostIdentity docs); vestigial TenantId keep-alive and the unconditionally-Ok resolved_binding_from_resolution fallibility cleaned up where touched.
  • observer.rs carries the arch-exempt: large_file annotation its size requires; REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through live-canary.yml; CHANGELOG sections follow Added/Changed/Removed order.

Change Type

  • Bug fix
  • New feature
  • Security
  • Documentation

Linked Issue

Follow-up to #7157 (same underlying decision; branched off its head).

Validation

  • cargo fmt --all -- --check
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings
  • cargo build (via full test builds)
  • Relevant tests pass — see Test Strategy
  • Postgres-backed integration legs ran under colima (DOCKER_HOST=unix://$HOME/.colima/default/docker.sock)
  • Manual testing: not performed; behavior pinned at unit/contract/integration tiers

Test Strategy

User behavior: shared-channel participants each get their own paired, self-owned thread; the invoker approves their own gates and every parked gate is announced on both delivery lanes; unconnected shared conversations are rejected; unpaired shared-channel participants get silence, never the operator's identity.

Risk areas:

  • Side effect
  • Persistence
  • Security or permissions
  • Cross-component behavior

Tests added or updated:

  • Unit or contract: conversations per-actor + trusted-owner pins, legacy morphs, the legacy Direct-probe refusal, and the per-actor restart-continuity pin; shared_admission port shape; ChannelConfigSharedAdmission (admit/deny/malformed/foreign/per-request/legacy-inert); assistant admission coverage (recording/failing/admit-all doubles, not-connected rejections incl. existing bindings and the RESET checkpoint, admission-precedes-side-effects, direct-never-consults, per-actor threads, cross-actor lookup denial); authorization_and_approval_scopes_both_follow_the_actor; the contract-tier three-rung ladder pins (acting_user_id, acting_resource_scope); the two-gate observer regression (sabotage-verified) + same-gate dedupe + two-auth-gate legs; the triggered-lane two-gate regression; projection-id shape pins (refless byte-stability incl. FinalReplyReady, verbatim short refs, bounded over-long refs); the owner≠actor raise/resume dance (passed before and after unification) + the synthetic-port resume-miss pin with its acting-scope positive control; the runtime-scope pin flipped to the acting user as a recorded decision.
  • Reborn integration: extension_delivery (telegram supergroup admitted via the new handle, reply as the invoker; stored shared targets fail closed); delivery_user_journeys; outbound_target; mcp; trace_capture; generated_gate_sequences; group_journeys; group_multiuser (multi-actor isolation pins unchanged and strictly stronger); reborn_identity_prompt_scope_isolation_parity re-pinned to the per-actor model.
  • Recorded fixture: Not applicable: no model tool-choice behavior changed (tool schemas/prompts untouched)
  • Browser E2E: Not applicable: no WebUI frontend change (zero frontend references to the subject model — verified by sweep)
  • Backend or runtime: conversations restart-path legacy-envelope + per-actor continuity tests (in-memory backend over the real envelope codec); Postgres integration legs via testcontainers as above
  • Live canary: orphaned subject env var removed from live-canary.yml; the new allowed-channels env var threaded; live-QA scripts re-pinned

What the tests prove: raise and resume derive one scope from one contract method (a half-unified derivation strands the approved capability — demonstrated in-PR, and the accepted deploy-boundary miss is itself pinned on both ports); admission fails closed on every path including existing bindings and reset; per-actor keying makes widening/probing another user's binding structurally impossible, and the legacy key-collision shape is refused; legacy data is retained but never silently reused, and new per-actor data survives restarts; each distinct gate reaches the user and is reply-routable on both delivery lanes, repeats dedupe, and no legal gate ref can make a notice undeliverable.

Security Impact

This PR is a security tightening: removes the shared-channel → operator-scope split (any participant could previously reach the operator's delivery targets/settings surface area; #7157 closed the worst reads/writes, this removes the split itself); admission is fail-closed on every binding path and never installed at all for operator-identity channels; unpaired participants can no longer act at all in shared channels; Telegram groups previously ran as the operator for anyone who added the bot — now allowlisted per chat; mounts, grants, gates, settings, and deliveries all derive one acting identity from one contract method. No new network calls, secrets handling, or sandbox changes.

Reborn Trust-Boundary Checklist

  • Trust-bearing types: SharedConversationAdmission is a decision port (bool), constructed only by channel-host assembly/extras — and only for channels with per-user actor identity; no new evidence minting
  • No new untrusted content enters prompts
  • No new hashing beyond the stable FNV-1a projection-id bound (host-side dedupe key composition; not security material — the sha256-derived managed subject was deleted)
  • New/changed variants: no enum variants changed; the not-connected rejection and the route-kind-mismatch refusal both reuse BindingRequired; downstream match sites unaffected
  • serde(default) on BindingKey.shared_actor_user_id fails closed by construction (legacy shared rows become unreachable, never mis-owned; the Direct-collision shape is refused) and has migration tests in both directions
  • No new unbounded collections
  • Error classes: config-store failures stay Transient (pinned with projection test); rejections stay permanent
  • No sandbox/native naming changes

Database Impact

None (no SQL migrations). The conversations envelope (/conversations/state.json over RootFilesystem) gains an optional serde-defaulted key component; old envelopes load unchanged (round-trip + legacy-shape tests on the shared codec; backend choice is below this layer).

Blast Radius

Channel binding resolution (Slack/Telegram shared conversations), notification-channel capability gating, run-notification delivery identities (both lanes), outbound target listings, operator channel configuration, and — for the legacy parked-run population only — mount/grant scoping (now actor-first like the rest of the dance). WebUI/openai_compat submit only Direct routes and are unaffected. The no-auth-vendor → operator residual is closed structurally: such channels can no longer admit shared conversations at all.

Rollback Plan

Revert the branch. Shared-channel data is NOT silently ignored on revert: the old BindingKey deserializer has no deny_unknown_fields, so every per-actor row for one conversation collapses onto the single legacy conversation-scoped key (map insertion order decides the arbitrary survivor), and the first post-revert save persists that collapse — the losing participants' binding rows are dropped from durable state and the channel re-binds to the surviving participant's thread under the old shared semantics (threads and messages themselves are ordinary retained threads and survive either way). A safe rollback therefore means either draining shared-channel traffic first or accepting one arbitrary re-binding per shared conversation. DM bindings round-trip byte-identically and are unaffected. The retired Slack config fields would need re-adding to the manifest on revert. No migrations to unwind.

Review Follow-Through

  • The in-flight-gate deploy boundary (all three modes above) is accepted and documented; flag if you want a dual-derivation fallback instead.
  • Shared channels as notification delivery targets are removed pending a participation rule (the caller holds a binding in the channel) — follow-up candidate.
  • A group-safe pairing affordance for unpaired shared-channel participants (e.g. DM the pinger) — follow-up candidate; blocked on distinguishing the not-connected and not-paired rejections, which deliberately share BindingRequired.
  • Not taken on (per the brief): moving trigger_delivery_migration.rs into ironclaw_triggers; the Task 1 work did not invert that registry lookup.

Review track: C (security/runtime)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

BenKurrek and others added 30 commits August 7, 2026 00:07
…ls, delivery heuristics deleted

Re-landed PR #7157 on current main (aa8b748 → 967f33a, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc (#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da (#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as #6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d (#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from #7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb (#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; #7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

# Conflicts:
#	tests/snapshots/golden_payload__context_surfacing.snap
…guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts:
#	crates/app/ironclaw_composition/src/runtime/tests/outbound_delivery.rs
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism #7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
…r ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split #7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
…e acting user

Unify the interim #7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
… scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy
BenKurrek and others added 3 commits August 8, 2026 12:10
… scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…annels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7377 August 8, 2026 16:11 Destroyed
@BenKurrek

Copy link
Copy Markdown
Collaborator Author

Review triage — every open thread addressed

All 20 inline review comments (serrrfirat's audit findings + CodeRabbit) are resolved by the review-hardening commits now on the branch. Dispositions, thread by thread:

serrrfirat (audit findings):

  1. memory.rs:1574 — Direct/legacy key aliasing splits threads and reaches legacy rows. Fixed, hardened beyond the report. A Direct request that key-collides with a retained legacy shared row is now refused outright as a route-kind mismatch (BindingRequired) on resolve, lookup, reset, and link, and the restart-path test gained a Direct-probe leg. Note the aliasing was not a regression — the pre-feat!: a run acts as its invoker — remove shared-route subject binding (#7157 follow-ups) #7377 key had no route/actor discriminator at all, so the identical request reached the identical row before; reachability strictly shrank and is now refused entirely. Kind-flapping for one conversation ref is not something today's adapters can produce (Slack C*/D*, Telegram positive/negative ids are disjoint); the guard makes that assumption enforced rather than assumed.
  2. memory.rs:953 — per-(conversation,actor) rows multiply the whole-state blob. Accepted, no change. Pre-existing whole-state store design; the audit's performance reviewer returned zero findings at threshold. Worth revisiting if group sizes grow, tracked as store-level work, not binding-model work.
  3. channel_shared_admission.rs:152 — per-message uncached config read. Accepted, no change. The per-request read is the fail-closed design (a disconnect takes effect immediately); caching would add invalidation complexity for a small JSON parse. Revisit only with profiling evidence.
  4. conversation_binding.rs:512 — reset admission untested. Fixed. shared_admission_gates_every_resolve_without_rebuilding_scope gained the reset leg: disconnected shared conversation → reset refused with the not-connected rejection, nothing rotates, and the actor's thread survives re-admission intact.
  5. run_context.rs:300 — ladder lacks a contract-tier test; fallback rung untested. Fixed. Three-rung table test (acting_user_id_prefers_actor_then_owner_then_configured_fallback) plus acting_resource_scope pins, in the owning crate.
  6. outbound_delivery.rs:290 — owner-first twin with the same name survives and scopes mounts. Fixed. All surviving hand-rolled ladders now delegate to LoopRunContext::acting_user_id / the new acting_resource_scope (capability_host mounts + inline grant minting, project_create's effective_user_id deleted); the pin flip is a recorded decision (visible_capability_request_uses_acting_user_for_runtime_scope). The same-name/opposite-precedence trap is gone.
  7. channel_shared_admission.rs:485 — vestigial TenantId keep-alive. Fixed (line, const, and import removed).
  8. channel_shared_admission.rs:47 — doc grammar regression. Fixed in the handles-collapse rewrite (the struct is gone; the scan fn's doc reads naturally).
  9. memory.rs:913 — widen unreachable under per-actor keying. Fixed. widen_binding_route_access and ReplyRouteAccess::allow_shared deleted; the persisted shared flag stays for legacy reads.
  10. inbound_turn.rs:1423 — thread_scope_from_binding duplicated. Fixed. The private copy is gone; run_delivery.rs's pub(crate) fn is the one definition (workflow.rs's TurnScope builder cross-references it).
  11. synthetic_capability.rs:36 — scope-recipe duplicated one level above the ladder. Fixed. The whole recipe is LoopRunContext::acting_resource_scope on the contract; both crate-local copies are deleted.
  12. channel_shared_admission.rs:55 — one-field handles wrapper. Fixed. Scan returns Option<String>; the resolver holds a plain String ("installed but handle-less" is no longer representable).
  13. observer.rs:815 — gate-ref ids don't dedupe against pre-upgrade records. Accepted + documented. One re-announcement per gate prompt delivered-but-unacked at deploy time, on both lanes now; recorded in the PR body's deploy-boundary section and the CHANGELOG. Inherent to fixing the collapse bug; a transitional legacy-id probe wasn't worth permanent code.

CodeRabbit:

  1. conversation_state_store_contract.rs:548 — unreachability proven only from the Shared path. Fixed — Direct-probe leg added (see Move whatsapp channel source to channels-src/ for consistency #1), plus the new forward-continuity pin per_actor_shared_bindings_keep_their_threads_across_reopen.
  2. inbound_contract.rs:395 — test name stale. Fixed — renamed to what the body proves (stored_shared_reply_target_resolves_both_access_kinds_only_for_its_own_actor).
  3. extension_host/lib.rs:161 — restrict crate-local re-exports. Fixed — the root pub use of the admission items is removed; in-crate consumers use the module path.
  4. telegram/manifest.toml:28 — add unlisted-group rejection coverage. Fixed — extension_delivery.rs gained the caller-path leg: correctly-signed webhook for an unlisted supergroup is acknowledged (no vendor retry storm) and produces no turn and no reply.
  5. CHECKLIST.md:157 — contradictory open status. Fixed with a dated ✎ reconciliation note in the file's house style.
  6. group.rs:809 — rename canonical_subject_user. Fixed (canonical_actor_user, locals and comments included).
  7. binary_e2e.rs:478 — rename resolve_default_binding_subject_user. Fixed (resolve_default_binding_actor_user, call-site locals included).

Also folded in from the multi-agent audit (not raised in these threads): the triggered/background lane's sibling gate-collapse fix + regression, the identity-parity re-pin, projection-id hash bounding, the no-auth-vendor structural closure, the conversation-binding contract amendments, the corrected rollback plan, and the unpaired-shared-participant docs alignment. Details in the updated PR body.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/extensions/ironclaw_extension_host/src/channel_host.rs`:
- Around line 824-834: Update the shared-admission selection around
shared_admission and default_shared_admission to return None immediately when
actor_identity_is_per_user is false, before consulting extras.shared_admission
or the manifest resolver. Preserve override and default resolution only for
per-user identity channels, and add a caller-path regression test injecting
ChannelExtras.shared_admission for a channel without identity lookup to verify
shared ingress is rejected.

In `@crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs`:
- Line 45: Use crate-relative paths for all internal channel admission
references: update handle_declares_field in channel_outbound_targets.rs to
import through crate::channel_shared_admission, and update
shared_channel_admission_handle plus ChannelConfigSharedAdmission in
channel_host.rs to use crate::channel_shared_admission.

In `@crates/product/ironclaw_assistant/src/channel_workflow.rs`:
- Around line 85-88: Update the comment describing the composed runtime’s
fallback operator user to insert “that” after “user,” making the sentence read
grammatically and clearly identify the user to whom host-initiated work is
attributed.

In `@tests/integration/extension_delivery.rs`:
- Around line 1723-1764: Extend the unlisted-update test around the ingress
request to capture the caller-visible turn acceptance or run count before
posting the update, then assert it remains unchanged after ingress.drain(). Keep
the existing sendMessage assertion, and use the existing test-facing
acceptance/run counter symbol rather than inferring admission solely from
network requests.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 432ba117-e458-4b53-be13-443834cd0062

📥 Commits

Reviewing files that changed from the base of the PR and between e71e5a8 and 6cc33c1.

⛔ Files ignored due to path filters (1)
  • CHANGELOG.md is excluded by !CHANGELOG.md
📒 Files selected for processing (34)
  • .github/workflows/live-canary.yml
  • crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs
  • crates/app/ironclaw_composition/src/runtime/capability_host.rs
  • crates/app/ironclaw_composition/src/runtime/capability_host/notification_channels_set.rs
  • crates/app/ironclaw_composition/src/runtime/capability_host/outbound_delivery.rs
  • crates/app/ironclaw_composition/src/runtime/capability_host/tests.rs
  • crates/contracts/ironclaw_loop_contracts/src/host/run_context.rs
  • crates/contracts/ironclaw_loop_contracts/src/host/run_context/tests.rs
  • crates/domains/ironclaw_conversations/src/memory.rs
  • crates/domains/ironclaw_conversations/tests/conversation_state_store_contract.rs
  • crates/domains/ironclaw_conversations/tests/inbound_contract.rs
  • crates/extensions/ironclaw_extension_host/src/channel_host.rs
  • crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs
  • crates/extensions/ironclaw_extension_host/src/channel_shared_admission.rs
  • crates/extensions/ironclaw_extension_host/src/lib.rs
  • crates/extensions/ironclaw_extension_manager/src/channel_config_product_service.rs
  • crates/loop/ironclaw_loop_host/src/synthetic_capability.rs
  • crates/product/ironclaw_assistant/src/channel_workflow.rs
  • crates/product/ironclaw_assistant/src/model_channel_delivery/tests.rs
  • crates/product/ironclaw_assistant/src/project_create_capability.rs
  • crates/product/ironclaw_assistant/src/run_delivery/observer.rs
  • crates/product/ironclaw_assistant/src/run_delivery/prompts.rs
  • crates/product/ironclaw_assistant/src/run_delivery/triggered.rs
  • crates/product/ironclaw_assistant/tests/product_surface_contract.rs
  • crates/product/ironclaw_assistant/tests/run_delivery_contract.rs
  • docs/reborn/contracts/conversation-binding.md
  • docs/reborn/setup-slack-for-reborn-binary.md
  • docs/reborn/target-architecture/CHECKLIST.md
  • tests/integration/extension_delivery.rs
  • tests/integration/group_extensions/scenario_slack_channel_lifecycle_state_machine.rs
  • tests/integration/support/group.rs
  • tests/integration/support/group_constructors.rs
  • tests/reborn_identity_prompt_scope_isolation_parity.rs
  • tests/support/reborn_parity_qa/binary_e2e.rs
💤 Files with no reviewable changes (1)
  • crates/extensions/ironclaw_extension_host/src/lib.rs

Comment on lines +824 to 834
let shared_admission: Option<Arc<dyn SharedConversationAdmission>> =
match &extras.shared_admission {
Some(resolver) => Some(Arc::clone(resolver)),
None => {
let fields = admin_configuration_fields(source.resolved());
let handles =
ironclaw_extension_host::shared_channel_admission_handles(&fields);
if handles.declared() {
let extension_id = ExtensionId::new(source.extension_id())
.map_err(|error| format!("invalid extension id: {error}"))?;
Some(Arc::new(
ironclaw_extension_host::ChannelConfigSubjectRouteResolver::new(
adapter_id.clone(),
installation_id.clone(),
identity.tenant_id.clone(),
extension_id,
handles,
Arc::clone(&self.deps.channel_config),
),
))
} else {
None
}
}
None => default_shared_admission(
actor_identity_is_per_user,
source,
&adapter_id,
&installation_id,
&self.deps.channel_config,
)?,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Block admission overrides for operator-resolved channels.

extras.shared_admission takes precedence before the per-user identity check. A channel with OperatorActorUserResolver can therefore admit a shared conversation and run each participant as operator_user_id.

Check actor_identity_is_per_user before selecting either an override or the manifest resolver. Return None when it is false. Add a caller-path regression test that injects ChannelExtras.shared_admission for a channel without identity lookup and verifies that shared ingress is rejected.

As per coding guidelines, “Do not weaken … allowlists.” As per path instructions, the Trusted-ingress seal requires inbound requests to preserve the trusted boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/extensions/ironclaw_extension_host/src/channel_host.rs` around lines
824 - 834, Update the shared-admission selection around shared_admission and
default_shared_admission to return None immediately when
actor_identity_is_per_user is false, before consulting extras.shared_admission
or the manifest resolver. Preserve override and default resolution only for
per-user identity channels, and add a caller-path regression test injecting
ChannelExtras.shared_admission for a channel without identity lookup to verify
shared ingress is rejected.

Sources: Coding guidelines, Path instructions


use crate::channel_host::GenericChannelHostAssembly;
use ironclaw_extension_host::ChannelConfigService;
use ironclaw_extension_host::channel_shared_admission::handle_declares_field;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Locate files and relevant occurrences:\n'
fd -a 'channel_(outbound_targets|host).rs' . || true
printf '\nOccurrences of ironclaw_extension_host::channel_shared_admission:\n'
rg -n "ironclaw_extension_host::channel_shared_admission" . || true
printf '\nRelevant file slices:\n'
sed -n '1,80p' crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs
printf '\n---\n'
sed -n '85,120p' crates/extensions/ironclaw_extension_host/src/channel_host.rs
printf '\nModule declaration:\n'
rg -n "^pub(crate)? (mod channel_shared_admission|use ironclaw_extension_host::channel_shared_admission)" crates/extensions/ironclaw_extension_host/src/lib.rs crates/extensions/ironclaw_extension_host/src/channel_host.rs crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs || true

Repository: nearai/ironclaw

Length of output: 6150


Use crate:: for internal module references.

These references are cross-module imports within ironclaw_extension_host, and the repo guideline requires using crate:: for cross-module Rust imports.

  • crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs#L45: import handle_declares_field through crate::channel_shared_admission.
  • crates/extensions/ironclaw_extension_host/src/channel_host.rs#L101/#L108: call shared_channel_admission_handle and construct ChannelConfigSharedAdmission through crate::channel_shared_admission.
📍 Affects 2 files
  • crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs#L45-L45 (this comment)
  • crates/extensions/ironclaw_extension_host/src/channel_host.rs#L101-L108
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rs` at
line 45, Use crate-relative paths for all internal channel admission references:
update handle_declares_field in channel_outbound_targets.rs to import through
crate::channel_shared_admission, and update shared_channel_admission_handle plus
ChannelConfigSharedAdmission in channel_host.rs to use
crate::channel_shared_admission.

Sources: Coding guidelines, Path instructions

Comment on lines +85 to +88
/// composed runtime's tenant/agent/project plus the fallback operator user
/// host-initiated work is attributed to (notice-thread scopes, the durable
/// idempotency-ledger scope). Inbound conversations run as their invoking
/// actor, not this user.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Clarify the operator-user sentence.

Lines 85-86 are grammatically incomplete: “the fallback operator user host-initiated work is attributed to”. Add that after user so the actor-scope rule is unambiguous.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/product/ironclaw_assistant/src/channel_workflow.rs` around lines 85 -
88, Update the comment describing the composed runtime’s fallback operator user
to insert “that” after “user,” making the sentence read grammatically and
clearly identify the user to whom host-initiated work is attributed.

Comment on lines +1723 to +1764
// Fail-closed group admission at the caller path: a correctly-signed
// update from a supergroup NOT listed in `telegram_allowed_channels` is
// acknowledged on the wire (no vendor retry storm) but produces no turn
// and no reply — the conversation is simply not connected.
let unlisted_body = json!({
"update_id": 502,
"message": {
"message_id": 12,
"message_thread_id": 5,
"date": 1710000001,
"text": "@itest_delivery_bot are you there?",
"entities": [{"type": "mention", "offset": 0, "length": 19}],
"from": {"id": 9911, "is_bot": false, "first_name": "Ada"},
"chat": {"id": -1009999999_i64, "type": "supergroup"}
}
})
.to_string();
let status = ingress
.post(
TELEGRAM_ROUTE,
&unlisted_body,
vec![(
"X-Telegram-Bot-Api-Secret-Token",
TELEGRAM_WEBHOOK_SECRET.to_string(),
)],
)
.await;
assert_eq!(
status,
StatusCode::OK,
"an unlisted group's update is acknowledged, not retried"
);
ingress.drain().await;
assert_eq!(
inbound
.captured_network_requests_for_test()
.iter()
.filter(|request| request.url.ends_with("/sendMessage"))
.count(),
send_message_count_before_rejected_update,
"an unlisted supergroup must produce no turn delivery and no reply"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Assert that the unlisted update creates no turn.

The test only verifies that no sendMessage request occurs. A disallowed update can still enter the turn workflow and then finish or fail without delivery.

Record a caller-visible turn acceptance or run count before the request. Assert that it is unchanged after ingress.drain(). This verifies the fail-closed admission boundary, not only the absence of a reply.

Repo invariant: “Test through the caller.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/extension_delivery.rs` around lines 1723 - 1764, Extend the
unlisted-update test around the ingress request to capture the caller-visible
turn acceptance or run count before posting the update, then assert it remains
unchanged after ingress.drain(). Keep the existing sendMessage assertion, and
use the existing test-facing acceptance/run counter symbol rather than inferring
admission solely from network requests.

Source: Path instructions

@BenKurrek
BenKurrek added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit 0b5fcde Aug 8, 2026
47 checks passed
@BenKurrek
BenKurrek deleted the run-acts-as-invoker branch August 8, 2026 21:06
BenKurrek added a commit that referenced this pull request Aug 8, 2026
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Rebased onto main after #7377 squash-merged. Two contract size ceilings re-captured against the new baseline (extension_contracts 7830->7841, product_contracts 15800->15819; the deltas are parallel-merged PRs plus this PR's ResolvedBinding.owner_user_id). Main tightened the extension-specificity gate, which now scans doc comments: reword the neutral-crate reply-placement/shared-thread docs to abstract surface vocabulary (threading vs. flat surface) instead of naming Slack/Telegram — the code was already flag-driven and neutral; the per-vendor mechanics stay in the channel packages.
serrrfirat added a commit that referenced this pull request Aug 9, 2026
- Merge origin/main (#7377 run-acts-as-invoker, #7323, #7382, #6938,
  #7280, #7393, #7389, #7364, #7228, #7371, #7399).
- main's #7377 landed a narrower terminal arm (generic failure notice for
  TurnStatus::Failed only); keep the #6896 arm, which covers Failed and
  RecoveryRequired with sanitized per-category summaries plus Cancelled
  and the timeout grace path, and adapt to the Option<String>
  notice_discriminator main introduced.
- Re-seed the composition budget to the merged-tree measurement
  (40811 -> 40861, the run-failure settlement observer lands +50 governed
  LOC); the arch-test record moves with the manifest.
personal-upstream-sync Bot pushed a commit to theredspoon/ironclaw that referenced this pull request Aug 10, 2026
…rai#6896) (nearai#7131)

* fix(run_delivery): deliver triggered run failures to the creator (nearai#6896)

Scheduled/triggered runs that ended in Failed, Cancelled, or
RecoveryRequired produced no user-visible notification: the triggered
delivery driver minted notifications only for Completed /
BlockedApproval / BlockedAuth and recorded every other terminal status
as Skipped. A run that timed out before reaching an actionable state
only logged a warn and recorded Failed, leaving the creator in silence.

Delivery:
- triggered_notification_for_state now mints a FinalReplyReady
  notification for Failed and RecoveryRequired using the existing
  per-category failure summaries (reborn_failure_summary_for_category)
  over state.failure.category(), with a generic fallback when no
  category is present.
- Cancelled mints the same notification, preferring a failure-category
  summary when one is present and falling back to a fixed cancellation
  notice otherwise.
- The RunWaitTimedOut branch with no prior blocked marker now delivers
  the timeout notice as a terminal reply instead of recording Failed.
- The wildcard arm is replaced with explicit non-actionable statuses
  (Queued, Running, CancelRequested, BlockedResource,
  BlockedDependentRun, BlockedExternalTool) so a future status fails to
  compile rather than silently skipping.

Observer:
- TriggerFireSettlementObserver gains on_failed_fire_settled as a
  default no-op method, plus a TriggerFailedFireSettlement event
  carrying tenant/trigger/fire-slot/run-id/history-status. Noop and
  existing implementors keep compiling.
- The active-cleanup sweep fires on_failed_fire_settled when
  clear_active_fire succeeds with TriggerRunHistoryStatus::Error, so
  post-accept failures are observable for automation health. Ok,
  Running, and already-cleared fires do not fire the hook.

Tests:
- run_delivery_contract: Failed+model_error, Failed without category,
  Cancelled, and timeout-before-actionable all assert a Delivered
  outcome with the expected notice text and footer.
- worker tests: a terminal-Error active fire fires exactly one
  on_failed_fire_settled; a terminal-Ok active fire fires none.

The larger retry/redrive budget for failed post-accept fires
(retry_disposition has zero production callers) is intentionally left
for a follow-up; it is out of scope for this surgical delivery fix.

* style: cargo fmt the nearai#6896 delivery fix

* fix(triggers): address terminal delivery review feedback

* fix(assistant): drop unused UserId import after merge

* fix(run_delivery): address multi-agent review findings

- Extract shared terminal-notice helpers (final_reply_notice,
  outcome_for_delivery_failure, deliver_terminal_notice) so the
  timeout, OAuth-backstop, and generic failure arms share one notice
  shape and outcome taxonomy instead of a third hand-rolled copy.
- Add a bounded race-grace window after the wait backstop: a run that
  crosses into a terminal state during the final wait (cancellation in
  flight, failure landing after the last poll) now delivers the correct
  terminal notice instead of the timeout copy.
- Cancelled runs always deliver the fixed cancellation notice; the
  failure-category branch was unreachable in production and would have
  mislabeled a host/operator cancel as a failure.
- Update the stale invariant doc, the five-output surface contract
  count, and the exhaustiveness-only comment on the non-actionable arm.
- Document the cheap/non-blocking contract on
  TriggerFireSettlementObserver (the worker awaits it inline in the
  poller sweep) and note it at the active-cleanup call site.
- Add contract coverage for the timeout arm's delivery-failure outcome
  (Failed) and a regression test proving the race-grace path delivers
  the cancellation notice; the cancelled-with-category test now asserts
  the cancellation notice wins.

* fix(run_delivery): address review comments and restore CI gates

Review fixes (CodeRabbit on 01e887f/f8af109):
- Grace loop fails loud: log the bound TurnError on state-poll failure and
  the RunDeliveryError on terminal-notice build failure before falling back
  to the timeout copy, with silent-ok markers on both intentional fallbacks.
- Hoist TriggeredReplyTargetAuthority, CodecChannelTargetResolver, and
  TriggeredNotificationContext to one construction before the watcher loop;
  the race-grace arm, timeout arm, and loop body now share it.
- Collapse the duplicated failure-summary expression into one closure and
  name TurnStatus::Failed explicitly so future statuses are compiler-visible.
- Drop the stale "Only three states" count from the surface-contract doc.
- Test fixture: encode the late-terminal flip as one Option<(usize,
  ScriptedRunState)> field instead of two correlated Options with an expect.
- Terminal-crossing test: document why flip_after=30 deterministically
  outruns the wait poll budget and assert the grace loop issues no
  cancellation (cancel_calls == 0).

CI:
- composition-budget: re-seed loc_ceiling 40432 -> 40593 (measured on the
  merged tree; the nearai#7131 settlement observer adds +161 governed LOC of
  wiring) and move the arch-test record with it.
- trigger_poller: use the colon-form tracing target required by nearai#7146.

* ci: re-trigger pull_request workflows for c2460ed

* fix(composition): capture the settlement health warn in the observer test

The traced_test default filter is {crate}=trace, which drops events whose
metadata target is `ironclaw::reborn::…`. The observer warning is emitted
with the colon-form target (required by nearai#7146 — the equals form recorded a
field and never matched RUST_LOG target filters), so the test saw an empty
buffer. Enable tracing-test's no-env-filter feature, the same pattern the
capabilities/host-runtime/mcp/loop crates use for cross-target assertions.

Re-seed the composition budget to the merged-tree measurement (40747 ->
40867): nearai#7131's observer wiring lands on top of post-measurement mainline
inflow; measured with the gate, set to current. The arch-test record moves
with the manifest.

* fix(run_delivery): merge main and adapt to notice_discriminator String

- Merge origin/main (nearai#7377 run-acts-as-invoker, nearai#7323, nearai#7382, nearai#6938,
  nearai#7280, nearai#7393, nearai#7389, nearai#7364, nearai#7228, nearai#7371, nearai#7399).
- main's nearai#7377 landed a narrower terminal arm (generic failure notice for
  TurnStatus::Failed only); keep the nearai#6896 arm, which covers Failed and
  RecoveryRequired with sanitized per-category summaries plus Cancelled
  and the timeout grace path, and adapt to the Option<String>
  notice_discriminator main introduced.
- Re-seed the composition budget to the merged-tree measurement
  (40811 -> 40861, the run-failure settlement observer lands +50 governed
  LOC); the arch-test record moves with the manifest.
Kampouse pushed a commit to Kampouse/ironclaw that referenced this pull request Aug 13, 2026
nearai#7157 follow-ups) (nearai#7377)

* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR nearai#7157 on current main (aa8b748 → 967f33a, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc (nearai#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da (nearai#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as nearai#6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d (nearai#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from nearai#7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb (nearai#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; nearai#7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(delivery): key observer gate notices by their gate ref

One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism nearai#7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(composition): pin the notification-channels gate dance when owner ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split nearai#7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(outbound): scope the whole notification-channels gate dance as the acting user

Unify the interim nearai#7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(conversations): key shared-route bindings per (conversation, actor)

A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(product)!: remove shared-route subject binding; scope = invoker

Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(reborn): align guidance, specs, and live-QA scripts with invoker scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(telegram): connect group chats via telegram_allowed_channels

Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(reborn): morph the test tier to invoker scope

Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(changelog): record the telegram_allowed_channels admission field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(merge): reconcile composition ceilings and capability_wiring test arity

Post-merge fixups after folding main (nearai#7157 squashed + nearai#7214 + the
inspector prompt-diagnostic work) into run-acts-as-invoker:

- Re-capture the composition absolute-mass ceiling 40_747 -> 40_811 in
  both the budget manifest and reborn_restructure_baselines.rs: the
  acting-user scope helper and shared-admission wiring add +64
  production LOC on the merged tree. Recorded rather than parked in the
  150-line tolerance.
- Add the 10th `tool_diagnostic_sink` argument (None) to the invoker's
  capability_wiring test call — main grew the signature after this
  branch wrote that call site.

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

* fix(conversations): refuse legacy-row route-kind mismatches; drop unreachable widen

A Direct request is the one key shape a retained legacy conversation-scoped
shared row can collide with. Resolve, lookup, reset, and link now refuse the
mismatch outright (BindingRequired) instead of trusting adapters never to
re-classify a conversation's route kind — pinned by a Direct-probe leg on the
legacy restart-path test. The forward half of the migration contract is pinned
too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a new
per-actor shared binding survives a restart (a deserialize-side regression
would previously have orphaned every group thread silently).

widen_binding_route_access and ReplyRouteAccess::allow_shared are deleted:
every Shared-keyed row is born shared under per-actor keying, so both widen
call sites were unreachable. The persisted flag stays for legacy reads.

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

* fix(delivery): key gate notices by gate ref on the triggered lane too

The gate-collapse fix shipped on the observer lane only; the background lane
still minted undiscriminated projection ids for ApprovalNeeded/AuthRequired,
so an automation run parking on a SECOND gate deduped to AlreadyDelivered,
recorded the whole delivery Failed, and the gate was never announced or
reply-routable (AGENTS.md: fix the sibling when a pattern bug is fixed).
TriggeredNotification's discriminator now carries the gate ref for gate
prompts (RunBlocked stand-ins compose their label with it), matching the
observer keying, with a triggered two-gate regression pinning outcome,
prompts, and both reply routes.

Also pinned: same-gate re-announcement dedupe (g1->g2->g1), two distinct AUTH
gates, and the refless id shapes incl. FinalReplyReady. Over-long
discriminators are bounded with a stable FNV-1a suffix so a maximal legal
TurnGateRef can never overflow ProjectionUpdateRef and silently lose a notice.

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

* refactor(identity): one contract derivation for every acting-identity scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

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

* fix(extension-host): collapse admission handles; operator-identity channels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

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

* test+docs(reborn): re-pin parity to per-actor scopes; align contract, docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Kampouse pushed a commit to Kampouse/ironclaw that referenced this pull request Aug 13, 2026
nearai#7157 follow-ups) (nearai#7377)

* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR nearai#7157 on current main (aa8b748 → 967f33a, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc (nearai#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da (nearai#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as nearai#6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d (nearai#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from nearai#7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb (nearai#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; nearai#7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(delivery): key observer gate notices by their gate ref

One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism nearai#7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(composition): pin the notification-channels gate dance when owner ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split nearai#7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(outbound): scope the whole notification-channels gate dance as the acting user

Unify the interim nearai#7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(conversations): key shared-route bindings per (conversation, actor)

A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(product)!: remove shared-route subject binding; scope = invoker

Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(reborn): align guidance, specs, and live-QA scripts with invoker scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(telegram): connect group chats via telegram_allowed_channels

Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(reborn): morph the test tier to invoker scope

Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(changelog): record the telegram_allowed_channels admission field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(merge): reconcile composition ceilings and capability_wiring test arity

Post-merge fixups after folding main (nearai#7157 squashed + nearai#7214 + the
inspector prompt-diagnostic work) into run-acts-as-invoker:

- Re-capture the composition absolute-mass ceiling 40_747 -> 40_811 in
  both the budget manifest and reborn_restructure_baselines.rs: the
  acting-user scope helper and shared-admission wiring add +64
  production LOC on the merged tree. Recorded rather than parked in the
  150-line tolerance.
- Add the 10th `tool_diagnostic_sink` argument (None) to the invoker's
  capability_wiring test call — main grew the signature after this
  branch wrote that call site.

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

* fix(conversations): refuse legacy-row route-kind mismatches; drop unreachable widen

A Direct request is the one key shape a retained legacy conversation-scoped
shared row can collide with. Resolve, lookup, reset, and link now refuse the
mismatch outright (BindingRequired) instead of trusting adapters never to
re-classify a conversation's route kind — pinned by a Direct-probe leg on the
legacy restart-path test. The forward half of the migration contract is pinned
too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a new
per-actor shared binding survives a restart (a deserialize-side regression
would previously have orphaned every group thread silently).

widen_binding_route_access and ReplyRouteAccess::allow_shared are deleted:
every Shared-keyed row is born shared under per-actor keying, so both widen
call sites were unreachable. The persisted flag stays for legacy reads.

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

* fix(delivery): key gate notices by gate ref on the triggered lane too

The gate-collapse fix shipped on the observer lane only; the background lane
still minted undiscriminated projection ids for ApprovalNeeded/AuthRequired,
so an automation run parking on a SECOND gate deduped to AlreadyDelivered,
recorded the whole delivery Failed, and the gate was never announced or
reply-routable (AGENTS.md: fix the sibling when a pattern bug is fixed).
TriggeredNotification's discriminator now carries the gate ref for gate
prompts (RunBlocked stand-ins compose their label with it), matching the
observer keying, with a triggered two-gate regression pinning outcome,
prompts, and both reply routes.

Also pinned: same-gate re-announcement dedupe (g1->g2->g1), two distinct AUTH
gates, and the refless id shapes incl. FinalReplyReady. Over-long
discriminators are bounded with a stable FNV-1a suffix so a maximal legal
TurnGateRef can never overflow ProjectionUpdateRef and silently lose a notice.

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

* refactor(identity): one contract derivation for every acting-identity scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

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

* fix(extension-host): collapse admission handles; operator-identity channels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

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

* test+docs(reborn): re-pin parity to per-actor scopes; align contract, docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
personal-upstream-sync Bot pushed a commit to theredspoon/ironclaw that referenced this pull request Aug 14, 2026
* docs(design): Telegram linked-device — proposal, plan, checklist, ADR

Design-only. Adds the engineering spec for linking a user's personal Telegram
account as a real MTProto linked device, so the agent can read their
conversations and act as them through the standard messaging operations.

Docs only — no production code, no behavior change.

Shape:
- README: overview, architecture, footprint, explicit non-goals
- PROPOSAL: decisions with rationale, per-crate change inventory, review log
- PLAN: 8 PRs with dependency edges and per-PR watch-lists
- CHECKLIST: definition of done, each box naming something to run or read
- ADR: the auth-hook decision and what it costs

Load-bearing decisions:
- Reads are live; no message content is persisted. Telegram is a cloud
  messenger, so history and search are server-side — which removes the mirror,
  the retention policy, the FTS plane, and (because no update stream is
  consumed) the session-sourced ingress work from v1.
- Device-link is an auth method with a narrow adapter hook, taking the
  extension-runtime spec's own "a vendor defeats the descriptor" revisit
  trigger. The hook revokes a stated security invariant; the ADR records the
  real compensation set and the in-process-vs-sidecar trade.
- Custody extends ironclaw_auth (a linked account is a CredentialAccount); the
  only genuinely new persistence surface is a CAS write path for a mutable
  binary secret.
- Sessions live in the existing telegram package behind a contracts-declared
  port; no new crates, no new runtime lane.

Vendor claims are verified against grammers 0.10.0 sources and the reference QR
implementations rather than assumed (PROPOSAL 14.1), and the whole document was
re-verified against origin/main after upstream nearai#7377/nearai#7397 (14.4) — which
removed owner-vs-actor and thereby retired this design's worst finding.

Status: sign-off withheld pending the conditions in the review log. Not
approved for implementation; opened for review.

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

* feat(telegram): linked-device — device-link auth, session custody, standard-op tools

Implements the design in docs/internal/design/telegram-linked-device/: a user
links their personal Telegram account as a real MTProto linked device, and the
agent reads their conversations and acts as them through the standard messaging
operations. Reads are live — no message content is persisted.

Contracts
- ironclaw_extension_contracts: device_link (DeviceLinkAdapter + its step/input
  vocabulary) and linked_session (SessionBytes, LinkedAccountRef/Grant,
  LinkedSessionPort + factory). VendorAuthRecipe::DeviceLink with every arm,
  including the (DeviceLink, DeviceLink) compatibility case and
  keepalive_idle_threshold -> None, both of which fail at activation rather than
  compile if missed.
- ironclaw_host_api: send_message.output.v2.json as a NEW file carrying the
  sent_unverified branch. .v1 is byte-identical — the standard's schema
  immutability rule forbids an in-place edit. Schema resolution is now
  version-aware so existing bindings keep resolving .v1 forever.

Auth
- Device-link flow: ordered steps with revision CAS so a duplicated poll is
  idempotent and never re-invokes the adapter; two clocks (a step clock that
  re-mints, a flow clock that terminalizes); AwaitingVendor projected explicitly
  as Authenticating rather than falling through to Disconnected.
- link_revision on CredentialAccount with a CAS-bearing opaque-material write.
  Auth owns conflict detection only; it does not parse the session blob.

Host
- Device-link binding slot and its check_binding arms. Retires
  auth_never_binds_is_not_a_binding_field, which encoded the invariant the ADR
  deliberately revokes; the retirement cites the ADR.
- SnapshotDeviceLinkDriver resolves extension -> bound adapter and enforces poll
  rate limits and TTLs host-side.

Package
- MTProto via grammers 0.10.0, exact-pinned: the pin is a security control, not
  hygiene, because 0.10.0 never persists server-pushed DC addresses and that is
  what makes address validation in IronclawSession airtight.
- Sockets confined to transport.rs. NoRetries plus an explicit wrapper, because
  AutoSleep would re-send a write after an I/O error and no retry policy can see
  whether a request is a write.
- QR login by re-export polling (the flow an official client uses), phone and
  2FA paths, per-link mutex, logout on every post-acceptance abort.
- 15 standard ops. send_message returning id == 0 is a confirmed-but-uncorrelated
  send: Completed with sent_unverified, never a failure — a failure is what a
  model retries, and the retry double-sends to a human. Dropped/Io on a write is
  outcome-unknown and maps to vendor_error instead.

Frontend
- One QR/countdown implementation, shared by the existing pairing panel and the
  new device-link card. QR <-> phone switch, 2FA entry, stale-revision guard,
  polling stops on terminal states.

Gates
- Vendor names kept out of generic crates.
- Cross-crate include ratchet 16 -> 17, recorded deliberately in that file: the
  telegram package gained prompt docs when it gained tools, using the same
  include shape Slack already uses. Not a new class of reach-in, and not
  repointable while the layer matrix forbids runtimes -> products.

Local verification: cargo fmt, clippy --all-targets --all-features -D warnings,
and the full ironclaw_architecture_tests suite all pass; 1342 unit/contract tests
green across the touched crates.

NOT complete. The design's checklist is largely unticked — no integration tests,
no live-Telegram verification, and the security conditions in PROPOSAL 14.2-14.4
remain unmet. See the PR body.

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

* test(telegram): integration harness, supply-chain pin, ownership pins, content bounds

Closes four gaps from the previous commit. All gates green; see the honesty
section below for what this does NOT close.

Integration
- tests/integration/support/harness/profiles/device_link.rs mounts the real
  bundled telegram package (its shipped manifest: channel + [auth.telegram] +
  15 standard_op tools) and writes [admin_configuration] through the real
  capability. New reborn_group_device_link target, registered in the root
  Cargo.toml, driving 3 scenarios against a scripted adapter.

Supply chain (the ADR requires these WITH the dependency, not later)
- All three grammers edges: =0.10.0 exact, default-features = false, explicit
  feature allowlists. grammers-client drops its `fs` default (nothing calls
  upload_file/download_media; attachments ride ironclaw_attachments). The socks5
  `proxy` feature stays off — a proxied dial bypasses Session::dc_option, which
  is the only seam our DC address validation owns.
- New reborn_linked_device_supply_chain_pin gate (13 tests) failing on any
  version or feature-set drift, with the rationale in its module doc.
- dependabot ignores grammers-*; Cargo.lock unmodified.

Ownership
- NewCredentialAccount::for_linked_device pins ExtensionOwned + empty grants;
  bump_link_revision refuses an unpinned account in both the production store
  and the fake. A §4.5 logout-before-unbind family lands in auth::cleanup.

Untrusted read content (§6.4)
- The content bounds become §7.2 constants with zero-checks and relationship
  asserts. @username handles now pass through sanitize_untrusted_text — the
  handle is the identity the model is told to trust.
- New conformance.rs proves every content-returning addendum frames its output
  as untrusted, and that the framing predicate is not inert.

Honesty — this is NOT a working feature yet
The handshake has no production wiring: nothing constructs a DeviceLinkDriver,
session custody resolves to unavailable() in every deployment, the durable
credential store does not implement opaque material (blocked on a CAS-bearing
SecretStorePort::put that was never built), completion cannot mint an account,
LinkedAccountResolver has zero implementations, and the shipped UI calls
/api/reborn/product-auth/device-link/... routes that do not exist. Fourteen
TODO(design) markers record each seam. Nothing here has ever spoken MTProto.

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

* fix(webui): device link rides the generic product-auth routes, not a new namespace

A build agent invented /api/reborn/product-auth/device-link/{start,flow/{id}/
status,flow/{id}/input,flow/{id}/cancel} and then recorded its own invention as
a missing backend dependency. PROPOSAL §8.12 says the opposite: "additive
flow-status fields (step, revision, display, retry-after); route input
submission to the driver" — extend what exists.

A device link IS an AuthFlowRecord, and flow_status(scope, flow_id) is already
generic over flows; the route is only *named* oauth/... for historical reasons.
So the browser now calls the routes that are actually mounted:

  status  -> /api/reborn/product-auth/oauth/flow/{flow_id}/status
  start   -> /api/reborn/product-auth/extension/oauth/start
  input   -> /api/reborn/product-auth/manual-token/secret/submit
  cancel  -> /api/reborn/product-auth/oauth/flow/{flow_id}/reconcile

That removes "no backend routes exist" as a blocker. What remains is genuinely
additive and much smaller: the status response must carry the device-link frame,
and secret submission must route to the device-link driver — both extensions of
handlers already mounted in product_auth/mod.rs, marked TODO(backend) at the one
place that reconciles them.

Frontend suite green: 143 files, 1264 tests.

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

* fix(webui): device-link routes — status is shared, start/input/cancel are not

Corrects an over-correction. The previous commit routed EVERYTHING through
existing product-auth routes, which is wrong: extension/oauth/start builds an
authorize URL (a device link has none) and manual-token/secret/submit means
"user pasted an API key" (not "user typed step 3's 2FA code").

The honest shape is a mix:

- STATUS is genuinely shared. flow_status(scope, flow_id) fetches an
  AuthFlowRecord and returns its status with no OAuth-specific logic, and
  PROPOSAL §8.12 asks for additive fields on exactly that response. Polling
  extends the existing route.
  Naming wart recorded: the route is spelled oauth/flow/... though the object
  it serves is generic. Renaming to /product-auth/flow/{flow_id}/status with
  the old spelling kept as an alias is the right follow-up, and is a
  route-descriptor change rather than part of this feature.

- START, INPUT and CANCEL are device-link specific, because the operations
  differ: start takes a link mode (QR vs phone); input carries a typed kind
  plus the step revision it was typed against; cancel must ask the vendor to
  log the device out, or an accepted-but-abandoned link leaves an orphan
  authorization on the user's account (§4.3). Nothing existing does that.

These three are marked TODO(backend) as work THIS feature owes — not, as the
original agent comment claimed, a dependency on another branch.

Frontend suite green: 143 files, 1264 tests.

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

* feat(telegram): finish the linked device — custody, mint, routes, resolver

The branch shipped a fail-closed skeleton: green gates over fourteen
`TODO(design)` markers and a feature that could not link an account. This
closes the chain end to end and fixes what the extension-unification audit
found on the way.

Audit findings, fixed rather than worked around:

* `LinkedAccountResolver` was declared by the telegram package, so the
  containment PROPOSAL §5.1 requires — rooted in a HOST-minted grant — could
  only ever have been satisfied by the package itself. Moved into
  `ironclaw_extension_contracts` beside `LinkedSessionPortFactory`, supplied
  on `BindContext`, and implemented host-side over the same
  credential-account selection every runtime injection uses.
* `DeviceLinkBinding` carried a bare `user_id`, which is *why* completion
  could not mint: minting needs an `AuthProductScope` and synthesizing one
  from a user id would re-derive security-relevant scope. It now carries the
  durable flow's own scope (`user_id()` is an accessor over it).

The implementation chain:

* `ironclaw_secrets` gains a compare-and-swap write path
  (`put_versioned`/`read_versioned`): the previous last-writer-wins `put`
  would let a concurrent write clobber a rotating vendor auth key, which is a
  silently dead link. Decorator and four test doubles follow the widened
  trait.
* `ironclaw_auth`'s durable store implements opaque-material load/store over
  it — detection only, never a semantic merge: the blob is vendor-private and
  only the package can read it. `complete_linked_device_link` is the one
  place the completion policy lives (reuse-before-create, never resurrect a
  revoked account, the §4.5 ownership pin, load-then-CAS so a crashed prior
  link cannot brick relinking).
* Custody splits by revision: a provisional in-process space for the
  handshake (the blob exists *before* any account does — §4.3's store → mint
  → report) and durable custody behind the credential service, plus the
  ref→account directory that maps a host-issued `LinkedAccountRef` to the
  coordinates the auth domain needs.
* The extension host's driver mints the account at completion and registers
  it with custody, so `DeviceLinkStepOutcome` finally carries `Some(account)`
  and a link can complete.
* Composition wires all of it and attaches the flow driver and the linked-
  device revoker to the product-auth bundle.
* `api_hash` is `secret = true`, so it cannot ride `BindContext` (non-secret
  config only). It resolves at *load* — the one I/O-legal point before bind —
  through a new pre-scoped `LoadTimeAdminSecrets` port
  (`NativeExtensionFactory::load` becomes `async`). Unset, the adapter still
  binds and fails every link attempt closed, so a bot-only deployment keeps
  activating.
* The CLI binds all three Telegram surfaces (channel + device-link + tools),
  the shape `check_binding` has always required.

WebUI: four device-link routes, not three. `poll` is the departure from the
design — a card cannot poll the read-only status route, because a link only
advances when the host re-exports the login token (§4.2) and nothing else
drives it, so a card polling a pure read waits forever on a QR that was
already scanned. Routing the advance through the shared GET would also hide a
vendor call behind a descriptor declared read-shaped. STATUS stays shared,
stays a read, and carries §8.12's additive frame so a re-rendered card
hydrates without disturbing a live link. The ADR's detection control ships in
the completion card: the resolved account plus a count-the-devices ask,
worded to claim only what it catches.

Two failures were real behavior, not test drift:

* A cleanup decorator built at construction captured the account read model
  before it was final and would have broken *every* cleanup. The
  logout-before-unbind ordering moved to the bundle's single cleanup entry
  point, where the read model is settled.
* A lost compare-and-swap surfaced as 503 "retry later" to a card holding a
  stale step revision; retrying a superseded revision can never succeed. It
  maps to 409.

Proof: `scenario_handshake_mints_and_serves` drives composition's real
`DeviceLinkFlowDriver` start → poll → submit → completed, asserts the §4.5
ownership pin on the account the mint produced, asserts custody actually
persisted, and proves a linked tool call resolves to that account. Three
caller-level route tests drive the four routes over the mounted router.

NOT DONE, and not claimed: nothing here has ever spoken MTProto. Every test
drives a scripted adapter, so QR acceptance, DC migration, 2FA and flood-wait
are unexercised, and the `id == 0` / `Dropped`-on-write evidence rules have
never met a real server. PROPOSAL §14.3's withheld security sign-off is
unchanged. CHECKLIST is 51/135 with a note on why the ratio is what it is.

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

* fix(device-link): the browser could start a link but never finish one

Three defects found by driving the real flow in a local stack, each at a
seam between two things that were individually tested and green.

**Blank ids were sent for ids the caller did not have** (frontend). The chat
auth-gate card reads its scope from a gate model that has no `threadId` at
all and leaves `invocationId` null, so it posted `thread_id: ""`. The host
parses each of these into a validated newtype — `ThreadId`, `InvocationId`,
`TurnRunRef`, `AuthGateRef` — every one of which rejects a blank, so `start`
answered `400 invalid_request` before the flow began. Absent optional ids are
now omitted at the one API choke point rather than defaulted to `""`.
Required fields are deliberately NOT filtered: a blank `provider` must reach
the host and be rejected, not vanish into a request meaning something else.

**`start` minted an invocation and never returned it** (wire contract).
`scope_matches` is exact equality over the whole scope, and poll/input/cancel
re-derive that scope from what the browser sends back. A card opened outside
a run — the Extensions configure modal, no run and no gate — has no
invocation to carry in, so the host minted one and stored the flow under it.
With no way to learn that value, every follow-up call built a different scope
and 400'd: the flow could be started and never advanced. `DeviceLinkFlowResponse`
now echoes `invocation_id`, exactly as `ManualTokenSetupResponse` already
does and for the same reason, and the panel prefers it over the prop.

**A still-valid QR was reported as "nothing to show"** (adapter). Telegram
returns the same token bytes on every poll for the whole of a token's
window; `paint_token` treated unchanged bytes as `AwaitingVendor`, which is
defined as "nothing to show", so the card blanked the code about one poll
after painting it and parked on "waiting for the vendor" until expiry. It now
always returns `Display`, with `expires_in` recomputed so the countdown stays
honest. Re-emitting identical bytes repaints identically — there was no churn
to avoid. This left `poll_interval` and its two constants dead (that helper
only ever fed the deleted arm; pacing comes from the host's 3s
`DEVICE_LINK_POLL_INTERVAL_MILLIS`, the cadence the module header documents),
and made `PendingPhase::AwaitingScan`'s `token` field write-only; both are
removed, and `paint_token` — which never touched `self` — is now a free
function so the regression test can drive it directly.

Compatibility: `invocation_id` is a new required field on a response DTO that
no released client consumes; the two frontend changes are additive at the
request boundary and widen what the host accepts nowhere.

Test Strategy
- Crate: `paint_token` re-export test asserts both the first poll and an
  identical re-export paint the scannable code (`ironclaw_telegram_extension`,
  201 passed). Sabotage-checked by reintroducing an `AwaitingVendor` arm.
- Frontend: new `device-link-api.test.ts` (blank ids omitted, required fields
  preserved, `revision: 0` survives the filter) and a `device-link-panel`
  case pinning that the host-minted invocation reaches the follow-up poll.
  Both sabotage-checked; 1271 vitest passed.
- Integration: `reborn_group_device_link` 15/15; `ironclaw_architecture_tests`
  green (wire DTO gained a field); clippy clean on both touched crates.
- Live: `start → poll → cancel` driven against real Telegram MTProto — the
  code is exported, re-exported, and still displayed on the second poll.
  NOT verified: nobody scanned it, so acceptance, DC migration, 2FA, and the
  credential mint on completion remain unexercised at every tier.

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

* fix(tests): repair test targets broken by the nearai#7477 x device-link merge

Three sites neither branch could have seen: our device-link driver tests
and integration harness profile still built the pre-nearai#7477
channel-adapter shape (now ChannelSurfaces), and nearai#7477's new
lifecycle_contract helpers predate the device_link binding slot and the
custody fields on ExtensionHostDeps. Caught by the workspace clippy
gate; the earlier post-merge check piped through tail and masked the
failing exit code.

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

* fix(linked-device): close the four review findings, with regression tests

- SessionPool: a poisoned lock now reports SessionPoolError::Poisoned
  instead of masquerading as AtCapacity (permanent corruption vs retry
  shortly), matching session_store's taxonomy.
- SessionPool takes Option<i32> api_id: a deployment without an MTProto
  identity now fails acquire closed with NotConfigured before custody or
  any dial (previously dialed with api_id = 0 and failed at the vendor),
  and a cold revoke reports LogoutUnverified immediately instead of
  burning a doomed handshake. The tool-side mapping carries the honest
  not-configured sentence; the CLI drops its unwrap_or(0).
- session_blob errors now carry a bounded serde reason (category +
  line/column only — never blob content), making a corrupt custody blob
  attributable.
- credential.rs relink version probe carries the silent-ok contract its
  fallback relies on (CAS is the authority; a failed load costs one
  conflict round-trip, never a clobber).
- GenericExtensionHost custody params are now required, with the
  fail-closed collapse moved to the composition boundary; test sites
  pass the unavailable shapes explicitly.

Also repairs two merge-tail gaps nearai#7477 exposed: the device-link fixture
manifest now speaks the per-axis channel grammar (was the retired
inbound/outbound booleans, failing 25 extension_host tests), and the
manager field-status expectation includes the two MTProto admin fields.

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

* fix(device-link): close the audit's blocker and vendor-neutrality findings

A 14-agent design pass found the generic device-link machinery is
vendor-blind in its runtime spine but not in its vocabulary, plus one
functional blocker independent of any second vendor.

THE BLOCKER. The auth tier re-mints a lapsed frame by calling
`DeviceLinkDriver::begin` again on the same flow, and the host driver
refused exactly that as a non-restartable `Internal`. The host flow TTL
(10m) outlives the step clock (60s), so at a lapse the flow is always
still live and the refusal always fired: every link not completed inside
the first frame terminalized with "cannot be completed for this
account". Both halves were tested and both suites passed, because
nothing crossed them.

`begin` now means one thing. A begin naming a live flow is a re-mint:
the stale vendor conversation is cancelled first (the parallel-
conversation hazard the refusal was really guarding), then a fresh one
starts under the same flow, carrying the flow clock and secret-attempt
count forward so a re-mint can neither extend the attempt nor reset an
abuse counter, and skipping the begin budget because it is not a new
attempt. The superseded test is rewritten, not deleted, to pin the
surviving invariant. `ironclaw_auth` now exports a DeviceLinkDriver
conformance suite covering the cross-half obligations, run against the
production driver; sabotage-tested by restoring the refusal.

VENDOR NEUTRALITY. The recipe's mode-label seam was built and then
bypassed, so the generic panel hardcoded one vendor's QR-vs-phone
ceremony in 11 locales and wedged forever against a vendor that
declares no alternate. `DeviceLinkPromptView` now carries
`alternate_available`, both recipe labels, `display_kind`,
`extension_id`, and `vendor_user_ref` (which used to double-book the
`code` slot); the labels ride the durable challenge beside
`display_name`, additive with serde defaults. The card gates and labels
the switch from the wire, resets mode on restart, honours display_kind,
and passes resume_flow_id (previously dead on every caller). Completion
copy no longer names one vendor's settings menu.

The host also gets its own voice: HostThrottled and LimitReached, so a
host budget stops reporting itself as vendor pushback, and NoBinding
maps off AccountUnavailable (an operator condition is not a broken
account).

ALSO: per-user budgets keyed by (user, extension) with counters evicted
on reap; at-most-one device_link recipe enforced at manifest parse (a
second was silently ignored, mis-attributing flows and grants);
PENDING_LINK_REVISION deduped and the provisional cap derived from the
driver's limit rather than agreeing by comment; port obligations
documented where implementors read them and the contradictory
poll-purity sentence reconciled; the specificity gate widened to
build.rs and frontend scripts, which immediately caught two real vendor
names now fixed rather than allowlisted; and a sabotage-tested
sole-consumer assertion on the MTProto stack.

Contracts ceiling re-pinned 10_344 -> 10_512 with the rationale in the
gate: declaration and documentation only.

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

* docs(telegram): design automatic linked channel identity

* feat(telegram): pair linked devices with bot channel

* fix(ci): classify linked-device Dependabot config

* fix(telegram): align linked-device CI evidence

* chore(telegram): document fixture panic invariants

* test(composition): classify channel harness as test-only

* fix telegram device-link setup CI

* fix standard messaging schema parity assertion

* fix telegram model search projection test

* feat(telegram): make the device-link cutover breaking for retired pairings

Reverses the zero-touch upgrade decision in AUTO-CHANNEL-IDENTITY §8 (owner
call, 2026-08-14): a proof-code identity binding written before the cutover
no longer authorizes anything on a device-link channel. Each connection
strategy now owns exactly one identity keyspace — the fallback chain is
deleted, and the device-link-v1 prefix is a fence that keeps retired rows
inert. Previously paired users land back at setup, get the connect-required
notice on their next bot DM, and re-link once: the same first-run ceremony
as a fresh install, and the same missing-credentials UX as every other
extension.

- admission, connection status, command roles, and outbound-target
  validation consult only the strategy's single keyspace; the plural
  channel_identity_lookup_keyspaces API is deleted
- the binder's retired-namespace cross-user veto is removed: an inert row
  cannot block a freshly authenticated link its owner has no way to clear
- ChannelIdentityKeyspace::Legacy renamed to Unversioned — nothing legacy
  about the namespace OAuth/pairing channels still live in
- retired rows stay untouched data (no bulk delete); explicit disconnect
  scrubs both generations, unchanged
- docs: AUTO-CHANNEL-IDENTITY §8 and the telegram package README now
  describe the breaking cutover; rollback stays valid because pre-cutover
  rows are never rewritten

Flipped pins, each watched red then green: resolver ignores a retired
pairing key; connection status reports disconnected; command roles confer
nothing; a stale foreign row does not veto a link; a retired delivery
target is offered only after re-link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LACC5dKyNy3GNXRNbvgz57

* fix(tests): bind the acme fixture's device-link adapter only when declared

The extension-profile fixture bound ScriptedDeviceLinkAdapter on every acme
bind, but check_binding proves agreement per axis and the stock
acme-messenger manifest declares oauth2_code only — so every bind failed
with UndeclaredDeviceLinkAdapter, activation never completed, install turns
recorded no capability results, and the standard-op tests' approval gates
never existed. Hidden until the merge queue: the PR lane's affected-surface
planner had never selected integration shards 2/3.

The adapter is now bound iff the installed manifest declares a device_link
recipe (the same declared_device_link_recipe test check_binding runs), so a
future acme device-link variant still gets the scripted adapter.

Verified: reborn_integration_extension_ingress 17/17 and
reborn_integration_extension_runtime 25/25 locally, Postgres legs included
(previously 3 + 4 failures reproducing the queue's shard 2/3 ejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LACC5dKyNy3GNXRNbvgz57

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
nearai#7157 follow-ups) (nearai#7377)

* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR nearai#7157 on current main (aa8b748 → 967f33a, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc (nearai#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da (nearai#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as nearai#6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d (nearai#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from nearai#7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb (nearai#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; nearai#7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(delivery): key observer gate notices by their gate ref

One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism nearai#7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(composition): pin the notification-channels gate dance when owner ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split nearai#7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(outbound): scope the whole notification-channels gate dance as the acting user

Unify the interim nearai#7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(conversations): key shared-route bindings per (conversation, actor)

A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(product)!: remove shared-route subject binding; scope = invoker

Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(reborn): align guidance, specs, and live-QA scripts with invoker scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(telegram): connect group chats via telegram_allowed_channels

Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(reborn): morph the test tier to invoker scope

Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(changelog): record the telegram_allowed_channels admission field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(merge): reconcile composition ceilings and capability_wiring test arity

Post-merge fixups after folding main (nearai#7157 squashed + nearai#7214 + the
inspector prompt-diagnostic work) into run-acts-as-invoker:

- Re-capture the composition absolute-mass ceiling 40_747 -> 40_811 in
  both the budget manifest and reborn_restructure_baselines.rs: the
  acting-user scope helper and shared-admission wiring add +64
  production LOC on the merged tree. Recorded rather than parked in the
  150-line tolerance.
- Add the 10th `tool_diagnostic_sink` argument (None) to the invoker's
  capability_wiring test call — main grew the signature after this
  branch wrote that call site.

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

* fix(conversations): refuse legacy-row route-kind mismatches; drop unreachable widen

A Direct request is the one key shape a retained legacy conversation-scoped
shared row can collide with. Resolve, lookup, reset, and link now refuse the
mismatch outright (BindingRequired) instead of trusting adapters never to
re-classify a conversation's route kind — pinned by a Direct-probe leg on the
legacy restart-path test. The forward half of the migration contract is pinned
too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a new
per-actor shared binding survives a restart (a deserialize-side regression
would previously have orphaned every group thread silently).

widen_binding_route_access and ReplyRouteAccess::allow_shared are deleted:
every Shared-keyed row is born shared under per-actor keying, so both widen
call sites were unreachable. The persisted flag stays for legacy reads.

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

* fix(delivery): key gate notices by gate ref on the triggered lane too

The gate-collapse fix shipped on the observer lane only; the background lane
still minted undiscriminated projection ids for ApprovalNeeded/AuthRequired,
so an automation run parking on a SECOND gate deduped to AlreadyDelivered,
recorded the whole delivery Failed, and the gate was never announced or
reply-routable (AGENTS.md: fix the sibling when a pattern bug is fixed).
TriggeredNotification's discriminator now carries the gate ref for gate
prompts (RunBlocked stand-ins compose their label with it), matching the
observer keying, with a triggered two-gate regression pinning outcome,
prompts, and both reply routes.

Also pinned: same-gate re-announcement dedupe (g1->g2->g1), two distinct AUTH
gates, and the refless id shapes incl. FinalReplyReady. Over-long
discriminators are bounded with a stable FNV-1a suffix so a maximal legal
TurnGateRef can never overflow ProjectionUpdateRef and silently lose a notice.

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

* refactor(identity): one contract derivation for every acting-identity scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

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

* fix(extension-host): collapse admission handles; operator-identity channels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

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

* test+docs(reborn): re-pin parity to per-actor scopes; align contract, docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
…rai#6896) (nearai#7131)

* fix(run_delivery): deliver triggered run failures to the creator (nearai#6896)

Scheduled/triggered runs that ended in Failed, Cancelled, or
RecoveryRequired produced no user-visible notification: the triggered
delivery driver minted notifications only for Completed /
BlockedApproval / BlockedAuth and recorded every other terminal status
as Skipped. A run that timed out before reaching an actionable state
only logged a warn and recorded Failed, leaving the creator in silence.

Delivery:
- triggered_notification_for_state now mints a FinalReplyReady
  notification for Failed and RecoveryRequired using the existing
  per-category failure summaries (reborn_failure_summary_for_category)
  over state.failure.category(), with a generic fallback when no
  category is present.
- Cancelled mints the same notification, preferring a failure-category
  summary when one is present and falling back to a fixed cancellation
  notice otherwise.
- The RunWaitTimedOut branch with no prior blocked marker now delivers
  the timeout notice as a terminal reply instead of recording Failed.
- The wildcard arm is replaced with explicit non-actionable statuses
  (Queued, Running, CancelRequested, BlockedResource,
  BlockedDependentRun, BlockedExternalTool) so a future status fails to
  compile rather than silently skipping.

Observer:
- TriggerFireSettlementObserver gains on_failed_fire_settled as a
  default no-op method, plus a TriggerFailedFireSettlement event
  carrying tenant/trigger/fire-slot/run-id/history-status. Noop and
  existing implementors keep compiling.
- The active-cleanup sweep fires on_failed_fire_settled when
  clear_active_fire succeeds with TriggerRunHistoryStatus::Error, so
  post-accept failures are observable for automation health. Ok,
  Running, and already-cleared fires do not fire the hook.

Tests:
- run_delivery_contract: Failed+model_error, Failed without category,
  Cancelled, and timeout-before-actionable all assert a Delivered
  outcome with the expected notice text and footer.
- worker tests: a terminal-Error active fire fires exactly one
  on_failed_fire_settled; a terminal-Ok active fire fires none.

The larger retry/redrive budget for failed post-accept fires
(retry_disposition has zero production callers) is intentionally left
for a follow-up; it is out of scope for this surgical delivery fix.

* style: cargo fmt the nearai#6896 delivery fix

* fix(triggers): address terminal delivery review feedback

* fix(assistant): drop unused UserId import after merge

* fix(run_delivery): address multi-agent review findings

- Extract shared terminal-notice helpers (final_reply_notice,
  outcome_for_delivery_failure, deliver_terminal_notice) so the
  timeout, OAuth-backstop, and generic failure arms share one notice
  shape and outcome taxonomy instead of a third hand-rolled copy.
- Add a bounded race-grace window after the wait backstop: a run that
  crosses into a terminal state during the final wait (cancellation in
  flight, failure landing after the last poll) now delivers the correct
  terminal notice instead of the timeout copy.
- Cancelled runs always deliver the fixed cancellation notice; the
  failure-category branch was unreachable in production and would have
  mislabeled a host/operator cancel as a failure.
- Update the stale invariant doc, the five-output surface contract
  count, and the exhaustiveness-only comment on the non-actionable arm.
- Document the cheap/non-blocking contract on
  TriggerFireSettlementObserver (the worker awaits it inline in the
  poller sweep) and note it at the active-cleanup call site.
- Add contract coverage for the timeout arm's delivery-failure outcome
  (Failed) and a regression test proving the race-grace path delivers
  the cancellation notice; the cancelled-with-category test now asserts
  the cancellation notice wins.

* fix(run_delivery): address review comments and restore CI gates

Review fixes (CodeRabbit on 01e887f/f8af109):
- Grace loop fails loud: log the bound TurnError on state-poll failure and
  the RunDeliveryError on terminal-notice build failure before falling back
  to the timeout copy, with silent-ok markers on both intentional fallbacks.
- Hoist TriggeredReplyTargetAuthority, CodecChannelTargetResolver, and
  TriggeredNotificationContext to one construction before the watcher loop;
  the race-grace arm, timeout arm, and loop body now share it.
- Collapse the duplicated failure-summary expression into one closure and
  name TurnStatus::Failed explicitly so future statuses are compiler-visible.
- Drop the stale "Only three states" count from the surface-contract doc.
- Test fixture: encode the late-terminal flip as one Option<(usize,
  ScriptedRunState)> field instead of two correlated Options with an expect.
- Terminal-crossing test: document why flip_after=30 deterministically
  outruns the wait poll budget and assert the grace loop issues no
  cancellation (cancel_calls == 0).

CI:
- composition-budget: re-seed loc_ceiling 40432 -> 40593 (measured on the
  merged tree; the nearai#7131 settlement observer adds +161 governed LOC of
  wiring) and move the arch-test record with it.
- trigger_poller: use the colon-form tracing target required by nearai#7146.

* ci: re-trigger pull_request workflows for c2460ed

* fix(composition): capture the settlement health warn in the observer test

The traced_test default filter is {crate}=trace, which drops events whose
metadata target is `ironclaw::reborn::…`. The observer warning is emitted
with the colon-form target (required by nearai#7146 — the equals form recorded a
field and never matched RUST_LOG target filters), so the test saw an empty
buffer. Enable tracing-test's no-env-filter feature, the same pattern the
capabilities/host-runtime/mcp/loop crates use for cross-target assertions.

Re-seed the composition budget to the merged-tree measurement (40747 ->
40867): nearai#7131's observer wiring lands on top of post-measurement mainline
inflow; measured with the gate, set to current. The arch-test record moves
with the manifest.

* fix(run_delivery): merge main and adapt to notice_discriminator String

- Merge origin/main (nearai#7377 run-acts-as-invoker, nearai#7323, nearai#7382, nearai#6938,
  nearai#7280, nearai#7393, nearai#7389, nearai#7364, nearai#7228, nearai#7371, nearai#7399).
- main's nearai#7377 landed a narrower terminal arm (generic failure notice for
  TurnStatus::Failed only); keep the nearai#6896 arm, which covers Failed and
  RecoveryRequired with sanitized per-category summaries plus Cancelled
  and the timeout grace path, and adapt to the Option<String>
  notice_discriminator main introduced.
- Re-seed the composition budget to the merged-tree measurement
  (40811 -> 40861, the run-failure settlement observer lands +50 governed
  LOC); the arch-test record moves with the manifest.
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
* docs(design): Telegram linked-device — proposal, plan, checklist, ADR

Design-only. Adds the engineering spec for linking a user's personal Telegram
account as a real MTProto linked device, so the agent can read their
conversations and act as them through the standard messaging operations.

Docs only — no production code, no behavior change.

Shape:
- README: overview, architecture, footprint, explicit non-goals
- PROPOSAL: decisions with rationale, per-crate change inventory, review log
- PLAN: 8 PRs with dependency edges and per-PR watch-lists
- CHECKLIST: definition of done, each box naming something to run or read
- ADR: the auth-hook decision and what it costs

Load-bearing decisions:
- Reads are live; no message content is persisted. Telegram is a cloud
  messenger, so history and search are server-side — which removes the mirror,
  the retention policy, the FTS plane, and (because no update stream is
  consumed) the session-sourced ingress work from v1.
- Device-link is an auth method with a narrow adapter hook, taking the
  extension-runtime spec's own "a vendor defeats the descriptor" revisit
  trigger. The hook revokes a stated security invariant; the ADR records the
  real compensation set and the in-process-vs-sidecar trade.
- Custody extends ironclaw_auth (a linked account is a CredentialAccount); the
  only genuinely new persistence surface is a CAS write path for a mutable
  binary secret.
- Sessions live in the existing telegram package behind a contracts-declared
  port; no new crates, no new runtime lane.

Vendor claims are verified against grammers 0.10.0 sources and the reference QR
implementations rather than assumed (PROPOSAL 14.1), and the whole document was
re-verified against origin/main after upstream nearai#7377/nearai#7397 (14.4) — which
removed owner-vs-actor and thereby retired this design's worst finding.

Status: sign-off withheld pending the conditions in the review log. Not
approved for implementation; opened for review.

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

* feat(telegram): linked-device — device-link auth, session custody, standard-op tools

Implements the design in docs/internal/design/telegram-linked-device/: a user
links their personal Telegram account as a real MTProto linked device, and the
agent reads their conversations and acts as them through the standard messaging
operations. Reads are live — no message content is persisted.

Contracts
- ironclaw_extension_contracts: device_link (DeviceLinkAdapter + its step/input
  vocabulary) and linked_session (SessionBytes, LinkedAccountRef/Grant,
  LinkedSessionPort + factory). VendorAuthRecipe::DeviceLink with every arm,
  including the (DeviceLink, DeviceLink) compatibility case and
  keepalive_idle_threshold -> None, both of which fail at activation rather than
  compile if missed.
- ironclaw_host_api: send_message.output.v2.json as a NEW file carrying the
  sent_unverified branch. .v1 is byte-identical — the standard's schema
  immutability rule forbids an in-place edit. Schema resolution is now
  version-aware so existing bindings keep resolving .v1 forever.

Auth
- Device-link flow: ordered steps with revision CAS so a duplicated poll is
  idempotent and never re-invokes the adapter; two clocks (a step clock that
  re-mints, a flow clock that terminalizes); AwaitingVendor projected explicitly
  as Authenticating rather than falling through to Disconnected.
- link_revision on CredentialAccount with a CAS-bearing opaque-material write.
  Auth owns conflict detection only; it does not parse the session blob.

Host
- Device-link binding slot and its check_binding arms. Retires
  auth_never_binds_is_not_a_binding_field, which encoded the invariant the ADR
  deliberately revokes; the retirement cites the ADR.
- SnapshotDeviceLinkDriver resolves extension -> bound adapter and enforces poll
  rate limits and TTLs host-side.

Package
- MTProto via grammers 0.10.0, exact-pinned: the pin is a security control, not
  hygiene, because 0.10.0 never persists server-pushed DC addresses and that is
  what makes address validation in IronclawSession airtight.
- Sockets confined to transport.rs. NoRetries plus an explicit wrapper, because
  AutoSleep would re-send a write after an I/O error and no retry policy can see
  whether a request is a write.
- QR login by re-export polling (the flow an official client uses), phone and
  2FA paths, per-link mutex, logout on every post-acceptance abort.
- 15 standard ops. send_message returning id == 0 is a confirmed-but-uncorrelated
  send: Completed with sent_unverified, never a failure — a failure is what a
  model retries, and the retry double-sends to a human. Dropped/Io on a write is
  outcome-unknown and maps to vendor_error instead.

Frontend
- One QR/countdown implementation, shared by the existing pairing panel and the
  new device-link card. QR <-> phone switch, 2FA entry, stale-revision guard,
  polling stops on terminal states.

Gates
- Vendor names kept out of generic crates.
- Cross-crate include ratchet 16 -> 17, recorded deliberately in that file: the
  telegram package gained prompt docs when it gained tools, using the same
  include shape Slack already uses. Not a new class of reach-in, and not
  repointable while the layer matrix forbids runtimes -> products.

Local verification: cargo fmt, clippy --all-targets --all-features -D warnings,
and the full ironclaw_architecture_tests suite all pass; 1342 unit/contract tests
green across the touched crates.

NOT complete. The design's checklist is largely unticked — no integration tests,
no live-Telegram verification, and the security conditions in PROPOSAL 14.2-14.4
remain unmet. See the PR body.

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

* test(telegram): integration harness, supply-chain pin, ownership pins, content bounds

Closes four gaps from the previous commit. All gates green; see the honesty
section below for what this does NOT close.

Integration
- tests/integration/support/harness/profiles/device_link.rs mounts the real
  bundled telegram package (its shipped manifest: channel + [auth.telegram] +
  15 standard_op tools) and writes [admin_configuration] through the real
  capability. New reborn_group_device_link target, registered in the root
  Cargo.toml, driving 3 scenarios against a scripted adapter.

Supply chain (the ADR requires these WITH the dependency, not later)
- All three grammers edges: =0.10.0 exact, default-features = false, explicit
  feature allowlists. grammers-client drops its `fs` default (nothing calls
  upload_file/download_media; attachments ride ironclaw_attachments). The socks5
  `proxy` feature stays off — a proxied dial bypasses Session::dc_option, which
  is the only seam our DC address validation owns.
- New reborn_linked_device_supply_chain_pin gate (13 tests) failing on any
  version or feature-set drift, with the rationale in its module doc.
- dependabot ignores grammers-*; Cargo.lock unmodified.

Ownership
- NewCredentialAccount::for_linked_device pins ExtensionOwned + empty grants;
  bump_link_revision refuses an unpinned account in both the production store
  and the fake. A §4.5 logout-before-unbind family lands in auth::cleanup.

Untrusted read content (§6.4)
- The content bounds become §7.2 constants with zero-checks and relationship
  asserts. @username handles now pass through sanitize_untrusted_text — the
  handle is the identity the model is told to trust.
- New conformance.rs proves every content-returning addendum frames its output
  as untrusted, and that the framing predicate is not inert.

Honesty — this is NOT a working feature yet
The handshake has no production wiring: nothing constructs a DeviceLinkDriver,
session custody resolves to unavailable() in every deployment, the durable
credential store does not implement opaque material (blocked on a CAS-bearing
SecretStorePort::put that was never built), completion cannot mint an account,
LinkedAccountResolver has zero implementations, and the shipped UI calls
/api/reborn/product-auth/device-link/... routes that do not exist. Fourteen
TODO(design) markers record each seam. Nothing here has ever spoken MTProto.

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

* fix(webui): device link rides the generic product-auth routes, not a new namespace

A build agent invented /api/reborn/product-auth/device-link/{start,flow/{id}/
status,flow/{id}/input,flow/{id}/cancel} and then recorded its own invention as
a missing backend dependency. PROPOSAL §8.12 says the opposite: "additive
flow-status fields (step, revision, display, retry-after); route input
submission to the driver" — extend what exists.

A device link IS an AuthFlowRecord, and flow_status(scope, flow_id) is already
generic over flows; the route is only *named* oauth/... for historical reasons.
So the browser now calls the routes that are actually mounted:

  status  -> /api/reborn/product-auth/oauth/flow/{flow_id}/status
  start   -> /api/reborn/product-auth/extension/oauth/start
  input   -> /api/reborn/product-auth/manual-token/secret/submit
  cancel  -> /api/reborn/product-auth/oauth/flow/{flow_id}/reconcile

That removes "no backend routes exist" as a blocker. What remains is genuinely
additive and much smaller: the status response must carry the device-link frame,
and secret submission must route to the device-link driver — both extensions of
handlers already mounted in product_auth/mod.rs, marked TODO(backend) at the one
place that reconciles them.

Frontend suite green: 143 files, 1264 tests.

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

* fix(webui): device-link routes — status is shared, start/input/cancel are not

Corrects an over-correction. The previous commit routed EVERYTHING through
existing product-auth routes, which is wrong: extension/oauth/start builds an
authorize URL (a device link has none) and manual-token/secret/submit means
"user pasted an API key" (not "user typed step 3's 2FA code").

The honest shape is a mix:

- STATUS is genuinely shared. flow_status(scope, flow_id) fetches an
  AuthFlowRecord and returns its status with no OAuth-specific logic, and
  PROPOSAL §8.12 asks for additive fields on exactly that response. Polling
  extends the existing route.
  Naming wart recorded: the route is spelled oauth/flow/... though the object
  it serves is generic. Renaming to /product-auth/flow/{flow_id}/status with
  the old spelling kept as an alias is the right follow-up, and is a
  route-descriptor change rather than part of this feature.

- START, INPUT and CANCEL are device-link specific, because the operations
  differ: start takes a link mode (QR vs phone); input carries a typed kind
  plus the step revision it was typed against; cancel must ask the vendor to
  log the device out, or an accepted-but-abandoned link leaves an orphan
  authorization on the user's account (§4.3). Nothing existing does that.

These three are marked TODO(backend) as work THIS feature owes — not, as the
original agent comment claimed, a dependency on another branch.

Frontend suite green: 143 files, 1264 tests.

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

* feat(telegram): finish the linked device — custody, mint, routes, resolver

The branch shipped a fail-closed skeleton: green gates over fourteen
`TODO(design)` markers and a feature that could not link an account. This
closes the chain end to end and fixes what the extension-unification audit
found on the way.

Audit findings, fixed rather than worked around:

* `LinkedAccountResolver` was declared by the telegram package, so the
  containment PROPOSAL §5.1 requires — rooted in a HOST-minted grant — could
  only ever have been satisfied by the package itself. Moved into
  `ironclaw_extension_contracts` beside `LinkedSessionPortFactory`, supplied
  on `BindContext`, and implemented host-side over the same
  credential-account selection every runtime injection uses.
* `DeviceLinkBinding` carried a bare `user_id`, which is *why* completion
  could not mint: minting needs an `AuthProductScope` and synthesizing one
  from a user id would re-derive security-relevant scope. It now carries the
  durable flow's own scope (`user_id()` is an accessor over it).

The implementation chain:

* `ironclaw_secrets` gains a compare-and-swap write path
  (`put_versioned`/`read_versioned`): the previous last-writer-wins `put`
  would let a concurrent write clobber a rotating vendor auth key, which is a
  silently dead link. Decorator and four test doubles follow the widened
  trait.
* `ironclaw_auth`'s durable store implements opaque-material load/store over
  it — detection only, never a semantic merge: the blob is vendor-private and
  only the package can read it. `complete_linked_device_link` is the one
  place the completion policy lives (reuse-before-create, never resurrect a
  revoked account, the §4.5 ownership pin, load-then-CAS so a crashed prior
  link cannot brick relinking).
* Custody splits by revision: a provisional in-process space for the
  handshake (the blob exists *before* any account does — §4.3's store → mint
  → report) and durable custody behind the credential service, plus the
  ref→account directory that maps a host-issued `LinkedAccountRef` to the
  coordinates the auth domain needs.
* The extension host's driver mints the account at completion and registers
  it with custody, so `DeviceLinkStepOutcome` finally carries `Some(account)`
  and a link can complete.
* Composition wires all of it and attaches the flow driver and the linked-
  device revoker to the product-auth bundle.
* `api_hash` is `secret = true`, so it cannot ride `BindContext` (non-secret
  config only). It resolves at *load* — the one I/O-legal point before bind —
  through a new pre-scoped `LoadTimeAdminSecrets` port
  (`NativeExtensionFactory::load` becomes `async`). Unset, the adapter still
  binds and fails every link attempt closed, so a bot-only deployment keeps
  activating.
* The CLI binds all three Telegram surfaces (channel + device-link + tools),
  the shape `check_binding` has always required.

WebUI: four device-link routes, not three. `poll` is the departure from the
design — a card cannot poll the read-only status route, because a link only
advances when the host re-exports the login token (§4.2) and nothing else
drives it, so a card polling a pure read waits forever on a QR that was
already scanned. Routing the advance through the shared GET would also hide a
vendor call behind a descriptor declared read-shaped. STATUS stays shared,
stays a read, and carries §8.12's additive frame so a re-rendered card
hydrates without disturbing a live link. The ADR's detection control ships in
the completion card: the resolved account plus a count-the-devices ask,
worded to claim only what it catches.

Two failures were real behavior, not test drift:

* A cleanup decorator built at construction captured the account read model
  before it was final and would have broken *every* cleanup. The
  logout-before-unbind ordering moved to the bundle's single cleanup entry
  point, where the read model is settled.
* A lost compare-and-swap surfaced as 503 "retry later" to a card holding a
  stale step revision; retrying a superseded revision can never succeed. It
  maps to 409.

Proof: `scenario_handshake_mints_and_serves` drives composition's real
`DeviceLinkFlowDriver` start → poll → submit → completed, asserts the §4.5
ownership pin on the account the mint produced, asserts custody actually
persisted, and proves a linked tool call resolves to that account. Three
caller-level route tests drive the four routes over the mounted router.

NOT DONE, and not claimed: nothing here has ever spoken MTProto. Every test
drives a scripted adapter, so QR acceptance, DC migration, 2FA and flood-wait
are unexercised, and the `id == 0` / `Dropped`-on-write evidence rules have
never met a real server. PROPOSAL §14.3's withheld security sign-off is
unchanged. CHECKLIST is 51/135 with a note on why the ratio is what it is.

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

* fix(device-link): the browser could start a link but never finish one

Three defects found by driving the real flow in a local stack, each at a
seam between two things that were individually tested and green.

**Blank ids were sent for ids the caller did not have** (frontend). The chat
auth-gate card reads its scope from a gate model that has no `threadId` at
all and leaves `invocationId` null, so it posted `thread_id: ""`. The host
parses each of these into a validated newtype — `ThreadId`, `InvocationId`,
`TurnRunRef`, `AuthGateRef` — every one of which rejects a blank, so `start`
answered `400 invalid_request` before the flow began. Absent optional ids are
now omitted at the one API choke point rather than defaulted to `""`.
Required fields are deliberately NOT filtered: a blank `provider` must reach
the host and be rejected, not vanish into a request meaning something else.

**`start` minted an invocation and never returned it** (wire contract).
`scope_matches` is exact equality over the whole scope, and poll/input/cancel
re-derive that scope from what the browser sends back. A card opened outside
a run — the Extensions configure modal, no run and no gate — has no
invocation to carry in, so the host minted one and stored the flow under it.
With no way to learn that value, every follow-up call built a different scope
and 400'd: the flow could be started and never advanced. `DeviceLinkFlowResponse`
now echoes `invocation_id`, exactly as `ManualTokenSetupResponse` already
does and for the same reason, and the panel prefers it over the prop.

**A still-valid QR was reported as "nothing to show"** (adapter). Telegram
returns the same token bytes on every poll for the whole of a token's
window; `paint_token` treated unchanged bytes as `AwaitingVendor`, which is
defined as "nothing to show", so the card blanked the code about one poll
after painting it and parked on "waiting for the vendor" until expiry. It now
always returns `Display`, with `expires_in` recomputed so the countdown stays
honest. Re-emitting identical bytes repaints identically — there was no churn
to avoid. This left `poll_interval` and its two constants dead (that helper
only ever fed the deleted arm; pacing comes from the host's 3s
`DEVICE_LINK_POLL_INTERVAL_MILLIS`, the cadence the module header documents),
and made `PendingPhase::AwaitingScan`'s `token` field write-only; both are
removed, and `paint_token` — which never touched `self` — is now a free
function so the regression test can drive it directly.

Compatibility: `invocation_id` is a new required field on a response DTO that
no released client consumes; the two frontend changes are additive at the
request boundary and widen what the host accepts nowhere.

Test Strategy
- Crate: `paint_token` re-export test asserts both the first poll and an
  identical re-export paint the scannable code (`ironclaw_telegram_extension`,
  201 passed). Sabotage-checked by reintroducing an `AwaitingVendor` arm.
- Frontend: new `device-link-api.test.ts` (blank ids omitted, required fields
  preserved, `revision: 0` survives the filter) and a `device-link-panel`
  case pinning that the host-minted invocation reaches the follow-up poll.
  Both sabotage-checked; 1271 vitest passed.
- Integration: `reborn_group_device_link` 15/15; `ironclaw_architecture_tests`
  green (wire DTO gained a field); clippy clean on both touched crates.
- Live: `start → poll → cancel` driven against real Telegram MTProto — the
  code is exported, re-exported, and still displayed on the second poll.
  NOT verified: nobody scanned it, so acceptance, DC migration, 2FA, and the
  credential mint on completion remain unexercised at every tier.

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

* fix(tests): repair test targets broken by the nearai#7477 x device-link merge

Three sites neither branch could have seen: our device-link driver tests
and integration harness profile still built the pre-nearai#7477
channel-adapter shape (now ChannelSurfaces), and nearai#7477's new
lifecycle_contract helpers predate the device_link binding slot and the
custody fields on ExtensionHostDeps. Caught by the workspace clippy
gate; the earlier post-merge check piped through tail and masked the
failing exit code.

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

* fix(linked-device): close the four review findings, with regression tests

- SessionPool: a poisoned lock now reports SessionPoolError::Poisoned
  instead of masquerading as AtCapacity (permanent corruption vs retry
  shortly), matching session_store's taxonomy.
- SessionPool takes Option<i32> api_id: a deployment without an MTProto
  identity now fails acquire closed with NotConfigured before custody or
  any dial (previously dialed with api_id = 0 and failed at the vendor),
  and a cold revoke reports LogoutUnverified immediately instead of
  burning a doomed handshake. The tool-side mapping carries the honest
  not-configured sentence; the CLI drops its unwrap_or(0).
- session_blob errors now carry a bounded serde reason (category +
  line/column only — never blob content), making a corrupt custody blob
  attributable.
- credential.rs relink version probe carries the silent-ok contract its
  fallback relies on (CAS is the authority; a failed load costs one
  conflict round-trip, never a clobber).
- GenericExtensionHost custody params are now required, with the
  fail-closed collapse moved to the composition boundary; test sites
  pass the unavailable shapes explicitly.

Also repairs two merge-tail gaps nearai#7477 exposed: the device-link fixture
manifest now speaks the per-axis channel grammar (was the retired
inbound/outbound booleans, failing 25 extension_host tests), and the
manager field-status expectation includes the two MTProto admin fields.

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

* fix(device-link): close the audit's blocker and vendor-neutrality findings

A 14-agent design pass found the generic device-link machinery is
vendor-blind in its runtime spine but not in its vocabulary, plus one
functional blocker independent of any second vendor.

THE BLOCKER. The auth tier re-mints a lapsed frame by calling
`DeviceLinkDriver::begin` again on the same flow, and the host driver
refused exactly that as a non-restartable `Internal`. The host flow TTL
(10m) outlives the step clock (60s), so at a lapse the flow is always
still live and the refusal always fired: every link not completed inside
the first frame terminalized with "cannot be completed for this
account". Both halves were tested and both suites passed, because
nothing crossed them.

`begin` now means one thing. A begin naming a live flow is a re-mint:
the stale vendor conversation is cancelled first (the parallel-
conversation hazard the refusal was really guarding), then a fresh one
starts under the same flow, carrying the flow clock and secret-attempt
count forward so a re-mint can neither extend the attempt nor reset an
abuse counter, and skipping the begin budget because it is not a new
attempt. The superseded test is rewritten, not deleted, to pin the
surviving invariant. `ironclaw_auth` now exports a DeviceLinkDriver
conformance suite covering the cross-half obligations, run against the
production driver; sabotage-tested by restoring the refusal.

VENDOR NEUTRALITY. The recipe's mode-label seam was built and then
bypassed, so the generic panel hardcoded one vendor's QR-vs-phone
ceremony in 11 locales and wedged forever against a vendor that
declares no alternate. `DeviceLinkPromptView` now carries
`alternate_available`, both recipe labels, `display_kind`,
`extension_id`, and `vendor_user_ref` (which used to double-book the
`code` slot); the labels ride the durable challenge beside
`display_name`, additive with serde defaults. The card gates and labels
the switch from the wire, resets mode on restart, honours display_kind,
and passes resume_flow_id (previously dead on every caller). Completion
copy no longer names one vendor's settings menu.

The host also gets its own voice: HostThrottled and LimitReached, so a
host budget stops reporting itself as vendor pushback, and NoBinding
maps off AccountUnavailable (an operator condition is not a broken
account).

ALSO: per-user budgets keyed by (user, extension) with counters evicted
on reap; at-most-one device_link recipe enforced at manifest parse (a
second was silently ignored, mis-attributing flows and grants);
PENDING_LINK_REVISION deduped and the provisional cap derived from the
driver's limit rather than agreeing by comment; port obligations
documented where implementors read them and the contradictory
poll-purity sentence reconciled; the specificity gate widened to
build.rs and frontend scripts, which immediately caught two real vendor
names now fixed rather than allowlisted; and a sabotage-tested
sole-consumer assertion on the MTProto stack.

Contracts ceiling re-pinned 10_344 -> 10_512 with the rationale in the
gate: declaration and documentation only.

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

* docs(telegram): design automatic linked channel identity

* feat(telegram): pair linked devices with bot channel

* fix(ci): classify linked-device Dependabot config

* fix(telegram): align linked-device CI evidence

* chore(telegram): document fixture panic invariants

* test(composition): classify channel harness as test-only

* fix telegram device-link setup CI

* fix standard messaging schema parity assertion

* fix telegram model search projection test

* feat(telegram): make the device-link cutover breaking for retired pairings

Reverses the zero-touch upgrade decision in AUTO-CHANNEL-IDENTITY §8 (owner
call, 2026-08-14): a proof-code identity binding written before the cutover
no longer authorizes anything on a device-link channel. Each connection
strategy now owns exactly one identity keyspace — the fallback chain is
deleted, and the device-link-v1 prefix is a fence that keeps retired rows
inert. Previously paired users land back at setup, get the connect-required
notice on their next bot DM, and re-link once: the same first-run ceremony
as a fresh install, and the same missing-credentials UX as every other
extension.

- admission, connection status, command roles, and outbound-target
  validation consult only the strategy's single keyspace; the plural
  channel_identity_lookup_keyspaces API is deleted
- the binder's retired-namespace cross-user veto is removed: an inert row
  cannot block a freshly authenticated link its owner has no way to clear
- ChannelIdentityKeyspace::Legacy renamed to Unversioned — nothing legacy
  about the namespace OAuth/pairing channels still live in
- retired rows stay untouched data (no bulk delete); explicit disconnect
  scrubs both generations, unchanged
- docs: AUTO-CHANNEL-IDENTITY §8 and the telegram package README now
  describe the breaking cutover; rollback stays valid because pre-cutover
  rows are never rewritten

Flipped pins, each watched red then green: resolver ignores a retired
pairing key; connection status reports disconnected; command roles confer
nothing; a stale foreign row does not veto a link; a retired delivery
target is offered only after re-link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LACC5dKyNy3GNXRNbvgz57

* fix(tests): bind the acme fixture's device-link adapter only when declared

The extension-profile fixture bound ScriptedDeviceLinkAdapter on every acme
bind, but check_binding proves agreement per axis and the stock
acme-messenger manifest declares oauth2_code only — so every bind failed
with UndeclaredDeviceLinkAdapter, activation never completed, install turns
recorded no capability results, and the standard-op tests' approval gates
never existed. Hidden until the merge queue: the PR lane's affected-surface
planner had never selected integration shards 2/3.

The adapter is now bound iff the installed manifest declares a device_link
recipe (the same declared_device_link_recipe test check_binding runs), so a
future acme device-link variant still gets the scripted adapter.

Verified: reborn_integration_extension_ingress 17/17 and
reborn_integration_extension_runtime 25/25 locally, Postgres legs included
(previously 3 + 4 failures reproducing the queue's shard 2/3 ejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LACC5dKyNy3GNXRNbvgz57

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

This branch was successfully deployed

No deployments
ironclaw-ci-preview / ironclaw-pr-7377 — 6cc33c13 Deployed Aug 8, 2026 by railway-app[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: ci CI/CD workflows scope: docs Documentation size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants