Skip to content

TerraYield federation-peer track: contract v1 freeze, MCP conformance, brokered credentials, ref-rendering (closes #12-#20) - #23

Merged
mortonanalytics merged 69 commits into
mainfrom
feat/federation-peer-track
Jul 26, 2026
Merged

TerraYield federation-peer track: contract v1 freeze, MCP conformance, brokered credentials, ref-rendering (closes #12-#20)#23
mortonanalytics merged 69 commits into
mainfrom
feat/federation-peer-track

Conversation

@mortonanalytics

@mortonanalytics mortonanalytics commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Closes #12, #13, #14, #15, #16, #17, #18, #19, #20. Supersedes #11.

Resolves the TerraYield federation-peer track and the red CI on #11, as one branch so CI fires once.

Resolution table

Issue Landed Proving test
#11 red CI phase12_peer subscribed without an Active binding, so poll correctly refused after the TT-A06 fail-closed fix. Both stale tests corrected, assertions strengthened. phase12_peer::mcp_client_poll_fetches_survivors_from_peer, phase14_bindings::mcp_client_poll_fails_closed_when_binding_inactive_or_missing
#17 contract v1 md/design/app-integration-contract-v1.md — frozen, all six surfaces numbered, concrete schemas, per-rule enforcement status, compatibility rule contract_v1_integration (5)
#19 pre-broker creds New workspace_peer_credentials table; encrypted at rest; rotation is a PUT, no schema change; presented as Authorization: Bearer peer_credential_integration (11)
#20 webhook ingress approval_required made optional per contract; malformed-signature, ±30s skew, ±300s window, 72h purge, envelope-validation coverage phase_push_ingress (13)
#16 stub peer + kit tests/support/stub_peer.rs + standalone src/bin/ione-conformance.rs (zero ione:: imports, liftable) stub_peer_conformance_integration (8) + 19 kit unit tests
#15 MCP peer-client Accept: application/json, text/event-stream, SSE-framed reply parsing, notifications/initialized, MCP-Protocol-Version, unique JSON-RPC ids with correlation, boot-time SSE sessions, nextCursor: null fix mcp_conformance_integration (8)
#18 ref-rendering tile_url/vector_url https-only + SSRF-guarded; expired signed-URL re-request; nextCursor on all four panel paths; chart 2 MiB cap; zero-persistence proof peer_ref_rendering_integration (6) + 3 e2e
#12 identity broker Broker refresh/revoke stub replaced with a real token exchange; per-(workspace, peer) delegated tokens; 4-tier credential precedence identity_broker_integration (14)
#13 peer lifecycle E2E Stranger → discovery → PKCE → token → allowlist → active → bound → renders map/chart/table → signed webhook, with negative paths peer_lifecycle_e2e_integration (5)
#14 tracking Closed on IONe-side scope. Its six morton-analytics-llc/eo_ag items (#15#20) are unaffected and remain open there.

Waves

Bugs found and fixed that no issue asked for

  • Cross-workspace cache bleed (introduced by this branch's own credential-scoping fix): peer_manifest_cache/peer_slice_cache were keyed by peer_id alone while being populated with workspace-scoped credentials, so workspace A's manifest was served to workspace B and persisted across restart. Now keyed by PeerCacheKey { peer_id, workspace_scope }.
  • Workspace credential never presented on the federated tools/call pathMinimal authenticated peer access (pre-broker): per-workspace credentials, no unauthenticated surfaces #19's "every MCP request" was four paths of eight-plus. Every credential test asserted through one route (/table-data), which is why it survived.
  • nextCursor: null → 50× request amplification: a conforming peer terminating with an explicit null got 50 pages and a 25×-duplicated manifest.
  • 401 retry silently downgraded a tier-1 delegated / tier-3 static credential to the peer-global token, on the default connector path.
  • tile_url unvalidated: javascript:, plaintext http:, and 169.254.169.254 reached MapLibre verbatim.
  • CI ran neither --lib/--bins nor non-#[ignore] tests — 50 unit tests plus phase_federation_hardening's 4 never executed. Loop now uses --include-ignored.
  • Conformance kit false-FAILed conforming peers three passes running (required approval_required, rejected paginating peers, could not parse SSE) and false-PASSed a peer missing registration_endpoint.
  • Eleven contract overclaims where the frozen doc asserted enforcement the code did not have — six of them in my own earlier corrections.

Local CI mirror — mirrors .github/workflows/ci.yml exactly

Job Result
1 · fmt · clippy · check PASS
2 · integration tests (live pg 5433) PASS — 53 suites, 544 tests, 0 failures
3 · release build PASS

59/59 steps green. Last two consecutive runs identical step-for-step. No test skipped, #[ignore]-d to dodge red, or weakened; every fix carries pre-fix failure output or mutation evidence.

Known gaps, stated rather than hidden

  1. Identity broker (S0–S7): gate for TerraYield brokered SSO #12 AC-15 (RLS org isolation)partially closed in the follow-up pass below. Enforcement is now proven under a restricted role; it remains bypassed under the default SUPERUSER connection, and most repositories still do not set the org context. See the F14 section for the exact boundary.
  2. Playwrightclosed in the follow-up pass below. CI grows a fourth e2e job.
  3. Follow-ups: peers.tool_allowlist is stored but never gates invocation; OAuth discovery resolves off the MCP path rather than the origin (RFC 8414); peer_name_from_url drops the port so two peers on one host collide; no MCP session cache; expand_tool_schema is dead code; ~20 stale file:line citations in the contract.

Follow-ups completed (second pass)

Every follow-up named in the original PR description is now closed, plus the two gaps that were listed as "not closed". CI grows a fourth job.

# Follow-up Landed Proving test
F1 peers.tool_allowlist stored but never gated invocation Enforced on all three federated paths. Migration 0049 adds tool_allowlist_configured so an authorized empty allowlist now fails closed, matching delivery.rs tool_allowlist_integration (4)
F2 registration_endpoint required → conforming peers couldn't join Optional; CIMD fallback; 400 naming both options when neither is published peer_join_followups_integration (9)
F3 OAuth discovery fetched off the MCP path Resolved against the origin per RFC 8414 §3; legacy path kept as a deprecated fallback with a warn!
F4 peer_name_from_url dropped the port → same-host peers collided Port included when non-default. Also fixed map_peer_registration_error, which matched e.to_string() while the error is .context()-wrapped — a genuine duplicate URL was returning 500, not 400
F5 foreign_roles: null rejected → whoami failed, binding stuck pending null and absent both mean "no roles"; malformed values still fail whoami_and_error_mapping_integration (4)
F6 Chart -32002 collapsed to 502 Branches on the JSON-RPC code like table_data; -32002404 on both paths
F7 whoami refresh path unbounded (~45 s ceiling) WHOAMI_TIMEOUT = 3 s on both paths, covering all of fetch_whoami
F8 No MCP session cache; sessions leaked Cached per PeerCacheKey, TTL-bounded, DELETE on eviction, HTTP 404 now recognised as session-expiry mcp_session_integration (7)
F9 expand_tool_schema dead code (also called a non-MCP method) Deleted
F10 Two un-cursored tools/list sites Both paginate; allowlist review no longer shows only page 1 ″ + peer_join_followups_integration
F11 Unparsable slice:// wiped sample_queries Distinguishes "peer served a valid empty slice" from "peer served garbage" mcp_session_integration
F12 Stale contract citations 2 pointed past EOF, 11 used a basename ambiguous between src/routes/ and src/services/. Converted to path+symbol; checker reports 39 resolved, 0 past EOF, 0 ambiguous citation checker
F13 Playwright never ran in CI New e2e job; document-ref-expiry.spec.ts — the only proof of #18's expired-URL criterion — now runs 48/48
F14 RLS AC-15 inert Migration 0050: FORCE ROW LEVEL SECURITY on all 11 tables + restricted ione_app role; src/rls.rs::org_scoped_tx sets the org context rls_enforcement_integration (4)

F14 — what AC-15 now is, precisely

Enforced and proven. The test reads broker_credentials with no org_id predicate at all, so RLS is demonstrably the only thing filtering: under org A's context, org B's rows are gone; the same unfiltered read as the default role still returns both. Migrated: WorkspacePeerCredentialRepo::{get,list_for_workspace,delete}, WorkspacePeerDelegationRepo::{get,delete}, BrokerCredentialRepo::{create_pending,list_for_user,find_for_user,store_tokens,delete}.

Still not covered, stated rather than glossed:

  • The deployment everyone runs connects as ione — SUPERUSER + BYPASSRLS — which Postgres lets past row security unconditionally. FORCE does not apply to it. AC-15 is therefore not marked satisfied outright.
  • Methods with no org id in hand cannot set context (upsert, secret_for, material_for, find_by_state, …).
  • Every other repository — peers, bindings, service-account tokens, MFA, identity-audit. Their tables carry FORCE; their queries don't set context.
  • ione_app is not yet a supported runtime role: unmigrated paths fail closed under it (a recycled pooled connection yields ''::uuid22P02). No leak, but a write would affect zero rows. It exists to prove isolation and enable a later cutover.

Two mistakes worth recording

A one-line comment edit took down all 57 suites. I amended the header of migration 0048 after it had been applied; sqlx checksums applied migrations, so sqlx migrate run failed and every suite failed behind it. It would have done the same to CI and to every existing deployment. Applied migrations are immutable — 0048 is restored byte-for-byte and the note moved to 0050. Verified all 50 apply to a pristine database.

Wiring F1's flag exposed a latent trap. There are three hand-written peers column lists, and because Peer marks newer fields #[sqlx(default)], a missed list does not error — it yields false, which reads as "not configured" and silently disables the allowlist gate. It took two rounds to find. Documented at peer_by_prefix.

The conformance kit has now been wrong in the same direction six times. Each production fix (optional approval_required, pagination, SSE, uppercase hex, null roles, optional registration_endpoint) silently invalidated a hand-duplicated copy of the same rule in src/bin/ione-conformance.rs. It is correct as of this commit and covered by 21 unit tests, but the duplication is structural and will keep re-breaking until the kit derives from shared code rather than restating it.

CI

Four jobs now. --include-ignored (not --ignored) so non-ignore-gated suites actually run; --lib --bins so unit tests execute. Every tests/*.rs is enumerated — audited, zero orphans.

Job Result
1 · fmt · clippy · check PASS
2 · integration tests PASS — 58 suites, 573 tests
3 · release build PASS
4 · playwright e2e PASS — 48 specs

Second-pass additions

  • F3 was incomplete. Two refresh-time sites (peer_delegation::discover, peer_tokens::discover_peer) still built discovery as {mcp_url}/.well-known/…, so a spec-conforming peer publishing only at its origin would join successfully and then fail every token refresh afterwards — surfacing hours after onboarding rather than at setup. Both now resolve origin-first with the legacy fallback.
  • Citation checker promoted into the repo (scripts/check-contract-citations.py) and wired into CI job 1: 39 resolved, 0 past EOF, 0 ambiguous, build fails otherwise.
  • foreign-tenant-mapping.md claimed an 8 s whoami timeout it never had; corrected to 3 s.

Issues filed

Issue Why it is follow-up-sized rather than done here
#24 Conformance kit restates production rules by hand — six silent invalidations on this branch, every one failing a conforming peer. Needs a shared-predicate crate or parity tests, not another patch.
#25 RLS org-context sweep across the remaining repos; making ione_app a supported runtime role.
#26 Three hand-written peers column lists + #[sqlx(default)] — a missed list silently disabled the allowlist gate added in this PR.
#27 Docs promise an aggregated slice:// on IONe's own surface that has never existed.
#28 MAX_SLICE_BYTES truncates a peer slice silently mid-payload into an LLM prompt.
#29 SSE replies split across multiple data: lines are not reassembled (production and the kit agree, so the kit is honest — it is a production gap).

Closed as already-fixed, with each issue's own premise corrected: #21 (described the token-tier downgrade as unreachable; it was live on the default connector path) and #22 (described an un-cursored resources/list; every resources/list site already paginated — the two un-cursored calls were tools/list).

🤖 Generated with Claude Code

mortonanalytics and others added 30 commits June 21, 2026 21:27
…tes, session race

Tri-thrust workflow A-cluster (validated, cargo check + clippy + fmt green):
- TT-A01: parameterize critic evidence query (SQLi via signal.evidence event_ids)
- TT-A02: reject bindings/subscriptions to non-active peers (route + repo backstop)
- TT-A03: re-validate peer + binding status at approval execution time
- TT-A04: peers:manage RBAC gate on create/patch/delete/refresh binding routes
- TT-A05: fix TOCTOU race in MCP session init (atomic DashMap entry)
- TT-C09: 100KB cap on webhook data field (flows into critic LLM prompt)

Ledger + dispositions: md/intake/tri-thrust-advance-2026-06-21.md

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

Tri-thrust workflow security/correctness wave (cargo check + clippy + fmt green):
- TT-A06: mcp_client poll fails closed when no Active binding (was falling back to
  unscoped peer-wide workspace enumeration → C-1 cross-workspace read)
- TT-A07: defer subscribe_peer first poll until binding is Active (firstPollDeferred)
- TT-C01: 64KiB cap on stream_event payloads at repo choke point (InsertOutcome::Rejected
  + skipped count in poll response); env override IONE_MAX_STREAM_EVENT_BYTES
- TT-C08: bucket_expr returns Option instead of panicking on unvalidated bucket
  (stream + audit aggregate repos); add minute arm to stream variant

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…earthquake schema

TT-B01 (domain-agnostic UX): the map detail panel hardcoded USGS earthquake field
names (magnitude/depth/PAGER/place/url). The backend already drives feature
properties from operator-declared view_config.property_fields, so:
- event_layers.rs: EventLayer now carries the ordered propertyFields name manifest
- app.js openEventPopup: renders those fields (in declared order) from
  feature.properties, auto-linking URL values; removes magnitude/depth/PAGER/place
  hardcoding and PAGER_LABELS; threads the real layer through both call sites

Falls back to all non-internal properties when no manifest is present.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New md/requirements/active/event-view-schema.md for TT-B01 (domain-agnostic
  event detail rendering contract)
- Update intake ledger: A06/A07/B01/C01/C08 shipped; A03b/A04b/C02 deferred with
  re-entry gates (capability-probe, InteractionEvent attribution, prompt-injection
  mitigation approach all need design, not surgical edits)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cover the shipped guards that lacked tests (live-DB integration, serial):
  TT-A02  subscribe rejects a non-active peer (400)
  TT-A04  binding create + delete require peers:manage (403, fail-closed)
  TT-A07  subscribe defers first poll when no Active binding exists
  TT-C01  oversized stream-event payloads -> InsertOutcome::Rejected, not stored
  TT-C09  oversized webhook data rejected at envelope validation

6/6 green against Postgres. Closes the test gap on the security-critical
federation fixes before PR.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ths + stale P1-P3 status

2026-07-07 asset audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both suites still encoded the pre-hardening contract that 003a2fd removed,
which is why CI was red on phase12_peer.

- phase14_bindings: mcp_client_poll_falls_back_when_binding_inactive_or_missing
  asserted a fallback to peer-wide list_workspaces enumeration across
  remote-a/remote-b -- i.e. it asserted the C-1 cross-workspace read leak as
  expected behavior. Renamed to ..._fails_closed_... and inverted: poll must
  return 502 connector_error and must issue zero list_workspaces and zero
  list_survivors calls. Strictly stronger than the assertion it replaces.

- phase12_peer: mcp_client_poll_fetches_survivors_from_peer subscribed without
  an Active binding, so poll scope resolution correctly refused and produced 0
  events. Let subscribe create the Active binding via IONe-to-IONe whoami, then
  scope it to node B's workspace. Also asserts firstPollDeferred=false to pin
  the TT-A07 path. The >= 1 event assertion is unchanged.

No test skipped, ignored, or weakened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI enumerated 26 suites explicitly; the repo has 45. Nineteen integration
suites were built, committed, and then never executed by any job -- including
the negative-path regressions that guard the tri-thrust hardening itself
(federation_hardening_regression_integration: peers:manage RBAC gates,
non-active-peer subscribe rejection, ingest size caps), plus rbac_integration,
catalog_search_integration, integration_oauth_roundtrip, integration_peer_manifest,
headless_provisioning_integration and the three observability suites.

All 19 were run locally against live Postgres before wiring in. Eighteen were
already green. contract_demo_workspace failed only because it never set
IONE_TOKEN_KEY, which demo seeding needs to encrypt the demo peer token; every
other suite that needs it sets it in-file, so this one now does too. That is an
env-setup omission in the test harness, not a production defect.

No production code changed.

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

Adds md/design/app-integration-contract-v1.md — the versioned, frozen (v1,
2026-07-25) peer contract. Every rule is documented against what the code
actually enforces today, not what the v0.1 playbook aspired to.

Contents:
- All six peer surfaces, consistently numbered to match
  md/playbooks/building-on-ione.md (the playbook only numbered five and left
  whoami as unheaded prose).
- Concrete JSON schemas with field types, required/optional, and limits for:
  the ione_view enum + per-view resource metadata, whoami request/response,
  the slice:// context-slice payload, and the webhook envelope.
- Webhook signature scheme: X-IONe-Signature grammar, HMAC-SHA256 over
  t ++ "." ++ raw_body, 64-hex digest, constant-time compare, and the
  hardcoded +/-300s and +/-30s replay windows.
- Error conventions: the global {error, message, hint?, field?} envelope and
  the deliberate webhook exception, with the enumeration-oracle rationale.
- Pagination/cursor semantics and the full size-limit table.
- An explicit compatibility rule: what a v1 peer can rely on across IONe
  releases, what IONe may add additively, and what forces v2.

Every rule carries an Enforcement status (Enforced / Specified / Peer-side) so
the doc never claims enforcement that does not exist. Notably called out as
specified-but-not-enforced: the 2 MiB chart resources/read cap (chart_data.rs
has a timeout but no byte cap, unlike table_data.rs) and nextCursor on the four
panel resources/list paths (only the manifest path paginates today).

Appendix A records seven places where the code contradicts the playbook,
including that IONe does not serve an aggregated slice:// at all — its own
resources/read rejects every URI except whoami://.

Marks the v0.1 playbook superseded with a pointer; no restructuring.

Adds tests/contract_v1_integration.rs making the frozen contract executable
against IONe's own MCP surfaces (IONe is itself a peer): whoami:// mimeType and
full field set, resources/list advertising only whoami://, slice:// returning
-32602, the global 4xx envelope, the non-leaky webhook envelope on both a 400
and a 401 path, and a correctly-signed webhook accepted while a request with a
one-nibble-tampered v1 digest is rejected 401 — a negative case the suite did
not previously cover.

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

IONe can now hold one static bearer credential per (workspace, peer) and
presents it as `Authorization: Bearer <credential>` on outbound MCP requests
made in that workspace's scope — byte-identical to the OAuth path, so a peer
cannot tell which mode IONe is in and needs no rebuild when the broker (#12)
lands.

Schema: new table `workspace_peer_credentials`, UNIQUE (workspace_id, peer_id),
not a column on `workspace_peer_bindings`. Binding rows are serialized wholesale
into API responses (routes/bindings.rs returns `serde_json::to_value(binding)`),
so a ciphertext column there would ride along on every binding read and every
future binding query would have to remember to exclude it; credential lifetime
is also independent of binding status. `org_id` is derived by a trigger and
carries the RLS policy, mirroring workspace_peer_bindings.

Rotation is `PUT` on the same path: it rewrites `credential_ciphertext` in place
and stamps `rotated_at`. No schema change, no new row, no migration. The
plaintext is returned exactly once in that response; `GET` and the list return
`models::WorkspacePeerCredential`, which has no secret field, so no read surface
can echo it. Audit rows carry {workspace_id, peer_id} only.

Precedence (services::peer_tokens::resolve_access_token), highest first:
brokered OAuth token > per-(workspace, peer) static credential > the
process-global IONE_OAUTH_STATIC_BEARER env fallback. OAuth wins deliberately:
when #12 lands, a peer that gains a brokered token switches on the next request
with no flag day, and the dormant static credential can be deleted at leisure.

Workspace scope travels on `models::Peer` as `workspace_scope: Option<Uuid>` —
not a `peers` column, just the scope the handle was resolved under.
`list_active_peers_for_workspace` tags every peer it returns, which covers the
workspace data paths (table/chart data, map layers, panels, federation tool and
resource calls) with no service-layer changes; subscribe, binding refresh, and
the mcp_client connector set it explicitly. Peer-global paths (registration-time
manifest fetch, the shared SSE session) have no workspace and stay on OAuth/env.

Encryption uses the versioned envelope under IONE_TOKEN_KEY, matching
broker_credentials. `peers.webhook_secret_ciphertext` (inbound webhook HMAC) is
untouched. Poll still fails closed without an Active binding (TT-A06).

RBAC: create/rotate/read/delete gated on workspace-scoped `peers:manage`, the
same grant the binding routes use; cross-org 404s before the permission check.

Design: md/design/pre-broker-peer-credentials.md
Tests: tests/peer_credential_integration.rs (11, DB-backed, ignored, serial)

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

Closes the untested negative paths in signed webhook ingress (issue #20).
Every rule below was enforced in code but had no regression guard.

- Signature header grammar (contract §3.1): duplicate `t`, duplicate `v1`,
  unknown key, missing `t`, missing `v1`, non-hex digest, over/under-length
  digest, pair without `=`, non-integer `t`, and a wholly absent header.
  These return 400 `webhook_rejected`, not 401 — an unparseable header never
  reaches `verify_signature`.
- The ±30 s `t` vs `occurred_at` skew rule (§3.2) in isolation. The existing
  stale-webhook test moves `occurred_at` ten minutes, tripping the ±300 s and
  ±30 s windows together, so the 30 s bound was not independently covered.
- The 72 h dedup purge (`cleanup_expired`): a row older than the window is
  deleted and one inside it survives.
- Envelope validation (§3.3): body/path `peer_id` mismatch, oversized and
  empty `id`, oversized and empty `foreign_tenant_id`, `type` violating
  `[a-z0-9._/-]`, and non-object `data`.
- `approval_required` requiredness and the escalate-only policy floor:
  `flagged`/`command` gate the signal even when the peer sends `false`, and a
  routine event with `false` is not escalated.

Each case is correctly signed against a peer with an active binding, and each
test carries a positive control, so a 400 is attributable to the rule under
test rather than to a missing binding or bad signature.

No production change. `approval_required` is left a bare `bool`: contract v1
§3.3, its divergence note, and Appendix A #2 all freeze it as required with no
default, and the contract is frozen.

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

The #20 agent correctly refused to add #[serde(default)] because the v1 doc
frozen hours earlier pinned the field as required in three places. Resolving
that properly rather than letting code and contract diverge.

Ruling: the field becomes optional, defaulting false, and the contract is
amended in the same commit.

Rationale. The ingress policy floor is escalate-only
(webhook_ingress.rs:66-67: env.approval_required || severity in
{Flagged, Command}), so an absent field is exactly equivalent to an explicit
`false` -- a value a peer may always send. Requiring it therefore bought no
safety while costing peers a bare 400 webhook_rejected carrying no message,
since v1 section 7.2 deliberately forbids one. That is undiagnosable from the
peer side and TerraYield's sender would have hit it first. The contract was
frozen today, before any peer implemented against it, so this is a
pre-adoption correction, not a v2 break.

Also clarifies a latent ambiguity the agent found: section 3.4 collapsed
malformed-signature and failed-verification into one "Signature valid -> 401"
row. Actual behavior is 400 for a header that does not parse (webhooks.rs:174-195)
and 401 only for a well-formed header whose digest fails (webhooks.rs:205-213).
Documentation only, no code change.

The agent's policy-floor assertions are preserved and extended: an omitted
field on a `flagged` event must still escalate, so `absent` is not a back door
around the floor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Decouples the peer-rendering work from TerraYield by supplying a peer we
control, and gives any candidate peer a way to validate contract v1 without
a running IONe.

- tests/support/stub_peer.rs: a faithful minimal peer implementing all six
  surfaces of md/design/app-integration-contract-v1.md — MCP server over
  JSON-RPC with a two-page tools/list, a canned resource per ione_view
  (map/chart/table/document) plus their resources/read bodies, slice://,
  whoami://, a real OAuth 2.1 AS with PKCE S256 verification, and a signed
  webhook sender.
- src/bin/ione-conformance.rs: pass/fail-per-surface checker a candidate peer
  runs against its own endpoint. Talks to no IONe deployment, imports nothing
  from the ione crate, adds no dependency, exits 1 on any failure and 2 on bad
  usage. Failures are phrased as what IONe would do — drop the resource, report
  502, truncate the slice.
- tests/stub_peer_conformance_integration.rs: IONe renders all four of the
  stub's views end to end from view-hint metadata alone, reads its chart and
  table bodies, binds its whoami identity, surfaces its peer-authored slice,
  follows its tools/list pagination, and accepts its signed webhook — with no
  stub-specific code in IONe. Also runs the kit against the stub and asserts
  six PASSes.
- md/design/peer-conformance-kit.md: usage, per-surface coverage, the rationale
  for a bin over a script, and two frozen-contract divergences found while
  building this (F1 nextCursor: null loops IONe to its 50-page cap; F2 the
  webhook ack emits signal_ids where §3.5 documents signalIds).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by the stub-peer/conformance-kit agent (finding F2).

WebhookAckResponse derived Serialize with no rename_all, so the 200 ack
emitted `signal_ids` while v1 section 3.5 documents `signalIds` and every
other peer-facing payload in the API is camelCase (peerId, foreignTenantId,
webhookUrl, signingSecret). `ok` and `duplicate` are single words, which is
why the divergence was easy to miss.

The frozen contract wins, and no consumer depended on the snake_case key --
grep over src/, static/ and tests/ found only Rust-side field names.

Pinned by webhook_ack_uses_the_camel_case_key_from_the_contract, which asserts
both that signalIds is present and that signal_ids is absent, and that a
duplicate ack omits the key rather than sending it null.

Also wires stub_peer_conformance_integration into the CI suite loop.

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

IONe's outbound MCP client only interoperated with servers that answered the
way IONe's own server does. Close the "works against any spec-conforming MCP
server" clause of issue #15.

Transport (src/services/peer_tokens.rs):
- POSTs now advertise `Accept: application/json, text/event-stream` and carry
  `MCP-Protocol-Version`, applied in `send_with_token` so every outbound caller
  gets them, not just the paths touched here.
- `read_jsonrpc_reply` parses both response framings. SSE-framed POST replies
  are selected by request id, so a server-initiated request or notification
  sharing the stream is never mis-attributed to the call in flight.
- SSE `data:` framing is decoded in one place (`sse_data_payload`), reused by
  the long-lived notification stream instead of a second hand-rolled parser.
- `next_request_id` allocates a unique JSON-RPC id per request; replies whose id
  does not match are refused. A null-id error reply is still surfaced, per the
  JSON-RPC rule for requests whose id could not be determined.
- `send_initialized_notification` sends the `notifications/initialized`
  lifecycle step the spec requires after `initialize`. Best-effort: a peer that
  rejects it (IONe's own server answers -32601) must not fail the handshake.

Pagination (src/services/federation.rs):
- `nextCursor: null` terminates pagination. `Value::get` returns
  `Some(Value::Null)` for a present-but-null key, so the old check treated
  "explicitly finished" as "here is another cursor" and re-requested the last
  page to the 50-page cap — 50x request amplification and a duplicated manifest
  against a peer doing exactly what contract-v1 §8.1 permits. Empty-string
  cursors are terminal for the same reason.

Session lifecycle (src/connectors/peer_session.rs, src/lib.rs):
- `start_sessions_for_active_peers` runs at boot beside `hydrate_manifest_cache`,
  so peer SSE sessions survive a restart instead of waiting for an operator to
  hit the reconnect endpoint. `PeerSessionRegistry::start` previously had no
  boot-time caller at all.

Backward compatibility is preserved: plain-JSON POST replies keep working
(covered by `plain_json_replies_still_round_trip` and the existing two-node
federation test), the `Authorization: Bearer` contract and the #19
`resolve_access_token` precedence chain are untouched, and the TT-A06
fail-closed poll scope is unchanged.

Test fixtures: three peer mocks in phase11_mcp_server and integration_peer_manifest
answered with a hardcoded `"id": 1` regardless of the request id, which JSON-RPC
2.0 forbids of any server. They passed only while the process-global id counter
happened to sit at 1. Replaced with an `EchoJsonRpcId` responder that echoes the
request id; no assertion was changed, weakened, or skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e, peer) delegated tokens (#12)

Closes the three gaps that blocked issue #12's acceptance criteria. S0-S7 of
md/design/identity-broker.md were already implemented (commit 015b252); this is
the remainder.

GAP A — brokered SaaS OAuth was a stub.
`POST /api/v1/broker/connections/:id/refresh` wrote an unconditional
`token_broker_refresh` success row and returned 204 without contacting the
provider. It now performs the refresh-grant exchange, re-encrypts with
`encrypt_versioned`, persists the new material, and audits the true outcome. A
provider failure returns 502 `broker_upstream`, audits `outcome = "failure"`
with a reason, and leaves the stored token untouched.
`DELETE /api/v1/broker/connections/:id` now attempts RFC 7009 revocation at the
provider before deleting the local row, emitting the already-defined
`TokenBrokerRevokeUpstreamFailed` event when that fails. Per the design doc, an
upstream failure does not block local deletion — the audit row is the record
that a credential may still be live upstream. The provider registry stays a
single env-driven `generic-test` entry; only `IONE_TEST_REVOKE_URL` is added.

GAP B — delegated tokens are now stored per (workspace, peer).
New `workspace_peer_delegations` + `workspace_peer_delegation_pending`
(migration 0048), a delegation dance under
`POST /api/v1/workspaces/:id/peers/:peerId/delegation` +
`GET /auth/broker/peer-callback`, and refresh-on-expiry at the peer's token
endpoint. Precedence is now: workspace delegation > peer-global OAuth >
per-(workspace, peer) static credential (#19) > IONE_OAUTH_STATIC_BEARER.
Additive and backward compatible: a peer with no delegation resolves exactly as
before. The `Authorization: Bearer <token>` wire contract is unchanged.

GAP C — RLS policies are inert, and are now documented and tested as such.
The application role owns every RLS-enabled table, no table FORCEs RLS, the
role holds BYPASSRLS, and nothing sets `app.current_org_id`. Setting the
variable alone would change nothing, so AC-15 is marked NOT SATISFIED with a
named follow-up rather than papered over. A regression test pins the current
state and fails if any of those three facts changes.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g, expiry (#18)

Closes the five real gaps behind issue #18's acceptance. The no-centralization
invariant is unchanged: no tile, document, or payload is proxied, cached, or
re-hosted by IONe.

GAP 1 — `map_layers::extract_map_layer` accepted any non-empty string as
`tile_url`, including `javascript:` and plaintext `http:`, and handed it
straight to MapLibre. It now runs the same https-only + SSRF check the document
path runs on `download_url`, dropping just the offending layer with a `warn!`
(partial-success convention). `vector_url` is validated identically and stripped
rather than dropped with its layer — contract v1 §4.2 freezes it as an accepted
optional pass-through, so removing the field would contradict the contract while
leaving it unvalidated would hand a future consumer an unchecked peer URL.

GAP 2 — expired signed-URL handling now exists, and is "re-request the ref, not
cache the object": a ref older than 4 min (inside the contract's ≥5 min peer
guarantee) is refreshed before it is embedded or followed, and an embed failure
re-requests the ref exactly once. A ref that comes back identical is a render
failure, not an expiry, and degrades to the existing inline-fallback; a ref the
peer no longer lists is reported gone. No object is ever fetched by IONe.

GAP 3 — the four panel `resources/list` paths follow `nextCursor` per contract
§8.1 via one shared `services::peer_panels::list_peer_resources`, including the
`nextCursor: null` termination case. `chart_data` gains the 2 MiB cap the
contract documents but never enforced, mapped to 413 like the table path.

GAP 4 — a full map+chart+table+document fan-out is driven against a fixture peer
whose payloads carry sentinels, then every base table in the public schema is
row-text scanned. Sentinels live only in payload bodies (tile URL, document URL,
chart values, table cells), never in resource names/URIs, which the manifest
path legitimately persists. A positive control proves the scan is not vacuous.

GAP 5 — the six panel/data call sites and both binding call sites now read
replies through `peer_tokens::read_jsonrpc_reply`, so an SSE-framed reply from a
spec-conforming peer no longer breaks every panel, and the reply is correlated
to the request id.

No render cache was added: the live per-request fan-out already holds no peer
payload beyond the response it is serving, so a cache would create the exact
retention the acceptance forbids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n, zero-persistence proof

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Issue #18 enforced three things the frozen doc still described as unenforced.
A contract that understates the code is as wrong as one that overstates it --
a peer reading section 8.2 would still believe only page 1 of resources/list is
rendered.

- Section 4.3 / 8.3: chart resources/read is now capped at 2 MiB -> 413,
  matching the table path. Was "Specified, NOT enforced".
- Section 8.2: all four panel paths now follow nextCursor via peer_panels.rs,
  subject to the same 50-page ceiling as the manifest path. Was "Specified,
  NOT enforced on these paths". Also records that absent/null/empty cursors all
  terminate identically -- load-bearing, since a present-but-null cursor caused
  a 50x request-amplification loop against a conforming peer.
- Appendix A rows 6 and 7 marked resolved with what changed.

All additive under the section 9 compatibility rule: a peer written against the
freeze-time text stays conformant.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mortonanalytics and others added 11 commits July 25, 2026 15:54
…rigin discovery, port-aware names, paginated review manifest

F2 (INTEROP). `PeerDiscovery::registration_endpoint` was a non-optional
`String`, so a peer publishing exactly contract §2's endpoint table failed
the join with `400 "invalid peer metadata"` before any authorization step.
It is now `Option<String>`. Precedence: register when the peer offers a
registration endpoint (CIMD body if it advertises CIMD, RFC 7591 otherwise),
and present IONe's own `/.well-known/mcp-client` URL as the `client_id` only
when there is no registration endpoint. CIMD is the fallback rather than the
preference because a peer publishing both — IONe's own authorization server
does — resolves `client_id` against its registered-client table and would
reject a bare metadata URL. A peer offering neither is refused with a message
naming both fields and the metadata URL it would have to accept.

`issuer` and `revocation_endpoint` are now deserialized and host-validated
with the other endpoints, so a tampered document cannot claim a foreign
issuer or point a future revoke elsewhere.

F3 (INTEROP). Discovery was fetched at `{mcp_url}/.well-known/…`; RFC 8414 §3
puts it at the origin. `fetch_peer_discovery` resolves the origin first and
falls back to the MCP-path location with a `warn!`, because existing peers and
fixtures serve it there. Both call sites (begin_federation, the OAuth callback)
now go through it.

F4 (BUG). `peer_name_from_url` dropped the port, so two peers behind one
hostname collided on `peers_org_id_name_key UNIQUE (org_id, name)`. The port
is included when present and non-default (`Url::port()`), so no peer already
registered on a default-port URL is renamed. `map_peer_registration_error`
now matches on anyhow's full error chain: `PeerRepo::insert` wraps the sqlx
error in `.context("failed to insert peer")`, so a genuine duplicate was
being reported as a 500 rather than "peer URL already registered".

F10a. `fetch_manifest_over_mcp` — the allowlist-review manifest — issued one
un-cursored `tools/list` and read page 1, and swallowed a JSON-RPC error into
`200 {"tools": []}`. It now follows `nextCursor` under the same 50-page cap as
the other list paths, reusing `peer_panels::next_cursor` rather than adding a
third copy of the rule, and surfaces a JSON-RPC error as an error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… 8414 origin discovery, port in peer name, paginated manifest

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e tools/list, keep slices honest

F1 (security): `peers.tool_allowlist` was written by
`POST /api/v1/peers/:id/authorize` — the call that promotes a peer to Active —
and then read in exactly one place, `services/delivery.rs`, which gates IONe's
*outbound* `propose_artifact`. The federated invocation path gated on an Active
binding, the `tool_invoke` grant and manifest membership, and never consulted
it, so a narrow allowlist restricted nothing a granted caller could invoke.

The allowlist is now an additional constraint on three paths:
`route_tool_call_with_session` (after the grant, before the manifest fetch, so
a denial costs no outbound round trip), `execute_pending_tool_call` (an
approval is consent to run one call, not to widen the authorized set — the
allowlist is re-read at execution so a narrowing takes effect), and
`invoke_peer_tool`, the funnel every federated `tools/call` passes through.
Denials emit `outcome=deny` / `code=tool_not_allowlisted` on the interaction
path and a `peer_tool_blocked` audit row on the approved path.

An EMPTY allowlist means "not configured", not "nothing allowed". The column
is `JSONB NOT NULL DEFAULT '[]'`, so nothing distinguishes the default from an
operator who authorized zero tools; fail-closed needs a schema change and is
recorded as such in `tool_is_allowlisted` and pinned by a test.

F8: `send_jsonrpc` initialized reactively on every call and discarded the
returned `MCP-Session-Id`, so against a session-enforcing peer each call cost
a failed request, an `initialize`, a `notifications/initialized` and a retry —
and abandoned another server-side session. Session ids are now cached per
`PeerCacheKey` (peer + credential scope, because a conforming server binds the
session to the principal that authenticated) and reused. HTTP 404 — the
streamable-HTTP signal for an expired session, which the old error text did
not match — now triggers exactly one re-handshake. Expired ids are released
with `DELETE`; the operator-driven teardown point (peer revoke/delete) lives
in the peers route handler and is not touched here.

F9: `expand_tool_schema` had zero callers and issued `tools/get`, which is not
an MCP method. Deleted.

F10b: `mcp_client::default_streams` read page one of `tools/list` and stopped,
so a readable tool on page two got no derived stream. It now follows
`nextCursor`, reusing `federation::next_cursor` and its page cap rather than
adding a third copy of the semantics.

F11: `peer_authored_slice` turned an unparsable or absent `contents[0].text`
into `Ok({})`, which `build_catalog_upsert` then treated as authoritative — one
truncated body wiped every catalog row's `sample_queries` and flipped its
`content_hash`. Unusable and absent bodies are now `Err`, so the caller keeps
what is indexed; a valid `{}` stays authoritative and still clears them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ion cache, dead code, pagination, slice validity

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes F1. The federation agent enforced peers.tool_allowlist on every
federated invocation path but could NOT fail closed on an empty list, because
tool_allowlist is JSONB NOT NULL DEFAULT '[]' -- there was no value separating
'the operator authorized zero tools' from 'this row never went through the
authorize route'. It reported that instead of weakening the ~8 suites that seed
'[]' directly. This adds the missing sentinel.

migrations/0049 adds peers.tool_allowlist_configured, set true by
PeerRepo::set_allowlist (the only writer, called from the authorize route).
tool_is_allowlisted now denies when configured AND empty, and falls through when
not configured, so directly-seeded peers keep working. Existing rows backfill
false deliberately -- marking them configured would silently deny every
already-federated peer whose allowlist is '['].

This also aligns the federated path with delivery.rs, which has always been
fail-closed on empty; the two previously disagreed.

Found while wiring it: the column had to be added to THREE hand-written peers
column lists (PeerRepo::PEER_COLUMNS, list_active_peers_for_workspace, and
federation::peer_by_prefix). Because Peer marks newer fields #[sqlx(default)],
a missed list does not error -- it yields false, which reads as 'not configured'
and silently disables the gate. Recorded on peer_by_prefix so the next person
adding a column does not lose the same cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tool_allowlist_integration, mcp_session_integration,
peer_join_followups_integration and whoami_and_error_mapping_integration were
all orphaned -- the exact hole this branch opened by fixing. Audited: every
tests/*.rs now appears in the workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The eleven org-scoped tables have carried `USING (org_id =
current_setting('app.current_org_id', true)::uuid)` policies since migration
0019, and none of them ever filtered a row. Three independent causes; two are
fixed here.

Migration 0050 sets FORCE ROW LEVEL SECURITY on all eleven tables and creates
`ione_app` — LOGIN, NOSUPERUSER, NOBYPASSRLS, owning nothing, granted only DML
plus schema/sequence USAGE, with matching ALTER DEFAULT PRIVILEGES. Role
creation is guarded by an IF NOT EXISTS block because the role is cluster-scoped
and every developer database runs the same migration.

`src/rls.rs::org_scoped_tx` supplies the third piece: a transaction that runs
`set_config('app.current_org_id', $1, true)` with the org id bound as a
parameter. Transaction scope is mandatory — sqlx runs on pooled connections, so
a session-scoped SET would leak one request's org onto the next.

Migrated to it: WorkspacePeerCredentialRepo::{get,list_for_workspace,delete},
WorkspacePeerDelegationRepo::{get,delete}, and BrokerCredentialRepo::
{create_pending,list_for_user,find_for_user,store_tokens,delete}. The last four
gained an `org_id` parameter and an `AND org_id = $n` predicate; all call sites
are in routes/broker.rs and already had `ctx.org_id` in hand.

DATABASE_URL, docker-compose, .env.example, and CI are untouched: they still
connect as `ione`, which is SUPERUSER and bypasses row security regardless of
FORCE, so every existing suite behaves exactly as before. `ione_app` is opt-in
and not yet a supported runtime role for the whole binary — the unmigrated
repositories would fail closed under it.

tests/rls_enforcement_integration.rs proves enforcement by opening a second pool
as `ione_app`, pinning the context to org A, and reading broker_credentials with
no org predicate: org B is absent, while the same unfiltered read as `ione`
still returns both. The existing inertness test is rewritten to pin what remains
true — the default role bypasses because it is SUPERUSER — instead of the FORCE
and ownership facts this change fixed.

md/design/identity-broker.md now states AC-15 precisely: covered methods listed,
uncovered methods listed by name, and the default-connection gap called out.

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

Three documents still described RLS as dormant after migration 0050 made it
enforceable under the restricted role:
- migrations/0048 header comment
- md/design/headless-provisioning.md HP-M4, which now states precisely that
  service_account_tokens is NOT among the migrated repos, so app-layer scoping
  is still the only guard for that design's table
- md/design/identity-broker.md line 39, which referred to a 'v0.2 RLS sweep';
  reworded to avoid committing to a version, per the pre-v1 release posture

Each now states the coverage boundary rather than a blanket claim in either
direction.

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

My previous commit amended the header comment of migration 0048, which was
already applied. sqlx checksums applied migrations, so 'sqlx migrate run' then
failed with 'migration 48 was previously applied but has been modified' -- and
because job2-migrate runs first, every one of the 57 integration suites failed
behind it. It would have done the same to CI and to every existing deployment.

Applied migrations are immutable. I gave the RLS agent exactly that instruction
('new migration only -- do not edit existing ones') and then broke it myself.

0048 restored byte-for-byte. The explanatory note now lives in 0050, which is
new and unapplied, along with a warning for the next reader that the earlier
migrations' 'RLS is inert' comments are deliberately left stale for this reason.

Verified: all 50 migrations apply cleanly to a pristine database, and 0050's
idempotency guard skips role creation when ione_app already exists cluster-wide.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
F3 made the JOIN path try the RFC 8414 origin location before the legacy
{mcp_url}/.well-known path, but two refresh-time call sites still built the
legacy URL directly:
  - src/services/peer_delegation.rs::discover (delegated-token refresh)
  - src/services/peer_tokens.rs::discover_peer (peer token refresh)

So a spec-conforming peer that publishes metadata only at its origin could
complete the join and then fail every refresh afterwards -- a failure that shows
up hours later, not at onboarding, which is the worst time to find it.

peer_delegation now delegates to peer_oauth::fetch_peer_discovery. peer_tokens
takes a bare reqwest::Client rather than AppState, so it gets the same
origin-then-legacy order inline, sharing origin_discovery_url and warning on a
legacy hit exactly as the join path does.

Also:
- scripts/check-contract-citations.py promoted out of scratch into the repo and
  wired into CI job 1. It reports 39 citations resolved, 0 past EOF, 0
  ambiguous, and fails the build otherwise -- two citations previously pointed
  past end of file and eleven were ambiguous between src/routes/ and
  src/services/.
- foreign-tenant-mapping.md said whoami has an 8 s timeout. It never did; the
  contract already contradicted it. Corrected to 3 s and noted that the refresh
  path had no per-call timeout at all until this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mortonanalytics
mortonanalytics merged commit d38683a into main Jul 26, 2026
4 checks passed
@mortonanalytics
mortonanalytics deleted the feat/federation-peer-track branch July 26, 2026 00:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Identity broker (S0–S7): gate for TerraYield brokered SSO

1 participant