feat(broker): unregistered_url_handler seam on AppContainer for discovery misses - #1281
Conversation
…ry misses Co-authored-by: Cursor <cursoragent@cursor.com>
Deep review — seam designVerdict: the seam is conceptually right — a default-preserving, container-injected interception point at the discovery-miss site is the correct high-level shape, and it correctly mirrors the injected- 1. The protocol is in the wrong module (blocking)
Fix: move the protocol to 2. The name over-promises and breaks the container's vocabulary
Fix: rename to 3. The tradeoff callout is missing — the seam opts out of more than it saysThe docstring nails the egress contract but understates everything else a forwarding (monitor-mode) handler silently loses. Compare the injected-
I considered whether these gaps mean the abstraction is wrong — i.e. a narrow value object + constrained 4. A short-circuit is invisible to core — settle the telemetry floor nowWhen a handler short-circuits, core records nothing — no execution row, no event, no metric. Deferring the issue's open question 3 wholesale isn't free: retrofitting telemetry after downstream handlers exist is an observable behaviour change. The cheap floor is a counter metric — this module already holds the meter ( 5. Tests under-deliver the issue's own test planThe issue's test plan lists "pinned-revision miss never invokes the handler" — that test doesn't exist in the PR, and it's the most rule-load-bearing behaviour here (nothing currently fails if a future refactor adds the hook call to the pinned site). Also:
6. Small stuff
Rule candidates this PR should seed
Bottom line: approve the shape; block on placement (1), settle the name now (2), and add the pinned-miss test + tradeoff callout + counter before merge. None of these change the abstraction — they make it the version the rules should be written from. |
- Rename the seam: AppContainer.on_operation_not_found -> unregistered_url_handler (noun-shaped like the container's other fields, and scoped to what actually fires it — the pinned-revision miss raises the same operation_not_found type but never invokes it). - Move the contract to shared/web/protocols.py (UnregisteredUrlHandler): the protocol is web-shaped (Request/Response), so it lives at the web layer; shared/broker/protocols stays transport-neutral. - Expand the contract docstring into the full tradeoff callout: PBAC posture, redaction obligations, body/response caps and deadlines that no longer apply, error semantics, HEAD/OPTIONS + respond-async notes, post-authn visibility floor. - Emit broker.unregistered_url.handled counter on a short-circuit — the core-side observability floor for handled traffic. - Add route-branch tests through the real _handle: handler short-circuit (receives the *validated* URL, pinning the egress ordering), no-handler 404, and the pinned-revision miss never invoking the handler. - Mirror the injected-Broker "resilience tradeoff" note in the extending docs; fix the seam-table row's Where column. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review items addressed in f994f35:
Verified: targeted pytest (24 passed), |
The counter is part of the documented seam contract (the core-side observability floor), so its two behaviours are pinned: a short-circuit increments it once; a declining handler (falls through to the 404) does not count as handled traffic. Co-authored-by: Cursor <cursoragent@cursor.com>
…mples The seam-table row and tradeoff callout exist, but the composition walkthrough is what integrators copy — add the handler to the AppContainer example and the compliance-test example. Co-authored-by: Cursor <cursoragent@cursor.com>
Review lap 1/7 — seam design & default-preservationStarting a multi-lap adversarial review across the epic #1280 set (#1281, #1283, #1284) plus #1286. Each lap runs several independent lenses; findings are cumulative and I won't re-report across laps. Reviewed in a wired worktree at Verdict: APPROVE-WITH-COMMENTS. The central claim — that OSS default behaviour is byte-identical and the seam is inert unless wired — holds up under attack; I tried several ways to break it and couldn't (details at the end). The findings are about what the seam promises an implementor, which matters because the whole point is that a downstream package writes against this contract without seeing the router. The egress claim is false — the hook sits after only one of two egress layers
At the hook site ( Fix: state the boundary precisely and hand over the safe client, e.g. "the URL has passed the pre-flight allow/deny check ( A sync handler passes both compliance tests, then 500s in the router
Probe: a sync handler with the right arity gets 3 passed, then at runtime Fix: assert The compliance base rejects correct implementations (minor)
Fix: resolve both sides with
|
Review lap 2/7 — seam blast radius · protocol evolution · merge topology · gatesFour independent lenses this lap. Findings are cumulative; I don't re-raise across laps. Verdict: APPROVE-WITH-COMMENTS (unchanged), and this PR is the only one of the four that is cleanly mergeable today — see the topology note at the end. The findings below are all about the seam's contract, not its default behaviour, which continues to hold up. The seam is a god-object: a handler reaches every secret in the deploymentLap 1 flagged the docstring's egress claim. This is bigger. So the blast radius of a buggy or hostile downstream handler is every credential in the deployment (the decrypted keyset plus the control DB) and token forgery for any actor (the JWT signing secret) — not merely "no PBAC has run". The seam's design does nothing to narrow it. And the returned
Fix: pass a narrowed frozen value object rather than the raw @dataclass(frozen=True)
class UnregisteredUrlCall:
method: str
upstream_url: str
identity: Identity
headers: Mapping[str, str] # Authorization/Cookie stripped
body: bytesand in The compliance base freezes the protocol at v1 — in both directions
So no forward-compatible shape exists in either direction: every future core parameter is a breaking change for every downstream on the release it lands. And because the compliance base is itself exported public API (advertised in Fix, while no downstream exists yet: either put Stack topology: this PR is the clean one — merge it firstVerified with For context, the other three all currently show One cross-PR note in your favour: #1284's Also worth knowing (stack-wide, affects this PR's CI)
Verified clean this lap
Lap 2 of 7 · lenses: authz/blast radius · contract irreversibility · stack topology · gate adversary · head |
Review lap 3/7 — failure isolation · observability · attributionThree lenses this lap. Findings are cumulative; I don't re-raise across laps. Verdict: APPROVE-WITH-COMMENTS (unchanged). Everything below is about what happens when a downstream handler misbehaves — which is the case an extension seam has to get right, because the core project inherits the blame. The hook has no error isolation, and the failure path emits nothing at all
So a broken handler converts every unregistered-URL 404 into a 500, and core emits no counter, no log, no event and no execution row. That matters especially because the seam's advertised users are passive: None of the 13 tests in Two related results at the same site:
Fix: isolate, instrument, and let the documented default win: except ProblemDetailException:
raise # a deliberate downstream problem response
except Exception: # NOT BaseException — CancelledError must propagate
_unregistered_url_handler_errors.add(1)
logger.exception("unregistered_url_handler_failed", handler=handler_name, method=method)
return None # fall through to the documented 404and amend A handler failure is both unattributable and uncorrelatableBeyond the missing log, the wire response gives an operator nothing to work with: Not RFC 9457 — so there's no machine-readable Worse, So even once you add the log line above, it won't correlate with the client's failed call unless it's emitted inside the router, before the middleware unwinds. Worth doing deliberately rather than discovering later. Fix: raise a typed The counter is success-only, against the codebase's own convention (minor)
The convention here is outcome-attributed counters, e.g. Fix: Verified clean this lap
Lap 3 of 7 · lenses: concurrency & failure modes · observability & day-2 · head |
Review lap 4/7 — mutation adversary · compound riskTwo lenses this lap: per-claim mutation testing (does a test actually fail when I break each claimed behaviour?) and compound-risk analysis. Findings are cumulative; I don't re-raise across laps. Verdict: APPROVE-WITH-COMMENTS (unchanged). No new bug in the shipped code — the findings are all test gaps, including one that supersedes a "verified clean" item from my lap 1 report. Correction to lap 1: the default-preservation mechanism passes for the wrong reasonI recorded " app_factory.py:566 if True: # was: if container.unregistered_url_handler is not None:gives Harmless today, because the router uses Fix: assert absence rather than falsiness — Three documented claims survive the entire 3570-test suiteEach of these mutations passed
The causes are visible in the fixtures: The middle row is the notable one: it means the error-isolation change I recommended in lap 3 could be implemented incorrectly — swallowing every handler exception into a silent 404 — and no test would object. Worth adding the test before the fix. Fix: assert Compound risk: the seam's
|
Lap 5 — integrator/consumer experience + docs-vs-code truthVerdict: MAJOR (L-03) — the seam's only documented composition example is the one topology the repo forbids for the broker
create_combined_app(ctx, ctx.config.apps, container=container)But the broker may never be part of a combined app: raise RuntimeError("broker must run as the sole surface; do not bundle it with others")Since the hook fires only in the broker router, any deployment that can reach it must be broker-only — i.e. Compounding: a broker-only app also needs Fix: replace the combined-app example for this seam with the broker-only one and state the constraint explicitly: "the hook fires only on the broker data plane, which must run as the sole surface — wire it via Verified clean — this PR's documentation is the best in the stackUnusually, most of this lap's work on #1281 produced negative results. Recording them so later laps don't re-tread:
Worktree clean at Cumulative for #1281: 0 BLOCKERs. The open items are L-03 (this lap), plus from earlier laps: G-03/H-01 (no error isolation or failure-path signal around the handler call), J-04 (three documented claims — the |
Lap 6 — merge-result verification (+ Lap 5 addendum)Verdict: Every prior lap reviewed this branch in isolation. This lap materialised the merge into today's Zero conflicts, no artifact churn, gates green
It merges with zero conflicts, touches no generated artifact, adds +14 unit tests, and zero main-side commits have landed on MAJOR (M-01) — but merging it first also protects it, and here's why that mattersThere is one stack interaction, and the ordering is the mitigation. This PR and #1284 mutually replace the same region of This PR adds The hazard is asymmetric and falls on your work. (This supersedes my lap-2 note that this conflict was trivial — true at the older base, false at today's main.) Fix (mostly on #1284's side): land this PR first, resolve all three Lap 5 addendum (N-03) — the compliance base's first failure is unactionableA handler written from the doc's seam row and tradeoff callout alone — neither of which says annotations must match — fails with a raw two-signature dump: It never says "add annotations", and never mentions the Fix: diff per-parameter and append "copy the signature from Verified clean on the merge axis
Cumulative for #1281 — still 0 BLOCKERs, and it is the only PR in the stack merging with zero conflicts. Open items are all fixable without redesign: L-03 (the documented composition example wires the handler into an app where it can never fire), G-03/H-01 (no error isolation or failure-path signal around the handler call), J-04/J-05 (four documented claims survive the full 3,570-test suite; the default-preservation test passes for the wrong reason), and A-18/A-10/N-03 on the compliance base. Merge order: this PR first → |
Lap 7 (final) — merge-readiness triage · verdict:
|
| # | Finding | Fix |
|---|---|---|
| J-05 | test_default_container_leaves_handler_unset:123 uses getattr(..., None) is None — cannot distinguish absent from set to None, so it passes for the wrong reason |
assert not hasattr(app.state, "unregistered_url_handler") |
| A-10 | isinstance(lambda: None, UnregisteredUrlHandler) → True; the protocol accepts a sync callable the seam will await |
add test_call_is_coroutine_function to the compliance base (also closes N-03) |
| G-03 | no error isolation or failure-path signal around the handler call — a broken passive observer becomes a silent 500 | wrap in try/except, log, and count failures |
| H-01/H-02 | counter is success-only, against repo convention | add the failure arm |
A-18, B-05, D-02 stay as recorded — all cosmetic or docs.
Cumulative: 7 laps, 0 blockers, 0 conflicts, 936 tests green. The seam design itself held up under the mutation adversary (lap 4) and the integrator lens (lap 5); what surfaced were test-precision and documentation gaps, not design faults. Fix the L-03 doc line, merge, and take the rest as fast-follow.
- isolate handler failures in the router: a raised exception (other than a deliberate ProblemDetailException) is logged with the handler's qualified name while request_id is still bound, counted, and falls through to the documented 404 instead of a 500; CancelledError still propagates - outcome-attribute the broker.unregistered_url.handled counter (handled/declined/error) per the repo's counter convention - protocol docstring states the egress boundary precisely (pre-flight check only; fetches must go through HttpClientProvider for DNS pinning), claims the execute rate limit, discloses the request.app.state reach (handler is fully trusted deployment code), and requires an async-def __call__ - app factories log unregistered_url_handler_installed at wire-up, and warn (unregistered_url_handler_unreachable) when the handler is set on an app with no broker surface, where it can never fire - extending docs: broker-only composition example for the seam (create_app + install_broker_registry_resolver) — the combined app never carries the broker, so the seam is unreachable there - compliance base: assert_signature_matches resolves annotations via typing.get_type_hints (string vs object annotations and Optional[X] vs X | None compare equal) with a per-parameter, actionable failure message; new test_call_is_coroutine_function rejects a sync __call__ - tests: default-container test asserts attribute absence (not falsiness); handler receives the raw request verbatim; StreamingResponse returns the same instance; raising/ProblemDetail/cancelled handler behaviour pinned at the helper and route level; counter outcomes pinned in both directions - new arch gate: every jentic_one.testing symbol the extending doc references must be exported, and every __all__ export must resolve Co-authored-by: Cursor <cursoragent@cursor.com>
|
Review items addressed in 85e561f (all seven laps triaged — everything actionable is in this PR, nothing deferred to follow-ups): Fix-before-merge
Fast-follow table — done now instead
Lap 1/2 contract findings
Deliberately not done (all deferred by the review itself; recording the reasoning): the Verified: full |
Review follow-up for GET /capabilities (#1284), on the branch recut from main that drops the duplicate #1282/#1285 commits. - Contributor seam: contributors receive a frozen CapabilityView instead of the live Context (no reach into mutable security config from behind an unauthenticated route); registration validates callables, arity and duplicates and logs the registrant; each contribution is isolated (exception/non-mapping/non-str-key/non-bool-value logged and dropped, never a 500); collisions are case-insensitive; contributors run once at app build, never on the request path. - features is dict[str, bool] (Go client emits map[string]bool). - urls.broker publishes only an explicitly set, validated http(s) server.advertised_broker_url with userinfo/query/fragment stripped; the internal server.mcp.broker_url hop is never published (the backend=local fallback keyed on a self-declared hint is gone). - urls block grows authorize/token/agent_registration/ oauth_client_registration plus the /mcp-scoped RFC 8414 and RFC 9728 metadata documents, all absolute whenever a base URL is known — a DCR client is pointed at /oauth-clients' issuer document, not /register's. - Field docs scope mount-derived flags to the answering process (split deployments: false means "not served here", not "does not exist"). - Deterministic body ships ETag + Cache-Control and honours If-None-Match (304); boot logs one capabilities_resolved line and warns on a loopback broker URL beside a public canonical URL. - jentic_one.testing keeps the #1281 and capability compliance bases side by side (explicit union); shape-pin test ties CAPABILITIES_VERSION to the document's field-pointer set. - Regenerated OpenAPI/UI/CLI clients, endpoints reference, config schema. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
Traffic to an unregistered METHOD+URL dies in the broker's catch-all as a 404 before any integrator seam can see it, so observing/serving discovery misses (audit sinks, catalog-suggestion UIs, monitor modes) currently requires a fragile global
OperationNotFoundErrorexception handler that cannot distinguish the discovery-miss 404 from other 404s. This adds a first-class, default-preserving hook on the existingAppContainerseam:None(the default) reproduces today's behaviour byte-identically.Changes
shared/web/protocols.py(new):UnregisteredUrlHandlerprotocol (Responseto short-circuit,Noneto fall through). The contract is deliberately web-shaped (Request/Response), so it lives inshared/web—shared/broker/protocolsstays transport-neutral. The docstring carries the load-bearing contract: the URL has passed the pre-flight egress check only (validate_upstream_urlis TOCTOU-vulnerable by design — any fetch the handler performs must go throughHttpClientProviderfor DNS pinning), and the full tradeoff callout — only authn, the per-actor execute rate limit, and the egress pre-check have run (no PBAC, no credential injection), core's body/response caps, deadlines, and resilience stack do not apply, the rawRequestcarries the caller's token/cookies/body and reaches the full deployment context viarequest.app.state(a handler is fully trusted deployment code, not a sandboxed plugin), fires forHEAD/OPTIONSand forPrefer: respond-asyncrequests (synchronous response), never sees anonymous traffic (401s first), and__call__must beasync def.shared/web/container.py:AppContainer.unregistered_url_handler: UnregisteredUrlHandler | None = None— noun-shaped like the container's other seam fields. Named for what actually fires it: the pinned-revision miss raises the sameoperation_not_foundproblem type but deliberately never invokes the handler (a caller pin error on a registered API is not an unregistered flow).broker/web/routers/execute.py:_handle's unregistered-URL miss calls the new_handle_unregistered_urlhelper (the_resolve_brokershape) before raising. The handler call is failure-isolated: a raised exception (other than a deliberateProblemDetailException, which propagates) is logged with the handler's qualified name — inside the router, whilerequest_idis still bound — counted, and falls through to the documented 404, so a broken passive observer never converts the miss into a 500 (CancelledErrorstill propagates). Thebroker.unregistered_url.handledcounter is outcome-attributed (handled/declined/error, bounded cardinality, matching the repo's counter convention) — the core-side observability floor, since a handled miss writes no execution row and emits no event. Streaming responses return verbatim (nothing in core buffers); the async path and worker run post-discovery and are out of scope.shared/web/app_factory.py: both factories stash the handler onapp.state(the injected-brokerpattern); unset by default. Wire-up is logged (unregistered_url_handler_installed); a handler set on an app with no broker surface — where the hook can never fire — logsunregistered_url_handler_unreachableinstead of failing silent.jentic_one.testing:BaseUnregisteredUrlHandlerComplianceTest(isinstance + exact__call__signature +test_call_is_coroutine_function, so a sync__call__— which the router wouldawaitinto a 500 — fails compliance rather than production).assert_signature_matchesnow resolves annotations viatyping.get_type_hints, so string vs object annotations andOptional[X]vsX | Nonecompare equal, and its failure message diffs per-parameter with a concrete fix hint. Exported and exercised against a no-op reference implementation in the OSS suite.docs/development/extending-jentic-one.md: seam-table row, an "Unregistered-URL tradeoff" callout mirroring the injected-Brokerresilience note, and a broker-only composition example — the hook fires only in the broker catch-all and the broker runs as the sole surface of its process, so the seam is wired viajentic_one.broker.web.app.create_app+install_broker_registry_resolver, never viacreate_combined_app.tests/arch/test_testing_public_api.py(new gate): everyjentic_one.testingsymbol the extending doc references must be exported via__all__, and every__all__export must resolve — so a merge resolution can never silently delete documented public API.Out of scope: richer telemetry (events) on handler short-circuits beyond the counter, a timeout around the handler call, narrowing the handler's argument to a value object (the raw-
Request-with-loud-contract shape is deliberate — see the review thread), and any monitor-mode implementation — this PR is the neutral seam only.Risk & rollback
None, the state attribute is never set (pinned by ahasattrassertion, not falsiness), and the raise site is reached exactly as today (same exception type and problem-details body — pinned by existing tests and the route-branch tests).make openapinot needed — the catch-all's shape is unchanged).Test plan
tests/unit/broker/test_unregistered_url_handler.py(new): container stashes the handler on surface + combined apps; default leaves the attribute absent; no handler /None-returning handler falls through to the 404; a returnedResponse(including aStreamingResponse, same instance) comes back verbatim; the handler receives method/URL/identity and the raw request object verbatim; route-branch tests through the real_handle: a handler short-circuits the miss and receives the validated URL (pinning the hook-after-validate_upstream_urlordering), no handler raisesoperation_not_foundas today, a raising handler still yieldsoperation_not_found(never a 500), and the pinned-revision miss never invokes the handler.outcome="error") and logged with its qualified name; aProblemDetailExceptionpropagates uncounted;CancelledErrorpropagates uncounted (no swallow).handled/declined/erroroutcomes, and no metric at all on the unwired default path.tests/unit/testing/test_compliance_oss.py: compliance base (isinstance + signature + coroutine check) run against a no-op reference handler.tests/arch/test_testing_public_api.py: doc-referencedjentic_one.testingsymbols resolve.tests/unit(3279 passed), fulltests/arch/(300 passed), repo-wide ruff +uv run mypy(clean, 1226 files).Review guide
Start with the protocol docstring in
shared/web/protocols.py(the egress boundary + tradeoff callout), then the single guarded branch inexecute.py::_handleand the failure isolation in_handle_unregistered_url; the rest is mechanical wiring mirroring the injected-broker seam. The review that shaped this state is in the PR comments.Closes #1277
Made with Cursor