Skip to content

refactor(channels): normalize ingress and split reply from delivery - #7477

Merged
BenKurrek merged 43 commits into
mainfrom
webapp-inbound-channel
Aug 12, 2026
Merged

BenKurrek merged 43 commits into
mainfrom
webapp-inbound-channel

Conversation

@BenKurrek

@BenKurrek BenKurrek commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Outcome

Finishes the channel-contract redesign around one rule: every external channel normalizes provider-specific ingress, replies, delivery, and attachments at its boundary; internal product, workflow, composition, and persistence code operate on stable channel-neutral structures.

The manifest remains reborn.extension_manifest.v3. This is an in-place channel-section evolution, not a new manifest generation.

Contract and boundary changes

  • Splits the former broad adapter into three orthogonal capabilities:
    • ChannelIngress::receive returns a complete normalized inbound outcome.
    • ChannelReply::send_reply handles source-routed conversational replies.
    • ChannelDelivery::deliver handles target-resolved out-of-band delivery.
  • Keeps stream replies host-owned: Web App publishes durable projection events over SSE/WebSocket and implements no reply adapter. Slack and Telegram use message replies through their adapters.
  • Replaces target-search grammar with one optional typed ExternalActorRef -> Option<ExternalConversationRef> direct-target provisioning operation.
  • Removes the built-in webui session fallback. Authenticated browser ingress resolves only through the manifest-declared web-app session channel.
  • Moves first-party channel initialization to a generic CLI-edge binding hook; composition no longer branches on Web App or VAPID.
  • Retains only a private, one-version v3 compatibility reader for the immediately preceding channel shape. There is no v4, generalized migration DSL, or Web API route compatibility layer.

Ingress and attachments

  • Slack and Telegram parse vendor payloads and fetch/reconcile private attachment bytes before returning NormalizedInboundMessage.
  • Web App decodes its base64 wire attachments at its authenticated-session edge into the same canonical InboundAttachment.
  • One product admission method consumes the normalized message, projects byte-free descriptors into the durable envelope, and lands the owned bytes exactly once. The duplicate sidecar attachment argument and full-payload clones are gone.
  • Provider batch staging now stores immutable fragment payload rows plus a small CAS header/catalog, avoiding repeated rewrites and payload-wide recovery scans.
  • Reply contexts are stored per hashed conversation rather than in one multi-megabyte snapshot, and storage failures fail closed before adapter egress.
  • Legacy Slack/Telegram product-envelope parsers and Telegram's superseded product renderer were deleted; vendor packages no longer construct product workflow DTOs.

Reply, streaming, delivery, and notifications

  • Routing is the minimal two-arm Reply | Delivery axis. Content kinds and authorization origins stay in their owning request types instead of becoming ornamental routing enums.
  • Stable delivery replays consult the authoritative stored attempt before current target validation, so an already-delivered fact remains delivered after revocation or validator outage.
  • Stream delivery evidence now comes from durable final assistant-message/projection facts, not process-local live-progress buffers.
  • Enrollment-required delivery resolves missing owner or zero registrations to no target before adapter invocation; non-enrollment channels perform no registration I/O.
  • Adapter reports must prove exact part cardinality. Malformed/empty evidence settles as unknown and is never blindly retried.
  • Automatic unfenced lazy recovery of Sending rows was removed; quiescent recovery remains explicit.
  • OAuth direct-message provisioning is deferred until the complete binding transaction commits and is awaited/idempotent.
  • Web Push registration documents remain host-owned and opaque. New host IDs are UUIDs, refresh preserves IDs, and the bounded compatibility reader retains the immediately preceding persisted shape.
  • VAPID bootstrap uses atomic create-if-absent secret storage so concurrent replicas converge on one keypair.

Type and state simplification

Removed mirror or dead lifecycle vocabulary, including:

  • duplicate surface trust/binding enums;
  • NormalizedAttachment in favor of canonical InboundAttachment;
  • duplicate communication-delivery kind;
  • StoredRegistration mirror;
  • dead synchronous render outcome types;
  • legacy ReplyKind / DeliveryOrigin routing taxonomy;
  • target-query/candidate search API;
  • provider-package product ingress/render DTO paths;
  • redundant IDs and Web App runtime slot.

Meaningful seams remain distinct: untrusted wire vs decoded input, provider attachment handle vs complete bytes, transient bytes vs durable descriptors, trusted envelope construction, reply vs delivery, stream vs message transport, provider evidence vs durable attempt state, and product projection vs browser wire events.

Compatibility and rollback

  • Manifest schema stays v3. Current writers emit only [channel.ingress], [channel.reply], and [channel.delivery]; a private reader normalizes only the immediately preceding deployed v3 shapes.
  • The bundled SPA and server switch together to the manifest session route; no old Web API/session alias is retained.
  • Persisted VAPID and enrollment storage coordinates remain stable. Registration reads accept the bounded preceding shape; writes remain rollback-readable where structurally meaningful.
  • Sending attempts fail closed rather than risking cross-replica duplicate egress without a real lease invariant.
  • Rollback of a rewritten manifest row requires restoring the prior manifest/package row; no permanent dual taxonomy is introduced.

Test strategy

Observed locally on signed commit 97274d5c9:

  • cargo test --workspace --all-features — green, including doctests, Docker-backed paths, libSQL and PostgreSQL contracts.
  • cargo clippy --all --benches --tests --examples --all-features -- -D warnings — green.
  • cargo fmt --all -- --check — green.
  • Focused channel, attachment, replay, durable-stream, VAPID concurrency, enrollment, batch restart/migration, and adapter-conformance regressions are included.
  • scripts/ci/docs_publication_boundary.py and diff-integrity checks — green.
  • Architecture, target-tree, and GitHub CI checks are rerun after push.

Audit

Independent agents reviewed architecture/boundaries, lifecycle enums and DTOs, ingress/security, delivery correctness, compatibility, code quality, and multi-replica scalability. Material findings were implemented and regression-tested. Four existing review threads remain intentionally open as separately scoped follow-ups; all stale or remediated review threads will be resolved after this push.

This PR is not being merged by this task.

BenKurrek and others added 14 commits August 10, 2026 08:16
…r is enrolled

The "Web app" row in the notification-channels picker rendered READY with a
selectable checkbox even with zero enrolled browsers (no push subscription),
so a user could pick a channel that has nowhere to deliver. Selectability and
the pill now follow the account's enrollment count: with no enrolled browser
the web-app checkbox cannot be SELECTED and its pill drops from Ready to
Unavailable, while the nested "Enable notifications in this browser" affordance
shows how to fix it. An already-stored selection stays deselectable (disabled
only when unchecked), so a browser that unsubscribes never leaves a locked-on
checkbox.

The web-push row now owns its device hook (WebPushChannelRow) so the account
status query still mounts only when the row is present; the shared row label
was extracted (renderChannelRowLabel) so every other channel renders its
checkbox inline, unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add IngressVerificationRecipe::AuthenticatedSession — the trust class for a
channel whose caller the host's authenticated transport (T1) already verified,
so it needs no webhook signature. Handle it fail-closed in the two webhook host
sites: no evidence mint in channel_host, and a NotWebhookVerifiable rejection in
the ingress verifier (a session channel mounts no webhook route and must never
be attested verified-inbound through the webhook path).

Incremental checkpoint toward the generic-inbound pipeline (PR2).

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

A channel's ingress mount now depends on its trust class. Webhook recipes (T2:
hmac/shared-secret/none) mount /webhooks/extensions/{id}/{suffix} and MUST
declare a route_suffix; an authenticated_session recipe (T1) is verified upstream
by the host transport, mounts no webhook route, and MUST NOT declare one.

- route_suffix becomes Option<RouteSuffix> (serde default+skip; existing webhook
  manifests parse unchanged into Some).
- ChannelDescriptor::validate pairs the recipe kind with route_suffix presence,
  fail-closed both ways (SessionIngressWithRouteSuffix / WebhookIngressWithoutRouteSuffix).
- Every mount/route-table consumer (active snapshot build + resolve + conflict,
  deployment channels resolve, lifecycle reserved-route check) fails closed when
  a session channel carries no route_suffix — it can never match a webhook route.

Groundwork for routing the web app's authenticated session through the one
generic inbound pipeline (PR2).

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

Every channel (web-app, Slack, Telegram) becomes one ChannelAdapter that
implements inbound + outbound/reply + notifications the same way. The only
per-channel variation is the declared ENTRYPOINT (webhook / api_key /
authenticated-session POST) and declared DELIVERY capabilities (reply mode
streaming|batched, optional max_message_chars, markdown, threads). Everything
between entrypoint and delivery is one abstract, channel-agnostic core:
idempotency -> bind(OwnedThread|ExternalRef) -> submit_turn -> durable reply
events -> per-mode reply sink.

Removes the current smell (two post-ingress cores; the web-app special-cased on
both inbound and reply). Records the migration deltas, the trust/security
invariants, and what stays on ProductSurface (the web-app's rich non-messaging
client API). Authoritative target for future agents touching channel code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kill web-push routes

