Repository navigation
refactor(channels): normalize ingress and split reply from delivery - #7477
Conversation
…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>
|
🚅 Deployed to the ironclaw-pr-7477 environment in ironclaw-ci-preview
|
🧭 IronLoop Run · ReviewThis comment updates in place as the Run moves through its stages. 🟩 Final result · Completed
Automatic trigger · attempt 1 of 3 · completed in 24m 54s IronLoop completed the review and posted it to GitHub. 🔗 Result |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughSummary by CodeRabbit
WalkthroughMigrates 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. ChangesUnified Channel Model Migration
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
🚥 Pre-merge checks | ✅ 3 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (3 passed)
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. Comment |
There was a problem hiding this comment.
🔍 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"; |
There was a problem hiding this comment.
🔍 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.
There was a problem hiding this comment.
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")?, |
There was a problem hiding this comment.
🔍 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.
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
🔍 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.
There was a problem hiding this comment.
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.)
|
Caution CodeRabbit couldn't update its existing comment. The review summary may be out of date. Error details |
…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>
…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>
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>
…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>
…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>
* 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>
…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>
…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>
* 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>
…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)
…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>
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
ChannelIngress::receivereturns a complete normalized inbound outcome.ChannelReply::send_replyhandles source-routed conversational replies.ChannelDelivery::deliverhandles target-resolved out-of-band delivery.ExternalActorRef -> Option<ExternalConversationRef>direct-target provisioning operation.webuisession fallback. Authenticated browser ingress resolves only through the manifest-declaredweb-appsession channel.Ingress and attachments
NormalizedInboundMessage.InboundAttachment.Reply, streaming, delivery, and notifications
Reply | Deliveryaxis. Content kinds and authorization origins stay in their owning request types instead of becoming ornamental routing enums.Sendingrows was removed; quiescent recovery remains explicit.Type and state simplification
Removed mirror or dead lifecycle vocabulary, including:
NormalizedAttachmentin favor of canonicalInboundAttachment;StoredRegistrationmirror;ReplyKind/DeliveryOriginrouting taxonomy;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
[channel.ingress],[channel.reply], and[channel.delivery]; a private reader normalizes only the immediately preceding deployed v3 shapes.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.scripts/ci/docs_publication_boundary.pyand diff-integrity checks — green.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.