feat!: a run acts as its invoker — remove shared-route subject binding (#7157 follow-ups) - #7377
Conversation
…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
… 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>
Review triage — every open thread addressedAll 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):
CodeRabbit:
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. |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
CHANGELOG.mdis excluded by!CHANGELOG.md
📒 Files selected for processing (34)
.github/workflows/live-canary.ymlcrates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rscrates/app/ironclaw_composition/src/runtime/capability_host.rscrates/app/ironclaw_composition/src/runtime/capability_host/notification_channels_set.rscrates/app/ironclaw_composition/src/runtime/capability_host/outbound_delivery.rscrates/app/ironclaw_composition/src/runtime/capability_host/tests.rscrates/contracts/ironclaw_loop_contracts/src/host/run_context.rscrates/contracts/ironclaw_loop_contracts/src/host/run_context/tests.rscrates/domains/ironclaw_conversations/src/memory.rscrates/domains/ironclaw_conversations/tests/conversation_state_store_contract.rscrates/domains/ironclaw_conversations/tests/inbound_contract.rscrates/extensions/ironclaw_extension_host/src/channel_host.rscrates/extensions/ironclaw_extension_host/src/channel_outbound_targets.rscrates/extensions/ironclaw_extension_host/src/channel_shared_admission.rscrates/extensions/ironclaw_extension_host/src/lib.rscrates/extensions/ironclaw_extension_manager/src/channel_config_product_service.rscrates/loop/ironclaw_loop_host/src/synthetic_capability.rscrates/product/ironclaw_assistant/src/channel_workflow.rscrates/product/ironclaw_assistant/src/model_channel_delivery/tests.rscrates/product/ironclaw_assistant/src/project_create_capability.rscrates/product/ironclaw_assistant/src/run_delivery/observer.rscrates/product/ironclaw_assistant/src/run_delivery/prompts.rscrates/product/ironclaw_assistant/src/run_delivery/triggered.rscrates/product/ironclaw_assistant/tests/product_surface_contract.rscrates/product/ironclaw_assistant/tests/run_delivery_contract.rsdocs/reborn/contracts/conversation-binding.mddocs/reborn/setup-slack-for-reborn-binary.mddocs/reborn/target-architecture/CHECKLIST.mdtests/integration/extension_delivery.rstests/integration/group_extensions/scenario_slack_channel_lifecycle_state_machine.rstests/integration/support/group.rstests/integration/support/group_constructors.rstests/reborn_identity_prompt_scope_isolation_parity.rstests/support/reborn_parity_qa/binary_e2e.rs
💤 Files with no reviewable changes (1)
- crates/extensions/ironclaw_extension_host/src/lib.rs
| 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, | ||
| )?, | ||
| }; |
There was a problem hiding this comment.
🔒 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; |
There was a problem hiding this comment.
📐 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 || trueRepository: 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: importhandle_declares_fieldthroughcrate::channel_shared_admission.crates/extensions/ironclaw_extension_host/src/channel_host.rs#L101/#L108: callshared_channel_admission_handleand constructChannelConfigSharedAdmissionthroughcrate::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
| /// 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. |
There was a problem hiding this comment.
📐 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.
| // 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" | ||
| ); |
There was a problem hiding this comment.
🔒 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
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.
- 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.
…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.
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>
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>
* 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>
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>
…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.
* 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>
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):
*_allowed_channelsconnected-channel lists, checked on resolve, lookup, and reset through the newSharedConversationAdmissionport — 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.acting_user_id/effective_user_idsplit inoutbound_delivery.rsis unified ontoLoopRunContext::acting_user_id, and the audit finished the job: the whole scope recipe is nowLoopRunContext::acting_resource_scopeon the contract type, and every remaining hand-rolled ladder (composition's owner-firstresource_scope_for_runfor workspace/skill mounts, the inline grant-minting copy,project_create_capability'seffective_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 invisible_capability_request_uses_acting_user_for_runtime_scope).AlreadyDelivered, recorded the delivery Failed, and was never announced or reply-routable). Over-long discriminators are hash-bounded so a maximal legalTurnGateRefcan never make a notice undeliverable at theProjectionUpdateRefcap.telegram_allowed_channelsadded (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:
run_delivery_contract.rsall state this; a group-safe pairing affordance, e.g. DM-ing the pinger, 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, soDirectrequest 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);per_actor_shared_bindings_keep_their_threads_across_reopenproves 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);*_subject_routesconfig 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):
replay payload is unavailable). Re-requesting approval recovers. Now pinned on BOTH ports:approval_resume_with_missing_replay_payload_fails_closed(runtime port) andapproval_resume_misses_owner_scoped_replay_payload_and_fails_closed+ its acting-scope positive control (synthetic port).AlreadyDeliveredrecord no longer matches. Refless kinds (FinalReplyReadyetc.) 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_actordrives 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.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.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 asLoopRunContext::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).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).loop_contracts' size ceiling was originally re-captured downward via therun_context/tests.rssplit; the ladder tests andacting_resource_scopere-capture it with dated provenance.Sabotage verification (Task 3, gate-notice collision)
observer_delivers_a_prompt_for_each_distinct_approval_gatedrives the realDeliveryCoordinatorover 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 toNonefails 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(theBlockedAutharm has its own discriminator wire), andtriggered_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)
telegram_allowed_channelsrow (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).run-notification:{kind}:{run_id}refless shape byte-stable,FinalReplyReadyincluded).resource_scope_for_runtrap is gone.widen_binding_route_accessandReplyRouteAccess::allow_shareddeleted (structurally unreachable under per-actor keying — every Shared-keyed row is born shared; the persisted flag stays for legacy reads).SharedChannelAdmissionHandlescollapsed to the one declared handle (Option<String>at scan, plainStringin 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.mdamended (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.mdstate contradiction reconciled.canonical_actor_user,resolve_default_binding_actor_user, re-narrated legacy-shape tests, rewordedworkflow_request/ChannelHostIdentitydocs); vestigialTenantIdkeep-alive and the unconditionally-Okresolved_binding_from_resolutionfallibility cleaned up where touched.observer.rscarries thearch-exempt: large_fileannotation its size requires;REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELSis threaded throughlive-canary.yml; CHANGELOG sections follow Added/Changed/Removed order.Change Type
Linked Issue
Follow-up to #7157 (same underlying decision; branched off its head).
Validation
cargo fmt --all -- --checkcargo clippy --all --benches --tests --examples --all-features -- -D warningscargo build(via full test builds)DOCKER_HOST=unix://$HOME/.colima/default/docker.sock)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:
Tests added or updated:
shared_admissionport 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.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_parityre-pinned to the per-actor model.live-canary.yml; the new allowed-channels env var threaded; live-QA scripts re-pinnedWhat 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
SharedConversationAdmissionis a decision port (bool), constructed only by channel-host assembly/extras — and only for channels with per-user actor identity; no new evidence mintingBindingRequired; downstream match sites unaffectedserde(default)onBindingKey.shared_actor_user_idfails closed by construction (legacy shared rows become unreachable, never mis-owned; the Direct-collision shape is refused) and has migration tests in both directionsTransient(pinned with projection test); rejections stay permanentDatabase Impact
None (no SQL migrations). The conversations envelope (
/conversations/state.jsonoverRootFilesystem) 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
BindingKeydeserializer has nodeny_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
BindingRequired.trigger_delivery_migration.rsintoironclaw_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