Strengthen the unified channel model per direction: the hard invariant is that
NOTHING in the codebase is specific to a given channel for inbound, outbound, or
notifications. Every route is generic and extension_id-parameterized; channel
behavior lives only in the adapter (packages/*).

- Web-push enrollment becomes GENERIC channel notification setup: a channel
  declares notifications_require_setup; a generic status/enable/disable surface
  (by extension_id) dispatches to the adapter. VAPID/endpoints/subscription store
  move behind the web-app adapter.
- Delete /web-push/{subscribe,unsubscribe,status} and the web-app-specific
  message route; replace with generic session-inbound + notification-setup routes.
- Extend the specificity gate: zero channel names / channel-specific routes in
  generic crates. Rename web-push -> web-app (id/routes/constants) is in-scope.

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

Channels implement their notification logic in the adapter
(ChannelAdapter::deliver_notification); the DeliveryCoordinator is the generic,
any-caller facade that dispatches to it by extension_id. Routines are one caller
among several (the model's outbound_deliver already is another) — callers own
WHEN/WHAT, never HOW or which channel. Delivery is already adapter-based, so this
is exposing the facade + adapter method, not a rebuild. Setup stays a separate
generic surface (7b). Migration renumbered 8-11 accordingly.

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

The unified-channel-model inbound vocabulary (§12.1 of
docs/internal/design/2026-08-10-unified-channel-model.md):

- ChannelInboundSurfaceRequest carries a trust-class enum
  (VerifiedInbound { evidence } | SessionCaller { caller }) and a binding
  enum (ExternalRef | OwnedThread { thread_id }) instead of bare webhook
  evidence, plus the session transports' requested_model hint.
- ProductInboundEnvelope carries the same pair (ProductInboundTrust /
  ProductInboundBindingDirective); auth_claim() is now Option, with
  require_verified_auth_claim() failing closed for session envelopes on
  every external-ref path (binding requests, command context, projection
  subjects).
- TrustedInboundContext::from_session_caller mints the session-arm context;
  the webhook constructors are unchanged in behavior.
- ProductInboundAck::Accepted gains optional submit-time metadata
  (AcceptedTurnSubmission) and the busy variants gain an optional
  BusyRunSnapshot, both serde-defaulted so ledger rows settled before this
  change still deserialize (pinned by ack_rows_without_submit_metadata_
  still_deserialize).
- ChannelInboundProductSurface gains a default-fail-closed inline-attachment
  admission door for session transports.
- ProductSurfaceRejectionKind gains DuplicateAction and ReplayUnavailable
  for the session-lane replay taxonomy; every exhaustive matcher classifies
  them explicitly.

Mechanical fallout: constructors updated across extension_host, openai_compat,
composition and the integration/parity harnesses; no behavior change on the
webhook lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
The webhook core's InboundTurnService gains the session lane (§12.2): the
envelope's binding directive selects the arm, and everything below
TurnCoordinator::submit_turn stays shared.

- OwnedThread prepare: the authenticated caller is the binding authority —
  ownership-probed through SessionThreadService (missing and foreign threads
  are indistinguishable, no existence oracle), never created implicitly, and
  the external binding resolver never runs.
- Session replay probes the exact persisted browser binding-id schemes
  (caller-scoped primary + thread-scoped legacy) so messages accepted by
  earlier builds replay instead of double-accepting; a client action id
  replayed against a different thread fails as ClientActionReplayMismatch.
- The submit tail is lane-parameterized: webui-src/webui-reply ref prefixes,
  the raw client action id as the coordinator idempotency key, and the WebUi
  product context are preserved byte-for-byte for session turns; webhook
  submissions are unchanged.
- Fresh submissions carry AcceptedTurnSubmission metadata; busy outcomes
  carry the blocking-run snapshot; session busy replays report no run
  metadata (the dedicated browser path's exact shape).
- Session skill-activation hooks record between acceptance and submission
  and clear on busy/error, matching the browser path's ordering.
- New session-lane failures (OwnedThreadUnavailable 404,
  ClientActionReplayMismatch 409/duplicate, ReplayUnavailable 409,
  SkillActivationFailed internal, AttachmentLanderUnavailable 503) never
  settle the idempotency ledger.
- submit_inbound_inner admits only user-message payloads from session
  callers, and build_channel_envelope rejects mixed trust/binding arms fail
  closed: webhook trust/pairing machinery can never run for a browser
  message and vice versa.
- CapacityExceeded submissions now surface non-retryable, matching the
  workflow's own settle decision (turn_error_is_retryable).

Covered by the new session_lane suite in inbound_turn_contract (ownership
probe and cross-thread guards sabotage-verified) plus the serde-compat pins
from the previous commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
…the one core

RebornServices::submit_turn (the SUBMIT_TURN_COMMAND implementation both the
browser route and the OpenAI-compatible transport invoke) now builds the
neutral session inbound request and admits it through the same
DefaultProductSurface core webhook channels ride — durable idempotency
ledger → owned-thread binding → TurnCoordinator::submit_turn (§12.2–3) —
then renders the acks back into the unchanged RebornSubmitTurnResponse wire
shape (fresh Submitted from submit-time metadata; replays as
AlreadySubmitted with the run's current state; busy shapes with their
decision-time snapshots; ledger-replayed busy without run metadata).

The duplicate browser tail is deleted: replay_webui_send_message,
replay_accepted_message, AcceptedWebUiMessage, mark_message_submitted_or_
replay, reconcile_terminal_duplicate, resolve_webui_thread_metadata,
parse_replay_run_id, and the webui binding-id scheme fns now live only as
the session lane of the shared core (the schemes byte-identical, with
legacy replay fallback). The reborn_services module-charter map is updated
in the same change.

Composition wires the durable session ledger
(build_session_inbound_ledger over the extension filesystem, mirroring the
per-extension channel ledgers' mount/bounds/CAS discipline) into every
product-surface instance; standalone/test builds keep the in-memory
default. SessionLaneRejectingBindingResolver guards the session core's
external-ref door fail closed.

The full reborn_services_contract suite (278 tests) passes unchanged
through the re-plumbed path — caller-owns-thread, no implicit thread
creation, client_action_id replay (including legacy binding-id rows),
cross-thread reuse rejection, busy/deferred/steering shapes, attachment
landing, and skill-activation ordering all preserved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
The web-app-specific browser message route is deleted and replaced by the
generic session-inbound door (§12.4, §8):

- POST /api/webchat/v2/channels/{extension_id}/messages replaces
  POST /api/webchat/v2/threads/{thread_id}/messages. No route names a
  channel; the path extension_id overrides the body and the thread rides the
  body (the caller owns it). Route descriptor policy is unchanged
  (14 MiB body, 60/60s per-caller, TurnCoordinator effect path).
- ProductSubmitTurnRequest/SendMessage carry the optional extension_id; the
  product surface validates it against the new
  SessionChannelDirectory port (declared in
  ironclaw_product_contracts::session_ingress, implemented by the extension
  host over the deployment channel registry — manifest-derived, install-state
  free). Unknown or non-session extensions are 404, indistinguishable from an
  absent route; a missing directory fails closed as 503. Transports that
  predate the parameter (OpenAI-compat) submit under the legacy session
  surface identity, unchanged.
- The web-app manifest declares its entrypoint: inbound = true with the
  authenticated_session verification recipe, no route_suffix (a browser
  request can never reach the webhook mount), conversation_model isolated.
  The manifest-lockstep pin now asserts exactly that.
- The deployment's session channel is advertised to the SPA on
  GET /session (session_channel_extension_id, derived from the registry —
  exactly-one resolves, otherwise none and sends fail closed client-side);
  the frontend plugs it into the generic route and carries no channel name.
- e2e harness + raw-route scenarios read the session channel from
  GET /session; Playwright mocks match the generic pattern.

Caller-level coverage: directory-missing 503 / unknown-extension 404 /
declared-channel admit in reborn_services_contract; the session-channel
directory contract in extension_host; route-table, handler, and charter
gates updated in the same change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
The reply model's declaration half (§12.5–7): every channel declares how its
reply sink consumes the durable reply-event stream.

- ChannelDescriptor gains reply_mode = streaming | batched (default batched;
  validation pairs streaming with the authenticated-session entrypoint —
  a webhook vendor has no projection stream to consume).
- The web-app manifest declares streaming: the existing SSE/WebSocket
  projection forward IS this channel's reply sink, exactly as it runs today —
  a consumer of durable reply events, never a replacement (the gateway-events
  layering rule). Its max_message_chars is now undeclared: a streaming sink
  never batches or splits, so the channel is unlimited (§6). Slack and
  Telegram declare batched explicitly; their declared bounds are unchanged.
- ResolvedChannelDelivery carries the declared mode from the same
  generation-pinned snapshot read, and the DeliveryCoordinator gates both
  delivery doors: conversation-reply intents for a streaming channel return
  NoDelivery before any attempt is persisted (the projection stream is the
  delivery), while notification-class sends (BackgroundRunNotice,
  ModelDelivery) flow regardless of mode so the notifications capability
  keeps working. Pinned by
  streaming_channel_conversation_reply_skips_batched_delivery and
  streaming_channel_still_receives_notification_class_deliveries.
- max_message_chars stays adapter-enforced at render time (channel-specific
  splitting is adapter behavior by charter); the declaration remains the
  model-facing hint. The batched sink itself never splits for a streaming
  channel by construction.

No behavior change for any existing delivery: no streaming channel receives
conversation-reply deliveries today, so the gate is the fail-closed
materialization of the current structure.

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

The §7a send half of notification generalization (§12.8):

- ChannelAdapter gains deliver_notification(envelope, egress) — the
  channel-specific notification send, defaulting to the channel's ordinary
  delivery (a conversational channel's notification is a message; a
  notification-only channel's whole delivery IS this send). Only the generic
  DeliveryCoordinator calls it, never feature code.
- The coordinator classifies each policy-lane delivery before the request is
  consumed: a run-notification that is not source-routed (it targets a
  notification channel, not the originating conversation) and is not an
  explicitly routed final answer rides the adapter's notification send;
  everything else rides ordinary delivery. Pinned by
  notification_class_delivery_rides_the_adapters_notification_send /
  conversation_reply_rides_the_adapters_ordinary_delivery. Zero behavior
  change for shipped adapters — all three inherit the delegating default.
- run_delivery::notifications is the named any-caller facade over the
  coordinator: notify(target, content) for one explicit catalog-resolved
  channel target, notify_user(user, content) fanning out over
  resolve_user_notification_targets (the picker set). The routine driver's
  own notification internals now delegate to it — one send path, with the
  routine lane as one caller among any number. Callers own WHEN/WHAT, never
  HOW, and never name a channel.
- The coordinator's streaming-reply gate now reads the new lightweight
  ChannelDeliveryResolver::channel_reply_mode lookup instead of performing a
  second full resolution, preserving the single generation-pinned
  resolve_channel_delivery read the OUT contract pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017DectwBVo9eDqV5dhRDGkb
…ter (§7b)

Replace the bespoke /web-push/{status,subscriptions,subscriptions/remove}
routes with one generic per-channel surface keyed by extension_id:
GET/POST /api/webchat/v2/channels/{extension_id}/notifications{,/enable,/disable}.

- contracts: web_push descriptor module deleted; notification_setup module
  (status view + enable/disable command descriptors) and the
  RebornNotificationSetup* wire family replace the RebornWebPush* DTOs;
  body extension_id serde-defaulted (route path is canonical).
- assistant: reborn_services/web_push.rs deleted; notification_setup.rs adds
  ChannelNotificationSetupService + fail-closed Unsupported default +
  AdapterChannelNotificationSetupService dispatching to the channel adapter
  via ChannelDeliveryResolver (unknown extension -> 404, no-setup channel ->
  enabled:true + mutation 400, payload/detail byte bounds enforced).
- delivery coordinator: the streaming-reply gate now keys on the ROUTE, not
  the intent — a notification-routed send (RunNotification + non-live-source
  origin) flows to a streaming channel even with a conversation-shaped
  intent; pinned at the contract tier and by the blocked-fire push journey.
- web-push package: adapter implements the three setup operations over the
  slot runtime (scope byte-identical to the retired product service; detail
  carries vapid_public_key/subscription_count/subscriptions with
  endpoint_digest correlation).
- composition: wires AdapterChannelNotificationSetupService over the channel
  delivery resolver; WebPushComposition handle family deleted (the slot
  install inside assemble_web_push is now the single consumer).
- webui: route descriptors/router/handlers swapped to the generic surface;
  CONTRACT.md route table + outbound charter row updated.
- frontend: api.ts gains getNotificationSetupStatus/enable/disable keyed by
  extensionId; web-push.ts -> device-push.ts and useWebPushDevice ->
  useDevicePush re-read the channel-opaque detail; the notification panel's
  device row is matched by the GET /session-advertised session channel id —
  no channel name remains in the frontend; webPush.* i18n keys renamed
  devicePush.* across all 11 locales.
- tests: 5 new setup-dispatch contract tests + streaming-notification
  regression pin; product-api round-trip and delivery journey rewritten onto
  the generic surface; frontend suites updated (1241 pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…g (§12.11, §13)

Product identity rename: extension id / channel name / catalog target id are
now 'web-app'; package dir crates/extensions/packages/web-app (crate
ironclaw_web_app_extension); domain crate crates/domains/ironclaw_web_app;
WEB_PUSH_* constants -> WEB_APP_*, WebPush* types -> WebApp*. PROPOSAL §5
tree updated (check-target-tree: 66/66 OK). Space-separated 'Web Push'
protocol prose stays — the protocol keeps its RFC name; the CHANNEL does not.

Persisted coordinates deliberately keep pre-rename bytes, each commented in
place and pinned by the new gate's allowlist:
- secret-store credential handle value 'web_push_vapid' (renaming = VAPID
  rotation = every existing browser subscription breaks cryptographically);
- enrollment document path /web-push/subscriptions.json plus composition's
  /web-push per-user mount alias (the alias resolves to a physical subpath;
  renaming would orphan enrollments);
- binding-ref grammar mints web-app/v1/ and decodes legacy web-push/v1/
  forever (regression test added).
Documented residue, no migration: stored notification-channel selections
carrying the old 'web-push' target id render Unavailable until re-selected
(population ~QA-only; the channel shipped 2026-08-09).

The install-catalog hide for the host's own surface is no longer an id
match: is_builtin_host_surface consults the SessionChannelDirectory (the
manifest-derived authenticated_session fact), failing OPEN on an absent
directory; the production round-trip test covers the hidden-listing behavior
end-to-end.

Enforcement (§13): new architecture gate
reborn_web_push_vocabulary_retired.rs pins web-push/web_push/WebPush/
webPush/WEB_PUSH at zero occurrences across crates/ (frontend sources
included), tests/integration/, and skills/, with an exact-term shrink-only
allowlist over the five persisted-compat files, a stale-sanction check, and
an assertion that the session + notification-setup routes stay
{extension_id}-parameterized. The specificity gate's web-app carve-out doc
records the rename.

E2E journey vocabulary renamed on both the Rust and Python sides
(case ids, test names, delivery-target enum member).

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

railway-app Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

🚅 Deployed to the ironclaw-pr-7477 environment in ironclaw-ci-preview

Service Status Web Updated (UTC)
ironclaw ✅ Success (View Logs) Web Aug 12, 2026 at 4:30 pm

@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7477 August 11, 2026 01:41 Destroyed
@github-actions github-actions Bot added scope: docs Documentation scope: dependencies Dependency updates size: XL 500+ changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Aug 11, 2026
@ironloopai

ironloopai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

🧭 IronLoop Run · Review

This comment updates in place as the Run moves through its stages.

🟩 Final result · Completed

🟨 Queued → 🟦 Working → 🟦 Posting results → 🟩 Completed

Automatic trigger · attempt 1 of 3 · completed in 24m 54s

IronLoop completed the review and posted it to GitHub.

🔗 Result

Open submitted review →

Run details

Run: eb251cc6-326c-42b5-9e1b-907337465a68
Base: main at ad7ecd1
Head: webapp-inbound-channel at 5e3a8c2
Created: 2026-08-11 01:42 UTC
Updated: 2026-08-11 02:06 UTC

@BenKurrek
BenKurrek marked this pull request as draft August 11, 2026 01:44
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 26628cfb-47ce-435b-a2aa-da2984547c1d

📥 Commits

Reviewing files that changed from the base of the PR and between ab6b2f4 and da75a43.

📒 Files selected for processing (2)
  • .github/workflows/reborn-tests.yml
  • crates/product/ironclaw_webui/src/webui_v2/static_assets/assets.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added the Web App channel for browser notifications, including automatic credential setup and delivery registration management.
    • Added channel-specific notification status, enable, and disable controls.
    • Added authenticated session-channel messaging with channel-scoped routes and durable session handling.
    • Added streaming replies, notification delivery tracking, replay protection, and attachment validation.
  • Improvements

    • Unified inbound messages, replies, and notifications across supported channels.
    • Improved delivery failure handling, registration pruning, security validation, and finalized response rendering.
    • Preserved compatibility with existing browser notification data and links.
    • Updated notification terminology and controls throughout the interface.

Walkthrough

Migrates channel handling to independent ChannelIngress/ChannelReply/ChannelDelivery capability traits, retires the fused ChannelAdapter, and replaces the Web Push crate/routes with a generic web-app channel plus authenticated-session ingress. Adds host-owned delivery-registration storage, notification-setup services, session-scoped inbound admission, and channel-scoped WebUI routes, secured by extension architecture and vocabulary-retirement gates.

Changes

Unified Channel Model Migration

Layer / File(s) Summary
Channel contracts and trust model
crates/contracts/ironclaw_extension_contracts/src/{channel.rs,channel_adapter.rs,channel_identity.rs,external.rs,recipe.rs,extension.rs,verified_inbound.rs,test_support*}, crates/contracts/ironclaw_product_contracts/src/{inbound.rs,inbound/tests.rs,delivery.rs,surface.rs,session_ingress.rs,notification_setup.rs,product_wire.rs,binding.rs,command.rs,inbound_requests.rs,projection.rs,outbound.rs,package_lifecycle.rs,lib.rs}, crates/contracts/ironclaw_host_api/src/{http.rs,product_adapter_error.rs,attachment.rs}, crates/contracts/ironclaw_loop_contracts/src/runtime_context*
Splits ChannelDescriptor capabilities into independent reply/delivery sections. Replaces ChannelAdapter with ChannelIngress, ChannelReply, ChannelDelivery, and ChannelSurfaces. Adds the AuthenticatedSession ingress recipe, inbound trust and binding-directive variants, a session-ingress directory trait, notification-setup DTOs, and duplicate/replay-unavailable rejection kinds.
Web-app domain and host runtime
crates/domains/ironclaw_web_app/*, crates/domains/ironclaw_auth/src/{delivery_registrations.rs,product_auth/api/auth.rs,lib.rs}, crates/app/ironclaw_cli/*, crates/app/ironclaw_composition/*, crates/extensions/packages/web-app/*
Renames the Web Push domain and extension to Web App. Keeps legacy web-push/v1/ target decoding for compatibility. Adds filesystem-backed delivery-registration storage with HTTPS allowlisting, first-party channel initialization for VAPID credentials, and transactional OAuth identity-binding commit/rollback. Updates CLI and composition code to install the web-app runtime instead of the retired WebPushRuntimeSlot.
Channel host wiring and adapter surfaces
crates/extensions/ironclaw_extension_host/src/*, crates/extensions/ironclaw_extension_registry/*, crates/extensions/packages/{slack,telegram}/*, crates/kernel/ironclaw_host_runtime/*, crates/substrates/ironclaw_secrets/*, crates/app/ironclaw_architecture_tests/tests/*, docs/internal/design/*, docs/reborn/extension-runtime/*, integration test harness and support files
Binds ChannelSurfaces per axis with axis-specific mismatch errors. Adds declarative vendor-call recipes for host-executed registration and deregistration. Converts Slack and Telegram to async receive, send_reply, and deliver. Adds put_if_absent to SecretStorePort. Updates architecture tests to enforce the ChannelIngress/Reply/Delivery contract split.
Session admission and delivery coordination
crates/product/ironclaw_assistant/src/*, crates/product/ironclaw_openai_compat/*, crates/domains/ironclaw_outbound/*, related test files
Adds authenticated-session and owned-thread trust for inbound turns. Adds a durable session-inbound idempotency ledger, a host-owned ChannelNotificationSetupService, and DeliveryClientBootstrap. Adds projection-verified stream replies, a canonical OutboundPushKind with compatibility aliases, and idempotent delivery-attempt replay.
WebUI, frontend, and migration coverage
crates/product/ironclaw_webui/*, e2e Python scenarios, crates/app/ironclaw_architecture_tests/tests/reborn_web_push_vocabulary_retired.rs, scripts/ci/composition-budget.toml, misc docs
Replaces thread-scoped send_message with {extension_id}-parameterized session-channel messaging. Replaces Web Push routes with generic notification-setup routes. Renames frontend webPush state to devicePush. Adds a vocabulary-retirement architecture gate that scans for remaining web-push terminology.

Estimated code review effort: 5 (Critical) | ~180 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant WebUiV2Handlers
  participant RebornServices
  participant SessionChannelDirectory
  participant InboundTurnService
  participant DeliveryCoordinator
  participant ChannelDelivery

  Browser->>WebUiV2Handlers: POST /channels/{extension_id}/messages
  WebUiV2Handlers->>RebornServices: submit_turn(extension_id, thread_id)
  RebornServices->>SessionChannelDirectory: is_session_channel(extension_id)
  SessionChannelDirectory-->>RebornServices: true or false
  alt undeclared channel
    RebornServices-->>WebUiV2Handlers: NotFound
  else declared session channel
    RebornServices->>InboundTurnService: submit_or_replay(trusted context)
    InboundTurnService->>InboundTurnService: validate caller-owned thread and idempotency ledger
    InboundTurnService-->>RebornServices: accepted or busy outcome
    RebornServices-->>WebUiV2Handlers: submission response
  end
  DeliveryCoordinator->>ChannelDelivery: deliver(envelope, registrations)
  ChannelDelivery-->>DeliveryCoordinator: DeliveryReport
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives substantial technical detail but omits most required template sections, including linked issue, security, database, blast radius, and review track. Rewrite the description using the repository template and complete every required section, including explicit validation, security, database, rollback, and review-track details.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title uses Conventional Commits format and clearly describes the channel-contract refactor and split reply/delivery model.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 IronLoop review

Found three medium-severity defects.

Findings: 🟠 Medium 3

🟠 Medium · Migrate persisted web-push target IDs

Inline on crates/domains/ironclaw_web_app/src/grammar.rs:26. See the inline comment for details.

🟠 Medium · Mount the retained subscription path in store tests

Inline on crates/domains/ironclaw_web_app/src/store.rs:279. See the inline comment for details.

🟠 Medium · Re-capture contract-size ceilings

Inline on crates/app/ironclaw_architecture_tests/tests/reborn_dependency_boundaries.rs:638. See the inline comment for details.

Validation

  • ❌ Web-app subscription-store round trip — Failed: the targeted enrollment/refresh/list/remove test returns WebAppError::Store on its first enrollment.
  • ❌ Architecture boundary suite — Failed: the contract-size ratchet reports extension-contracts, host-api, and product-contracts above their ceilings.
Review details
  • Run: eb251cc6-326c-42b5-9e1b-907337465a68
  • Workflow: Review
  • Attempts: 1

/// The constant, owner-scoped catalog target id. Target resolution is always
/// scoped to the requesting owner, so one stable id per user is unambiguous
/// ("this user's enrolled browsers").
pub const WEB_APP_TARGET_ID: &str = "web-app";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 IronLoop review · Inline finding

🟠 Medium · Migrate persisted web-push target IDs

Existing preference records store notification target IDs as `web-push`, but the new provider exposes only `web-app`. Non-empty stored IDs are resolved verbatim; the old value becomes Missing and notification fan-out skips it. Legacy binding-ref decoding does not translate this field, so existing users stop receiving browser notifications until they manually reselect the channel. Add a persisted-ID migration or compatibility mapping with upgrade coverage.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 667b3f1 — and you were right that this was inconsistent rather than acceptable residue. The PR already kept pre-rename bytes for the VAPID handle, the /web-push mount alias, and the web-push/v1/ ref prefix on exactly this reasoning; the catalog target id is equally a persisted per-user preference identity, so WEB_APP_TARGET_ID now keeps the value web-push (the user-visible channel name stays web-app). No migration needed and no stored selection is orphaned. The production round-trip test now pins the split so the two identities can't be conflated again.

use ironclaw_host_api::path::{MountAlias, VirtualPath};
MountView::new(vec![MountGrant::new(
MountAlias::new("/web-push")?,
MountAlias::new("/web-app")?,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 IronLoop review · Inline finding

🟠 Medium · Mount the retained subscription path in store tests

The store intentionally retains `/web-push/subscriptions.json`, but this fixture grants only `/web-app`. The first upsert therefore resolves an ungranted path and returns `WebAppError::Store`, so the new round-trip test fails before enrollment. Mount `/web-push` in the fixture, or change the persisted path and production compatibility mount together.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed before this review landed (commit 96be7fe) — the store-test fixture mounts /web-push again, matching the retained document path. Caught by the local suite: the rename sweep had also renamed the RFC 8291 WebPush: info key-derivation string in crypto.rs, which the Appendix A vector test flagged; that literal is protocol-fixed and is now restored plus allowlisted in the vocabulary gate.

// (channel.rs). Ingress-trust vocabulary + validation on the descriptor's
// own shape; the shared inbound pipeline that consumes it lives in
// ironclaw_extension_host / product, not here.
("ironclaw_extension_contracts", 7_872),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 IronLoop review · Inline finding

🟠 Medium · Re-capture contract-size ceilings

This update raises only the extension-contracts ceiling to 7,872, but its current count is 8,023. The change also grows host-api to 18,931 against 18,922 and product-contracts to 16,075 against 15,885. The architecture suite therefore fails its contract-size ratchet. Re-capture each affected ceiling with its required rationale, or move the additional logic out of the contracts tier.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 9c6d618. All three ceilings re-captured from the merged tree with per-crate rationale: extension_contracts 8_157, product_contracts 16_119, host_api 19_003. (The numbers moved again after merging main twice, which is why they don't match the ones quoted here.)

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Caution

CodeRabbit couldn't update its existing comment. The review summary may be out of date.

Error details
Validation Failed: {"resource":"IssueComment","code":"custom","field":"body","message":"body is too long (maximum is 65536 characters)"} - https://docs.github.com/rest/issues/comments#update-an-issue-comment

…ion/reaction trains

Semantic reconciliations on top of the textual union:
- inbound_turn: the ref construction is lane-split — the webhook lane adopts
  main's canonical per-event refs (#7397: stored source/reply binding ids ARE
  the minted refs, rebuilt verbatim, never re-wrapped), while the session
  lane keeps the byte-compatible browser scheme (webui-src/webui-reply
  prefixes over one caller-scoped id, raw client action id as idempotency
  key). The session lane's synthetic ResolvedBinding now carries the typed
  ref pair main added, filled with those same wrapped values.
- workflow: main's shared-channel ReplyToBot NoOp arm lands ahead of the
  UserMessage arm, which now chains BOTH with_requested_model (ours) and
  with_channel_context (main's). Session submissions are DirectChat, so the
  NoOp arm cannot affect them.
- surface/ingress: ChannelInboundSurfaceRequest carries requested_model
  (ours) and channel_context (main's); sessions pass channel_context: None.
- product_surface_contract/group_multiuser: main's new membership tests
  updated to the Result-returning from_envelope signature.
- extension_contracts size ceiling: 7_885 (main) + 114 (our
  AuthenticatedSession trust class) = 7_999.
- thread_scope_from_binding now imported from run_delivery (main moved it);
  the dead pre-merge product_source_binding_id copy is dropped.

Also repairs two rename-sweep defects the pre-merge battery caught:
- crypto.rs: the RFC 8291 key-derivation info string is the protocol-fixed
  literal 'WebPush: info' — restored (the sweep had renamed it, breaking
  encryption; the Appendix A test-vector test caught it) and allowlisted in
  the vocabulary-retirement gate.
- store.rs tests: the fixture mount alias restored to the persisted
  /web-push spelling to match the document path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@railway-app
railway-app Bot temporarily deployed to ironclaw-ci-preview / ironclaw-pr-7477 August 11, 2026 11:49 Destroyed
…rains

Two small unions: the extension_contracts size ceiling restacks our +114
AuthenticatedSession delta on main's 7_892 (-> 8_006), and reborn_services
carries both main's caller_extension_auth seam (#7247) and this train's
session-lane imports.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@BenKurrek
BenKurrek added this pull request to the merge queue Aug 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
@BenKurrek
BenKurrek added this pull request to the merge queue Aug 12, 2026
@BenKurrek BenKurrek mentioned this pull request Aug 12, 2026
7 of 20 tasks
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
@BenKurrek
BenKurrek added this pull request to the merge queue Aug 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
@BenKurrek
BenKurrek added this pull request to the merge queue Aug 12, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 12, 2026
@BenKurrek
BenKurrek added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 88fe2d0 Aug 12, 2026
96 of 98 checks passed
@BenKurrek
BenKurrek deleted the webapp-inbound-channel branch August 12, 2026 19:06
BenKurrek added a commit that referenced this pull request Aug 12, 2026
Three sites neither branch could have seen: our device-link driver tests
and integration harness profile still built the pre-#7477
channel-adapter shape (now ChannelSurfaces), and #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>
BenKurrek added a commit that referenced this pull request Aug 12, 2026
…ests

- 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 #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>
personal-upstream-sync Bot pushed a commit to theredspoon/ironclaw that referenced this pull request Aug 13, 2026
…sion channel message route (nearai#7574)

Two independent regressions have kept every scheduled Live Canary red:

1. Since nearai#7171 moved skill mounts onto one backend-generic tree, the case
   homes exported into artifacts carry no .ironclaw-reborn-bundled.json
   runtime marker (verified: zero markers across all 31 materialized skills
   in the QA-10 artifact of run 31641918366), so the marker-keyed
   bundled-skill pruning from nearai#6453 never engages and the long-committed
   placeholder text in skills/local-test/SKILL.md (docker examples with
   NEARAI_API_KEY=<your-key>) fails the strict scrub in all 12 shards —
   deterministically since the first scheduled run after nearai#7171 (Aug 9,
   21:11 UTC). The scrubber now also prunes a marker-less skill snapshot
   whose file set and bytes are identical to the source-controlled bundle;
   divergent or operator-authored content stays in scanning scope.

2. Since nearai#7477 the WebChat composer posts messages on the session channel
   ingress route (/api/webchat/v2/channels/<extension>/messages), while the
   live-QA submission-identity capture waited on the retired thread-scoped
   route — so QA 10 (the shard whose cases capture submission identity)
   went 9/10 to 0/10 at the first post-nearai#7477 scheduled run (Aug 12,
   21:18 UTC) with every case timing out at expect_response on a healthy,
   streaming turn (the failure screenshots show the correct answers
   mid-stream). The predicate now accepts both routes — the same migration
   the stress client made in nearai#7568 — so one harness spans binaries on
   either side of the split.

Scrub self-tests: 21 pass including two new cases (marker-less identical
snapshot pruned; marker-less divergent snapshot still fails strict), and
the identical-snapshot test fails against the unfixed script. Live-QA
runner unit tests: 218 pass including the new route-pattern regression
test.

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

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

Docs only — no production code, no behavior change.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Frontend suite green: 143 files, 1264 tests.

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

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

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

The honest shape is a mix:

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

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

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

Frontend suite green: 143 files, 1264 tests.

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

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

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

Audit findings, fixed rather than worked around:

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

The implementation chain:

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

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

Two failures were real behavior, not test drift:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(telegram): design automatic linked channel identity

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

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

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

* chore(telegram): document fixture panic invariants

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

* fix telegram device-link setup CI

* fix standard messaging schema parity assertion

* fix telegram model search projection test

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
…earai#7477)

* fix(webui): only offer the web-app notification channel when a browser is enrolled

The "Web app" row in the notification-channels picker rendered READY with a
selectable checkbox even with zero enrolled browsers (no push subscription),
so a user could pick a channel that has nowhere to deliver. Selectability and
the pill now follow the account's enrollment count: with no enrolled browser
the web-app checkbox cannot be SELECTED and its pill drops from Ready to
Unavailable, while the nested "Enable notifications in this browser" affordance
shows how to fix it. An already-stored selection stays deselectable (disabled
only when unchecked), so a browser that unsubscribes never leaves a locked-on
checkbox.

The web-push row now owns its device hook (WebPushChannelRow) so the account
status query still mounts only when the row is present; the shared row label
was extracted (renderChannelRowLabel) so every other channel renders its
checkbox inline, unchanged.

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

* wip(ingress): declare authenticated-session ingress recipe

Add IngressVerificationRecipe::AuthenticatedSession — the trust class for a
channel whose caller the host's authenticated transport (T1) already verified,
so it needs no webhook signature. Handle it fail-closed in the two webhook host
sites: no evidence mint in channel_host, and a NotWebhookVerifiable rejection in
the ingress verifier (a session channel mounts no webhook route and must never
be attested verified-inbound through the webhook path).

Incremental checkpoint toward the generic-inbound pipeline (PR2).

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

* feat(ingress): make channel route_suffix optional, paired with trust class

A channel's ingress mount now depends on its trust class. Webhook recipes (T2:
hmac/shared-secret/none) mount /webhooks/extensions/{id}/{suffix} and MUST
declare a route_suffix; an authenticated_session recipe (T1) is verified upstream
by the host transport, mounts no webhook route, and MUST NOT declare one.

- route_suffix becomes Option<RouteSuffix> (serde default+skip; existing webhook
  manifests parse unchanged into Some).
- ChannelDescriptor::validate pairs the recipe kind with route_suffix presence,
  fail-closed both ways (SessionIngressWithRouteSuffix / WebhookIngressWithoutRouteSuffix).
- Every mount/route-table consumer (active snapshot build + resolve + conflict,
  deployment channels resolve, lifecycle reserved-route check) fails closed when
  a session channel carries no route_suffix — it can never match a webhook route.

Groundwork for routing the web app's authenticated session through the one
generic inbound pipeline (PR2).

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

* docs(design): materialize the unified channel model (target architecture)

Every channel (web-app, Slack, Telegram) becomes one ChannelAdapter that
implements inbound + outbound/reply + notifications the same way. The only
per-channel variation is the declared ENTRYPOINT (webhook / api_key /
authenticated-session POST) and declared DELIVERY capabilities (reply mode
streaming|batched, optional max_message_chars, markdown, threads). Everything
between entrypoint and delivery is one abstract, channel-agnostic core:
idempotency -> bind(OwnedThread|ExternalRef) -> submit_turn -> durable reply
events -> per-mode reply sink.

Removes the current smell (two post-ingress cores; the web-app special-cased on
both inbound and reply). Records the migration deltas, the trust/security
invariants, and what stays on ProductSurface (the web-app's rich non-messaging
client API). Authoritative target for future agents touching channel code.

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

* docs(design): no channel-specific code — generic notification setup, kill web-push routes

Strengthen the unified channel model per direction: the hard invariant is that
NOTHING in the codebase is specific to a given channel for inbound, outbound, or
notifications. Every route is generic and extension_id-parameterized; channel
behavior lives only in the adapter (packages/*).

- Web-push enrollment becomes GENERIC channel notification setup: a channel
  declares notifications_require_setup; a generic status/enable/disable surface
  (by extension_id) dispatches to the adapter. VAPID/endpoints/subscription store
  move behind the web-app adapter.
- Delete /web-push/{subscribe,unsubscribe,status} and the web-app-specific
  message route; replace with generic session-inbound + notification-setup routes.
- Extend the specificity gate: zero channel names / channel-specific routes in
  generic crates. Rename web-push -> web-app (id/routes/constants) is in-scope.

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

* docs(design): notification send is a generic facade over ChannelAdapter::deliver_notification

Channels implement their notification logic in the adapter
(ChannelAdapter::deliver_notification); the DeliveryCoordinator is the generic,
any-caller facade that dispatches to it by extension_id. Routines are one caller
among several (the model's outbound_deliver already is another) — callers own
WHEN/WHAT, never HOW or which channel. Delivery is already adapter-based, so this
is exposing the facade + adapter method, not a rebuild. Setup stays a separate
generic surface (7b). Migration renumbered 8-11 accordingly.

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

* feat(inbound): trust-class + binding enums on the channel inbound contract

The unified-channel-model inbound vocabulary (§12.1 of
docs/internal/design/2026-08-10-unified-channel-model.md):

- ChannelInboundSurfaceRequest carries a trust-class enum
  (VerifiedInbound { evidence } | SessionCaller { caller }) and a binding
  enum (ExternalRef | OwnedThread { thread_id }) instead of bare webhook
  evidence, plus the session transports' requested_model hint.
- ProductInboundEnvelope carries the same pair (ProductInboundTrust /
  ProductInboundBindingDirective); auth_claim() is now Option, with
  require_verified_auth_claim() failing closed for session envelopes on
  every external-ref path (binding requests, command context, projection
  subjects).
- TrustedInboundContext::from_session_caller mints the session-arm context;
  the webhook constructors are unchanged in behavior.
- ProductInboundAck::Accepted gains optional submit-time metadata
  (AcceptedTurnSubmission) and the busy variants gain an optional
  BusyRunSnapshot, both serde-defaulted so ledger rows settled before this
  change still deserialize (pinned by ack_rows_without_submit_metadata_
  still_deserialize).
- ChannelInboundProductSurface gains a default-fail-closed inline-attachment
  admission door for session transports.
- ProductSurfaceRejectionKind gains DuplicateAction and ReplayUnavailable
  for the session-lane replay taxonomy; every exhaustive matcher classifies
  them explicitly.

Mechanical fallout: constructors updated across extension_host, openai_compat,
composition and the integration/parity harnesses; no behavior change on the
webhook lane.

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

* feat(inbound): owned-thread session lane inside the one inbound core

The webhook core's InboundTurnService gains the session lane (§12.2): the
envelope's binding directive selects the arm, and everything below
TurnCoordinator::submit_turn stays shared.

- OwnedThread prepare: the authenticated caller is the binding authority —
  ownership-probed through SessionThreadService (missing and foreign threads
  are indistinguishable, no existence oracle), never created implicitly, and
  the external binding resolver never runs.
- Session replay probes the exact persisted browser binding-id schemes
  (caller-scoped primary + thread-scoped legacy) so messages accepted by
  earlier builds replay instead of double-accepting; a client action id
  replayed against a different thread fails as ClientActionReplayMismatch.
- The submit tail is lane-parameterized: webui-src/webui-reply ref prefixes,
  the raw client action id as the coordinator idempotency key, and the WebUi
  product context are preserved byte-for-byte for session turns; webhook
  submissions are unchanged.
- Fresh submissions carry AcceptedTurnSubmission metadata; busy outcomes
  carry the blocking-run snapshot; session busy replays report no run
  metadata (the dedicated browser path's exact shape).
- Session skill-activation hooks record between acceptance and submission
  and clear on busy/error, matching the browser path's ordering.
- New session-lane failures (OwnedThreadUnavailable 404,
  ClientActionReplayMismatch 409/duplicate, ReplayUnavailable 409,
  SkillActivationFailed internal, AttachmentLanderUnavailable 503) never
  settle the idempotency ledger.
- submit_inbound_inner admits only user-message payloads from session
  callers, and build_channel_envelope rejects mixed trust/binding arms fail
  closed: webhook trust/pairing machinery can never run for a browser
  message and vice versa.
- CapacityExceeded submissions now surface non-retryable, matching the
  workflow's own settle decision (turn_error_is_retryable).

Covered by the new session_lane suite in inbound_turn_contract (ownership
probe and cross-thread guards sabotage-verified) plus the serde-compat pins
from the previous commit.

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

* refactor(inbound): route browser + OpenAI-compat submit_turn through the one core

RebornServices::submit_turn (the SUBMIT_TURN_COMMAND implementation both the
browser route and the OpenAI-compatible transport invoke) now builds the
neutral session inbound request and admits it through the same
DefaultProductSurface core webhook channels ride — durable idempotency
ledger → owned-thread binding → TurnCoordinator::submit_turn (§12.2–3) —
then renders the acks back into the unchanged RebornSubmitTurnResponse wire
shape (fresh Submitted from submit-time metadata; replays as
AlreadySubmitted with the run's current state; busy shapes with their
decision-time snapshots; ledger-replayed busy without run metadata).

The duplicate browser tail is deleted: replay_webui_send_message,
replay_accepted_message, AcceptedWebUiMessage, mark_message_submitted_or_
replay, reconcile_terminal_duplicate, resolve_webui_thread_metadata,
parse_replay_run_id, and the webui binding-id scheme fns now live only as
the session lane of the shared core (the schemes byte-identical, with
legacy replay fallback). The reborn_services module-charter map is updated
in the same change.

Composition wires the durable session ledger
(build_session_inbound_ledger over the extension filesystem, mirroring the
per-extension channel ledgers' mount/bounds/CAS discipline) into every
product-surface instance; standalone/test builds keep the in-memory
default. SessionLaneRejectingBindingResolver guards the session core's
external-ref door fail closed.

The full reborn_services_contract suite (278 tests) passes unchanged
through the re-plumbed path — caller-owns-thread, no implicit thread
creation, client_action_id replay (including legacy binding-id rows),
cross-thread reuse rejection, busy/deferred/steering shapes, attachment
landing, and skill-activation ordering all preserved.

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

* feat(ingress): generic session-inbound route keyed by extension_id

The web-app-specific browser message route is deleted and replaced by the
generic session-inbound door (§12.4, §8):

- POST /api/webchat/v2/channels/{extension_id}/messages replaces
  POST /api/webchat/v2/threads/{thread_id}/messages. No route names a
  channel; the path extension_id overrides the body and the thread rides the
  body (the caller owns it). Route descriptor policy is unchanged
  (14 MiB body, 60/60s per-caller, TurnCoordinator effect path).
- ProductSubmitTurnRequest/SendMessage carry the optional extension_id; the
  product surface validates it against the new
  SessionChannelDirectory port (declared in
  ironclaw_product_contracts::session_ingress, implemented by the extension
  host over the deployment channel registry — manifest-derived, install-state
  free). Unknown or non-session extensions are 404, indistinguishable from an
  absent route; a missing directory fails closed as 503. Transports that
  predate the parameter (OpenAI-compat) submit under the legacy session
  surface identity, unchanged.
- The web-app manifest declares its entrypoint: inbound = true with the
  authenticated_session verification recipe, no route_suffix (a browser
  request can never reach the webhook mount), conversation_model isolated.
  The manifest-lockstep pin now asserts exactly that.
- The deployment's session channel is advertised to the SPA on
  GET /session (session_channel_extension_id, derived from the registry —
  exactly-one resolves, otherwise none and sends fail closed client-side);
  the frontend plugs it into the generic route and carries no channel name.
- e2e harness + raw-route scenarios read the session channel from
  GET /session; Playwright mocks match the generic pattern.

Caller-level coverage: directory-missing 503 / unknown-extension 404 /
declared-channel admit in reborn_services_contract; the session-channel
directory contract in extension_host; route-table, handler, and charter
gates updated in the same change.

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

* feat(reply): channel-declared reply mode — streaming sinks never batch

The reply model's declaration half (§12.5–7): every channel declares how its
reply sink consumes the durable reply-event stream.

- ChannelDescriptor gains reply_mode = streaming | batched (default batched;
  validation pairs streaming with the authenticated-session entrypoint —
  a webhook vendor has no projection stream to consume).
- The web-app manifest declares streaming: the existing SSE/WebSocket
  projection forward IS this channel's reply sink, exactly as it runs today —
  a consumer of durable reply events, never a replacement (the gateway-events
  layering rule). Its max_message_chars is now undeclared: a streaming sink
  never batches or splits, so the channel is unlimited (§6). Slack and
  Telegram declare batched explicitly; their declared bounds are unchanged.
- ResolvedChannelDelivery carries the declared mode from the same
  generation-pinned snapshot read, and the DeliveryCoordinator gates both
  delivery doors: conversation-reply intents for a streaming channel return
  NoDelivery before any attempt is persisted (the projection stream is the
  delivery), while notification-class sends (BackgroundRunNotice,
  ModelDelivery) flow regardless of mode so the notifications capability
  keeps working. Pinned by
  streaming_channel_conversation_reply_skips_batched_delivery and
  streaming_channel_still_receives_notification_class_deliveries.
- max_message_chars stays adapter-enforced at render time (channel-specific
  splitting is adapter behavior by charter); the declaration remains the
  model-facing hint. The batched sink itself never splits for a streaming
  channel by construction.

No behavior change for any existing delivery: no streaming channel receives
conversation-reply deliveries today, so the gate is the fail-closed
materialization of the current structure.

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

* feat(notify): ChannelAdapter::deliver_notification + the generic notify facade

The §7a send half of notification generalization (§12.8):

- ChannelAdapter gains deliver_notification(envelope, egress) — the
  channel-specific notification send, defaulting to the channel's ordinary
  delivery (a conversational channel's notification is a message; a
  notification-only channel's whole delivery IS this send). Only the generic
  DeliveryCoordinator calls it, never feature code.
- The coordinator classifies each policy-lane delivery before the request is
  consumed: a run-notification that is not source-routed (it targets a
  notification channel, not the originating conversation) and is not an
  explicitly routed final answer rides the adapter's notification send;
  everything else rides ordinary delivery. Pinned by
  notification_class_delivery_rides_the_adapters_notification_send /
  conversation_reply_rides_the_adapters_ordinary_delivery. Zero behavior
  change for shipped adapters — all three inherit the delegating default.
- run_delivery::notifications is the named any-caller facade over the
  coordinator: notify(target, content) for one explicit catalog-resolved
  channel target, notify_user(user, content) fanning out over
  resolve_user_notification_targets (the picker set). The routine driver's
  own notification internals now delegate to it — one send path, with the
  routine lane as one caller among any number. Callers own WHEN/WHAT, never
  HOW, and never name a channel.
- The coordinator's streaming-reply gate now reads the new lightweight
  ChannelDeliveryResolver::channel_reply_mode lookup instead of performing a
  second full resolution, preserving the single generation-pinned
  resolve_channel_delivery read the OUT contract pins.

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

* feat(notifications): generalize notification setup behind ChannelAdapter (§7b)

Replace the bespoke /web-push/{status,subscriptions,subscriptions/remove}
routes with one generic per-channel surface keyed by extension_id:
GET/POST /api/webchat/v2/channels/{extension_id}/notifications{,/enable,/disable}.

- contracts: web_push descriptor module deleted; notification_setup module
  (status view + enable/disable command descriptors) and the
  RebornNotificationSetup* wire family replace the RebornWebPush* DTOs;
  body extension_id serde-defaulted (route path is canonical).
- assistant: reborn_services/web_push.rs deleted; notification_setup.rs adds
  ChannelNotificationSetupService + fail-closed Unsupported default +
  AdapterChannelNotificationSetupService dispatching to the channel adapter
  via ChannelDeliveryResolver (unknown extension -> 404, no-setup channel ->
  enabled:true + mutation 400, payload/detail byte bounds enforced).
- delivery coordinator: the streaming-reply gate now keys on the ROUTE, not
  the intent — a notification-routed send (RunNotification + non-live-source
  origin) flows to a streaming channel even with a conversation-shaped
  intent; pinned at the contract tier and by the blocked-fire push journey.
- web-push package: adapter implements the three setup operations over the
  slot runtime (scope byte-identical to the retired product service; detail
  carries vapid_public_key/subscription_count/subscriptions with
  endpoint_digest correlation).
- composition: wires AdapterChannelNotificationSetupService over the channel
  delivery resolver; WebPushComposition handle family deleted (the slot
  install inside assemble_web_push is now the single consumer).
- webui: route descriptors/router/handlers swapped to the generic surface;
  CONTRACT.md route table + outbound charter row updated.
- frontend: api.ts gains getNotificationSetupStatus/enable/disable keyed by
  extensionId; web-push.ts -> device-push.ts and useWebPushDevice ->
  useDevicePush re-read the channel-opaque detail; the notification panel's
  device row is matched by the GET /session-advertised session channel id —
  no channel name remains in the frontend; webPush.* i18n keys renamed
  devicePush.* across all 11 locales.
- tests: 5 new setup-dispatch contract tests + streaming-notification
  regression pin; product-api round-trip and delivery journey rewritten onto
  the generic surface; frontend suites updated (1241 pass).

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

* feat(channels): rename web-push -> web-app and retire the old spelling (§12.11, §13)

Product identity rename: extension id / channel name / catalog target id are
now 'web-app'; package dir crates/extensions/packages/web-app (crate
ironclaw_web_app_extension); domain crate crates/domains/ironclaw_web_app;
WEB_PUSH_* constants -> WEB_APP_*, WebPush* types -> WebApp*. PROPOSAL §5
tree updated (check-target-tree: 66/66 OK). Space-separated 'Web Push'
protocol prose stays — the protocol keeps its RFC name; the CHANNEL does not.

Persisted coordinates deliberately keep pre-rename bytes, each commented in
place and pinned by the new gate's allowlist:
- secret-store credential handle value 'web_push_vapid' (renaming = VAPID
  rotation = every existing browser subscription breaks cryptographically);
- enrollment document path /web-push/subscriptions.json plus composition's
  /web-push per-user mount alias (the alias resolves to a physical subpath;
  renaming would orphan enrollments);
- binding-ref grammar mints web-app/v1/ and decodes legacy web-push/v1/
  forever (regression test added).
Documented residue, no migration: stored notification-channel selections
carrying the old 'web-push' target id render Unavailable until re-selected
(population ~QA-only; the channel shipped 2026-08-09).

The install-catalog hide for the host's own surface is no longer an id
match: is_builtin_host_surface consults the SessionChannelDirectory (the
manifest-derived authenticated_session fact), failing OPEN on an absent
directory; the production round-trip test covers the hidden-listing behavior
end-to-end.

Enforcement (§13): new architecture gate
reborn_web_push_vocabulary_retired.rs pins web-push/web_push/WebPush/
webPush/WEB_PUSH at zero occurrences across crates/ (frontend sources
included), tests/integration/, and skills/, with an exact-term shrink-only
allowlist over the five persisted-compat files, a stale-sanction check, and
an assertion that the session + notification-setup routes stay
{extension_id}-parameterized. The specificity gate's web-app carve-out doc
records the rename.

E2E journey vocabulary renamed on both the Rust and Python sides
(case ids, test names, delivery-target enum member).

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

* chore(deps): bump lru 0.18.1 -> 0.18.2 (RUSTSEC double-free advisory)

cargo-deny advisories began failing on every head when the lru advisory
published; 0.18.2 is the fixed release (lru-rs#238).

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

* chore(ci): raise composition arc_dyn ceiling 816 -> 818 (unified channel model)

Two net-new dyn seams wired at assembly, both genuine inversion ports:
the session-inbound lane's SessionChannelDirectory + durable
IdempotencyLedger, and the generic ChannelNotificationSetupService —
offset by the deleted WebPushComposition handle family. Observed on the
merged branch: 833 = 818 + 15 tolerance exactly, no slack.

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

* fix(ci): migrate the last pre-unification callers and re-pin ratcheted ceilings

Everything here is fallout of surfaces this PR deliberately changed:

- smoke + composition webui_v2_e2e: the raw-HTTP browser-send helpers now
  discover the session channel from GET /session and post the generic
  /channels/{extension_id}/messages route (thread_id in the body) — the
  same flow the SPA ships.
- InboundUserMessageDispatch::Accepted is boxed (clippy large_enum_variant:
  the merged InboundTurnOutcome grew past the threshold; a rejection stays
  slim).
- journey coverage: a channel whose ingress verification is
  authenticated_session has no webhook mount — its inbound IS the WebUI
  session route, so it maps onto the webui journey evidence instead of
  demanding a per-channel label.
- attachments no-lander test pins the sharpened
  AttachmentLanderUnavailable variant (503, never settles the idempotency
  reservation) instead of the old generic rejection.
- body-limit contract test pins webui.v2.session_channel_message (14 MiB)
  after the route rename.
- contracts size ceilings re-pinned to measured merged values with
  rationale: extension_contracts 8_157 (AuthenticatedSession trust class,
  reply modes, §7b setup adapter surface), product_contracts 16_119
  (trust/binding enums, SessionChannelDirectory, setup descriptors + wire
  family), host_api 19_003 (doc churn referencing the renamed crate).

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

* fix(review): keep the catalog target id persisted, migrate e2e callers, pin notice routing

Review triage for nearai#7477 (IronLoop + multi-agent review).

Persisted identity (IronLoop Medium, also flagged by the review): the
catalog target id now keeps its pre-rename `web-push` bytes. The
notification-channel picker stores its selection as target ids in each
user's communication preferences, so this is a persisted per-user identity
exactly like the VAPID handle, the mount alias, and the binding-ref prefix —
the three the PR already kept. Renaming it resolved every stored selection to
Missing and dropped those users from notification fan-out. Applying the PR's
own rule uniformly removes the documented residue rather than shipping it;
the integration test now pins the split (target id `web-push`, channel
`web-app`) so the two can't be conflated again.

E2E callers of the retired send route (the browser-lane CI failure): four
Playwright interceptions and one API helper still targeted
/threads/{id}/messages, so failure injection never fired. All now use the
generic /channels/{extension_id}/messages route, and every mocked GET
/session advertises session_channel_extension_id the way a real deployment
does — the SPA fail-closes without it.

deliver_notice asymmetry (review Medium, correctness): confirmed correct and
now pinned. Notice-class intents are source-routed, so none is ever
notification-routed and `deliver`'s carve-out cannot apply; for a streaming
channel the originating conversation IS the projection stream, and Retract /
React have no counterpart there (the adapter reports both unsupported). The
new test drives all seven notice intents plus the notification path in one
breath so they cannot drift.

Session surface is built once (review Low/Medium, hot path): submit_turn
rebuilt DefaultProductSurface plus ~5 Arc'd services per browser message;
every input is an immutable builder-wired Arc, so it is memoized behind a
OnceLock.

Docs the rename sweep left stale: tests/CLAUDE.md cited a test name that
never existed, the web-app README cited a VAPID handle value that doesn't
exist (the constant deliberately keeps the old value), the extensions
package-inventory row still called the channel outbound-only with no ingress,
and a merge left a duplicated comment block in inbound_turn.

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

* test(review): cover the notify_user fan-out and the web-app adapter's setup errors

Closes the two review findings that were test gaps rather than follow-ups —
the repo's own rule is that production-wired behavior ships with its
caller-level test, and both of these were new production surface with none.

notify_user (crates/product/ironclaw_assistant/tests/run_delivery_contract.rs):
the driver only ever calls the single-target notify, so the fan-out loop was
untested. Two contracts now pinned through the real facade: an unconfigured
user yields an empty result rather than an error, and a target whose channel
no longer resolves surfaces its own Err while its healthy sibling still
delivers (asserted at the adapter, not just the return value).

web-app adapter (crates/extensions/packages/web-app/tests/notification_setup_contract.rs):
the generic service tests drive a scripted adapter, so the real parse →
validate → store path had no error coverage. Six cases: non-JSON document,
missing key material, undecodable base64url keys, an endpoint on an
undeclared push host, a malformed unenrollment document, and every setup
operation with no runtime installed. Each asserts the store was never
touched, so a rejected payload can't leave the browser believing it is
enrolled with no server record behind it. All six passed on first run — the
arms were correct, just unproven.

observer.rs: the repeated fallible-from_envelope fallback is now one
`degradable_binding` helper — but only for the two sites that genuinely
merge 'no request' and 'no binding' into the same degrade. The delivery path
still propagates (a send with no binding is a fault), and the
rejection-hint path still distinguishes them (posted nothing vs handled by
staying silent); both reasons are documented on the helper rather than
flattened away.

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

* fix(channels): close the audit findings — dead per-channel routes, gate gaps, session-surface fallback

Fallout from the four channel-specificity audits, plus two defects those
audits found in MY OWN branch that would have kept CI red.

Introduced by this branch, now fixed:
- a contracts-crate doc comment named Slack/Telegram, an untracked
  specificity-gate violation (the gate strips #[cfg(test)], and this was
  production code)
- deleting the dead telegram frontend modules made five ALLOWLIST rows
  stale; that list is an EQUALITY ratchet (both <= and >=), so the rows are
  removed and the baseline drops 117 -> 112

Pre-existing, found by the audit:
- 871 lines of orphaned telegram-setup frontend code holding the only two
  channel-named URL literals in the SPA, pointing at routes the backend no
  longer serves. Deleted.
- a dead ironclaw_assistant -> ironclaw_web_app dependency edge: a generic
  product crate holding a compile-time edge to one channel's domain crate
  for nothing
- telegram_extension_gates.rs still documented the retired per-channel
  pairing route as live

New gate (§13's structural half): no source file may name a channel in a
/api/webchat/v2/channels/ route. It scans SOURCE rather than the descriptor
table, because the defect it exists to catch lived entirely in callers the
route table never knew about — the table was clean while 871 lines of
channel-named client code sat beside it. Sabotage-tested against the deleted
file, which it flags. Placeholders and test fixtures are exempt (tests may
name channels, per the extension-runtime overview).

Session-surface regression, found by CI on the composition e2e suite:
a deployment that installs no channel extension had NO route to submit a
browser turn, because the old /threads/{id}/messages route is gone and
/session advertised no channel id. That is a supported deployment shape
(assemble_web_app treats its slot as optional), so browser chat must not
depend on an installed extension. BUILTIN_SESSION_SURFACE_ID now lives in
product_contracts::session_ingress; composition advertises it when no channel
claims the surface, the product gate accepts it, and WebuiServeConfig
defaults to it rather than None — the transport defaulting the surface to
'absent' was the actual defect. 15/15 composition e2e tests pass.

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

* docs(internal): channel output has two axes — reply and delivery

Design record for the follow-up train. The unified channel model unified the
pipeline; this reshapes the contract it drives. Recorded here rather than in
the follow-up PR so the decisions survive the conversation that produced them.

The root finding is not the eleven-method ChannelAdapter — that is the
symptom. It is that two independent concepts share one vocabulary:

- reply    = answering the run's input, SOURCE-routed, never without a run
- delivery = reaching someone out-of-band, TARGET-resolved, runs optional

They are orthogonal, not alternatives: one run can stream an answer into an
open tab AND push a notification because the user is not looking. Dispatching
on intent rather than on this axis already produced a real defect on this
branch — a gate prompt is a reply when a human is in the thread and a delivery
when a 3am routine is blocked, and keying the streaming skip on the intent
silently dropped the second case.

Decisions: three manifest sections (ingress/reply/delivery) replacing the
inbound/outbound/notifications booleans; OutboundRoute plus two transport
enums so nonsense combinations are unrepresentable; DeliveryOrigin keeping
model-chosen targets from inheriting user-configured trust; a streaming
delivery returns a projection cursor as evidence instead of NoDelivery,
closing an audit hole where browser replies produce no record at all;
activate/cleanup become an ingress-registration recipe; the attachment fetch
moves AFTER the ack (the durable write currently depends on it, which is what
puts a vendor round-trip on the webhook deadline path); enrollment moves
host-side with no adapter method, keeping one generic pre-storage check that
exists to prevent an SSRF primitive.

Five open questions and a six-step sequencing table are recorded; step one is
the smallest and closes both the no-op and the audit hole.

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

* fix(ci): repair fmt, the last retired-route callers, and the contracts ceiling

Three red checks on 3e8d40b, all mechanical:

- cargo fmt: the export-list edit in ironclaw_assistant/src/lib.rs left an
  unformatted line (fmt reflows multi-item use blocks; I removed a symbol
  after the last fmt pass).
- composition webui_v2_serve: two tests still posted to the retired
  /threads/{id}/messages route and got 404. Migrated to the generic
  /channels/{extension_id}/messages with thread_id in the body. One of them
  exists specifically to pin 'the shape api.ts builds', so it has to track
  the SPA; the other pins the 14 MiB descriptor cap against Axum's 2 MiB
  Json default, which is unchanged by the route move.
- product_contracts size ceiling 16_119 -> 16_132: BUILTIN_SESSION_SURFACE_ID
  plus its doc, the built-in session surface that keeps the generic session
  route from depending on an installed channel extension.

Verified: cargo fmt --check clean. The suites are left to CI — another agent
is working in this worktree and a local battery would block it.

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

* wip(channels): reply and delivery become two declared axes

Contract half of the channel-output redesign
(docs/internal/design/2026-08-11-channel-adapter-contract.md §1-§3).
INCOMPLETE — see the PR body / handoff for the stale-reference list.

- ChannelReplyMode -> ReplyTransport{Stream,Message} +
  DeliveryTransport{Push,Message}. Two enums so Stream-for-delivery and
  Push-for-reply are unrepresentable; a third transport joins as a
  variant rather than a reshape (§10.5).
- [channel.reply] and [channel.delivery] manifest sections replace the
  inbound/outbound/notifications/notifications_require_setup booleans
  and reply_mode. Absence of a section means the axis is unsupported,
  so a declaration can no longer say *that* a channel does something
  without saying *how* (§2, §9).
- max_message_chars moves from [channel.presentation] to
  [channel.reply]: a split bound is a property of the reply transport
  and is meaningless for transport = stream.
- ChannelDeliveryResolver::channel_reply_mode ->
  channel_reply_transport; notifications_require_setup ->
  requires_enrollment.

Fixes a live defect found while reshaping, not a rename: the stream
reply/session-ingress pairing check sat inside 'if let Some(ingress)',
so a channel declaring a stream reply with NO ingress validated
silently. The check now sits outside that block and the no-ingress arm
is pinned.

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

* refactor(channels): one derived projection for channel output facts

Replaces the three loose `max_message_chars` scalars an earlier pass in
this branch added beside `channel_presentation` at each carrier.

The bound legitimately moved out of [channel.presentation] into
[channel.reply] (it is a property of the reply transport, and is
meaningless for transport = stream). But pulling it out of a struct that
was ALREADY threaded through three carriers turned 'one type threaded
three times' into 'one type plus a loose scalar threaded three times' —
re-declaring the value at three layers with nothing keeping them in
agreement (.claude/rules/architecture.md §3).

ChannelOutputFacts is the fix: presentation + the reply bound, assembled
once by ChannelDescriptor::output_facts(), threaded exactly where
ChannelPresentation was. One manifest home per field, one projection,
carrier field count unchanged.

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

* wip(channels): three traits + declarative vendor-call recipes

Steps 2, 4, and 6 STARTED, NOT FINISHED — the ~20 consumers of the
removed ChannelAdapter are not yet updated. Does not compile.

- ChannelAdapter's 11 methods -> ChannelIngress::receive (async),
  ChannelReply::send_reply, ChannelDelivery::deliver/list_targets, held
  as ChannelSurfaces { ingress, reply, delivery }. A None is the same
  fact as a missing manifest section. A stream-reply channel implements
  no reply half at all.
- ChannelVendorCallRecipe: per-channel data, generic execution. Replaces
  activate/cleanup as [channel.ingress.registration]/[deregistration]
  and the attachment fetch as [channel.attachments], run post-ack.
- Telegram's setWebhook/deleteWebhook become manifest data; both method
  bodies go to zero.

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

* wip(channels): wire the three-trait split, the two-axis router, and host-owned enrollment

The workspace compiles again. `cd167ab8ed` defined ChannelIngress /
ChannelReply / ChannelDelivery and deleted ChannelAdapter without updating
~60 consumers; this wires them and lands the contract changes those consumers
were waiting on.

Step 6 — the split, with the check that earns it. ChannelSurfaces replaces
`Arc<dyn ChannelAdapter>` on ExtensionBindings, ActiveExtension,
DeploymentChannelBinding, ResolvedChannelDelivery and the composition binding.
`check_binding` now proves each `[channel.*]` section against its implementing
half at activation: webhook ingress <-> ingress half, `transport = "message"`
<-> reply half, `[channel.delivery]` <-> delivery half. Two axes are required
ABSENT and that is the point — a `stream` reply is published by the host and
`authenticated_session` ingress is normalized at the session door, so binding
a half there is dead code that reads as live. Without this check the three
Options would be a second copy of manifest facts with nothing keeping them in
agreement (architecture.md §3); with it, declaration and code cannot disagree
past activation. web-app now binds delivery ONLY.

Step 1 — OutboundRoute. The axis is computed once in DeliveryCoordinator from
the resolved routing decision and threaded through the drive chain in place of
the `as_notification` bool. The streaming gate keys on the route, not on
DeliveryIntent::is_conversation_reply — which is the exact conflation that
silently dropped blocked-routine pushes. A stream reply is no longer a silent
NoDelivery: `record_stream_reply` persists a full attempt row and returns
StreamDelivered { cursor }, so "was the user's answer delivered?" has one
answer and web-app stops being invisible in delivery audits (§4.1, §10.4).
Evidence is the projection ref the turn already wrote — §4.4's verify, not own.

Step 2 — activate/cleanup are gone. `[channel.ingress.registration]` /
`[channel.ingress.deregistration]` are executed generically by
`channel_vendor_calls`: `{handle}` substitution from non-secret config,
unresolved placeholders left for egress credential injection, body_credentials
forwarded by handle, single-pass substitution, JSON keys never templated.
Telegram's two method bodies became zero lines. Their assertions move with the
behavior to the host executor.

Step 5 — enrollment is host-owned. `ironclaw_auth::delivery_registrations`
stores an opaque, size-bounded document keyed (tenant, user, extension) with
the one security-critical check generic and pre-storage: the endpoint must
target a host declared in `[[channel.egress]]`, read from the same resolved
manifest egress policy enforces with. Without it enrollment is an SSRF
primitive. Placement is ironclaw_auth over ironclaw_outbound because the
adapter-facing view must live in extension_contracts and auth already names
it. Registrations ride the envelope and the adapter reports prunes — it holds
no store. A channel with zero registrations is a resolvable "no target" before
any adapter call. Pre-§8 documents migrate forward on read; `/web-push/
subscriptions.json` and its mount alias keep their exact bytes.

Still to do: --all-targets (test doubles, integration suites), step 4's
post-ack attachment fetch, docs, ratchets, PR body.

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

* wip(channels): carry the three-trait split through every test double and fixture

`cargo check --workspace --all-targets` is clean. The prior commit got the
lib and binary compiling; this carries the same change through the test
surface, which is where the behavioural pins live.

- Lifecycle: the three deleted Telegram `activate`/`cleanup` tests are
  re-pinned against the generic recipe executor, verbatim in what they assert
  — the bot token travels as a HANDLE and never as bytes, the shared secret
  rides `body_credentials` so the host inserts its VALUE at the manifest's
  declared pointer, the rendered body carries `url` but never `secret_token`
  nor the handle name, a missing config value and a vendor 5xx both fail
  activation, and deactivation calls deleteWebhook. Adds the arms the adapter
  tests could not reach: deregistration is best-effort and cannot strand a
  deactivation, and a channel declaring no recipes makes no vendor call.
- Binding: per-axis tests drop or add exactly one half against a manifest
  declaring the other two, so a failure names the axis. Plus the two absences
  that are the point — a `stream` reply and `authenticated_session` ingress
  must bind NO half, because the host publishes and the session door
  normalizes.
- web-app: `notification_setup_contract` becomes
  `registration_parsing_contract`, re-aimed at where the behaviour went.
  Endpoint admission and storage bounds are generic now and pinned in
  `ironclaw_auth`; what stays this package's is interpreting the opaque
  document at delivery. New coverage the old shape could not express: one
  unusable registration is pruned WITHOUT costing its siblings their
  notification, because the host owns the list and the adapter no longer
  reads its own store.
- outbound_delivery_contract: the §7b adapter-dispatch block becomes §8
  enrollment coverage. The security-critical arm is explicit — four hostile
  endpoint shapes (undeclared host, http, userinfo smuggling, suffix
  lookalike) are refused BEFORE storage, and the recording store proves
  nothing was written.
- Test doubles across assistant/host/composition/integration bind the halves
  their fixture manifests declare; the Acme fixture gains reply+delivery over
  one shared `send`, as a conversational vendor really behaves.

Reverted in this commit: an in-flight change making `receive` return a
COMPLETE message (attachment bytes + conversation context) so the two fetch
handles could leave the trait entirely. The design is right and is written up
for a fresh pass; landing it 70% done would repeat the breakage this branch
started from.

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

* refactor(channels): return complete inbound messages

Make ChannelIngress::receive the only vendor ingress call and return complete attachments and conversation context through manifest-restricted egress. Delete the host/product late-fetch callbacks while preserving exact byte validation, attachment budgets, policy reconciliation, batch recovery, and ack-after-commit semantics.

Keep Slack URL/history and Telegram two-hop file validation inside their packages. Also carry the selected manifest egress credential into host-owned lifecycle calls so Telegram setWebhook receives its declared token injection; the production libSQL journey covers activation, inbound bytes, dedupe, reply, and refresh.

* docs(channels): align capability contracts and ratchets

Document the ingress/reply/delivery capability split, host-owned session/stream modes, complete receive boundary, and delivery-registration ownership across the contract, package, product, and runtime guides. Amend the design record with the measured pre-ack order and the reasons a declarative attachment recipe cannot model Slack or Telegram safely.

Recapture the measured contract ceilings at extension_contracts 8,594 and loop_contracts 13,307, and the composition budget at 41,751 LOC / 837 Arc<dyn> sites. Widen the retired web-push scanner to Cargo.toml, E2E, and Python while preserving exact persisted-coordinate exceptions.

* fix(cli): warn when session channel is unavailable

Emit an operator-visible serve warning on the documented tracing target when composition resolves no session channel. Capture both the target and message in a regression test so the authenticated session route cannot disappear silently.

* fix(channels): align post-merge contracts

* fix(channels): finish normalized channel boundaries

* fix(channels): harden egress and notification setup

* fix(channels): make stream evidence and wiring explicit

* fix(review): close the verified audit and review findings

Critical — OpenAI-compat lane restored. submit_turn hard-404'd
extension_id: None while both compat workflows send None (the documented
lane: headless SDK clients cannot learn a channel id from GET /session).
Restores the None => BUILTIN_SESSION_SURFACE_ID arm exactly as the wire
doc specifies, keeps every Some strictly directory-gated, and pins the
builtin id as not route-addressable. Two-sided seam pins: the surface
half in reborn_services_contract, the caller half in the compat handlers
contract, plus a drift pin equating the contracts constant with the turn
kernel's WEBUI_SOURCE_CHANNEL.

Delivery reliability:
- Adapter report gate is coverage, not equality: vendor chunking reports
  one outcome per chunk (conformance legalizes >=); requiring == settled
  fully delivered chunked replies Unknown/Failed and invited duplicate
  resends. Under-reporting still settles Unknown, never retried.
- A crash-orphaned Prepared row re-validates and re-authorizes on replay
  (no vendor egress happened; the claim CAS stays the one transition
  authority) instead of wedging AlreadyInFlight forever. A revoked
  replay rejects via a distinct audit row, leaving the stable row for
  the claim fence. Sending-row recovery stays explicitly fail-closed
  per OUT-6; startup wiring needs a status index and is deferred with
  rationale in the PR discussion.
- Working-indicator notice refs are monotonic per run: the stable ref
  made every post-gate re-post settle AlreadyDelivered, so the
  indicator vanished after a gate cycle (and nudge refs reset the same
  way). First-post bytes are preserved.
- Reply-context store failures are logged before mapping to the unit
  port error (both the host source and the coordinator site).
- Partial web-app fan-out reasons carry the failing cause; the push
  status classification matrix (401/403/413/429/5xx/transport/mixed)
  is pinned.

OAuth binding compensation follows the credential: a terminally-failed
lifecycle activation revokes the extension credential, so the identity
binding now rolls back on exactly that arm instead of committing a
"connected with no usable credential" state; retryable dispatch
failures keep the binding (the credential remains valid and the replay
path never re-runs the hook). ContinuationDispatchFailure carries the
terminalization fact to the callback site.

Browser push enrollment un-broken (two-sided wire drift): the client
read the retired flat web-push detail shape while the backend emits
registrations/bootstrap — enroll was permanently dead and enrolled
browsers derived "another account". The client now reads the canonical
shape, project() emits per-registration endpoint_digest (lowercase hex
SHA-256 via ironclaw_common::hashing, matching endpointDigestHex), and
incomplete digest coverage reads correlation-unavailable, never
"not mine". Pinned by vitest parsing tests, the api mock now mirroring
the real shape, and a digest assertion in the integration round trip.

Session-ledger and feedback correctness:
- LlmConfigServiceError::Internal no longer settles a durable permanent
  PolicyDenied: a backend fault is transient, and the same
  client_action_id succeeds after recovery (pinned).
- Duplicate/replay rejections settle silently again instead of
  rendering the false DM-only command copy.
- ProductInboundTrust / ProductInboundBindingDirective persist
  snake_case tags (pinned before the first ledger row ships).
- session_inbound_request and sibling sites use the cause-logging
  internal_from constructor instead of dropping constructor errors.
- Attachment kind classification case-folds MIME at the boundary.
- The persisted webui-src/webui-reply prefixes are defined once.

Test-support honesty: the harness StaticSecretStore stores what
put_if_absent claims to create (and leases remember their handle), so
first-time VAPID bootstrap flows are testable; the SSRF reserved-key
strip in delivery_registrations is pinned; the session-channel catalog
hiding now has a directory-present test; web-app manifest label reads
"Web app".

Refuted with evidence (no change): the outbound record layer is already
CAS insert-if-absent + first-write-wins with the lost-race shape pinned
in outbound_state_store_contract; the WebUI session route needs no
route-level channel check because the product surface enforces the
directory fail-closed.

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

* fix(gates): reword a test comment out of the retired vocabulary scan

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

* fix(ci): stack headroom for the crate-bucket lane; evidence-driven final-reply pin

The composition-core bucket SIGABRTs on Linux: the composed-runtime skills
turn overflows the 2 MiB default test-thread stack (first seen in
the_model_runs_a_skills_script_from_the_workdir_the_body_advertises).
Give the bucket lane the same 8 MiB headroom the integration lanes document;
deep subtrees stay Box::pin'd — this is headroom, not a substitute.

The webui grouping pin asserted a hardcoded 'isFinalReply: false' literal;
the stream-evidence rework made the marker evidence-driven
(isFinalReply: finalizedText from the durable projection's finalized bit).
Pin the derivation — the same in-flight guarantee, stated against the
stronger shape.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
…sion channel message route (nearai#7574)

Two independent regressions have kept every scheduled Live Canary red:

1. Since nearai#7171 moved skill mounts onto one backend-generic tree, the case
   homes exported into artifacts carry no .ironclaw-reborn-bundled.json
   runtime marker (verified: zero markers across all 31 materialized skills
   in the QA-10 artifact of run 31641918366), so the marker-keyed
   bundled-skill pruning from nearai#6453 never engages and the long-committed
   placeholder text in skills/local-test/SKILL.md (docker examples with
   NEARAI_API_KEY=<your-key>) fails the strict scrub in all 12 shards —
   deterministically since the first scheduled run after nearai#7171 (Aug 9,
   21:11 UTC). The scrubber now also prunes a marker-less skill snapshot
   whose file set and bytes are identical to the source-controlled bundle;
   divergent or operator-authored content stays in scanning scope.

2. Since nearai#7477 the WebChat composer posts messages on the session channel
   ingress route (/api/webchat/v2/channels/<extension>/messages), while the
   live-QA submission-identity capture waited on the retired thread-scoped
   route — so QA 10 (the shard whose cases capture submission identity)
   went 9/10 to 0/10 at the first post-nearai#7477 scheduled run (Aug 12,
   21:18 UTC) with every case timing out at expect_response on a healthy,
   streaming turn (the failure screenshots show the correct answers
   mid-stream). The predicate now accepts both routes — the same migration
   the stress client made in nearai#7568 — so one harness spans binaries on
   either side of the split.

Scrub self-tests: 21 pass including two new cases (marker-less identical
snapshot pruned; marker-less divergent snapshot still fails strict), and
the identical-snapshot test fails against the unfixed script. Live-QA
runner unit tests: 218 pass including the new route-pattern regression
test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
l3ocifer pushed a commit to l3ocifer/frick-ironclaw that referenced this pull request Sep 3, 2026
* 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>
henrypark133 added a commit that referenced this pull request Sep 3, 2026
…dary test

#8038 added api-boundary.test.ts with "web-push" as an arbitrary example
extension id. That spelling was retired for "web-app" in #7477 and the
architecture gate retired_web_push_spelling_stays_at_zero_occurrences pins
it at zero outside the persisted-compat allowlist, so main has been red on
Tests (Reborn) since 666ebcb. The string carries no persisted or compat
meaning (the function under test interpolates any id verbatim), so rename
rather than widen the allowlist.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6JWfzEzWKThikJ9wsHAer
(cherry picked from commit 18c95e2)
pull Bot pushed a commit to Stars1233/ironclaw that referenced this pull request Sep 3, 2026
…dary test (nearai#8058)

nearai#8038 added api-boundary.test.ts with "web-push" as an arbitrary example
extension id. That spelling was retired for "web-app" in nearai#7477 and the
architecture gate retired_web_push_spelling_stays_at_zero_occurrences pins
it at zero outside the persisted-compat allowlist, so main has been red on
Tests (Reborn) since 666ebcb. The string carries no persisted or compat
meaning (the function under test interpolates any id verbatim), so rename
rather than widen the allowlist.


Claude-Session: https://claude.ai/code/session_01X6JWfzEzWKThikJ9wsHAer

Co-authored-by: Henry Park <16583448+henrypark133@users.noreply.github.com>
Co-authored-by: Claude Code <noreply@anthropic.com>

This branch was successfully deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants