Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CHANGELOG.md

Large diffs are not rendered by default.

24 changes: 24 additions & 0 deletions agent/lambda/inventory_read_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ def _inventory_stale_after_minutes(env=None):
"account/region fallbacks. identityProvenance is always telemetry_claim, even when an ARN "
"names the host; queues never bridge into inventory. Shared destination ARNs join across "
"callers only within datasource/environment; the same ARN can have separate nodes in each scope."
" Runtime and requested-model identities are also telemetry claims, never inventory ownership "
"or proof of successful inference. Runtime qualifiers come only from a valid runtime ARN; "
"requested models have null account/region claims. A hosts edge is reported runtime context, "
"not a traced request. The requested model/endpoint does not prove the provider behind a Gateway."
)

MATERIALIZED_TOPOLOGY_NOTE = (
Expand Down Expand Up @@ -190,6 +194,23 @@ def build_topology_chain(by_type, root=None):
return chains


def _inference_claim_meta(kind, meta):
"""Typed, bounded read projection; never trust stored ownership or provenance."""
fields = {"sourceId": 512, "environment": 512}
fields.update({"arn": 512} if kind == "runtime" else {"provider": 64, "model": 256, "endpoint": 253})
safe = {key: meta[key] for key, limit in fields.items()
if isinstance(meta.get(key), str) and len(meta[key]) <= limit}
port = meta.get("port")
if kind == "model" and type(port) in (int, float) and 1 <= port <= 65535 and port == int(port):
safe["port"] = port
arn = re.fullmatch(
r"arn:aws(?:-[a-z0-9-]+)?:bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime/[A-Za-z0-9_][A-Za-z0-9_-]{0,255}",
safe.get("arn", ""),
) if kind == "runtime" else None
return {**safe, "claimedAccountId": arn[2] if arn else None,
"claimedRegion": arn[1] if arn else None, "identityProvenance": "telemetry_claim"}


def _fetch_topology_graph(resource_id=None, cls="flow", limit=500):
"""Read the materialized topology graph from topology_nodes/edges (ADR-010[legacy 043]).

Expand Down Expand Up @@ -312,6 +333,9 @@ def _parse_meta(m):
"meta": _parse_meta(r.get("meta"))} for r in node_rows if r.get("id")]
if cls == "trace":
for node in nodes:
if node["kind"] in ("runtime", "model"):
node["meta"] = _inference_claim_meta(node["kind"], node["meta"])
continue
if node["kind"] != "queue":
continue
meta = node["meta"].copy()
Expand Down
29 changes: 28 additions & 1 deletion agent/lambda/test_inventory_read_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,7 @@ def setUpClass(cls):
cls._psql((migrations / name).read_text())
for suffix in ("_topology_inventory_evidence.sql", "_graph_attempt_disclosure.sql",
"_graph_projection_parity.sql", "_trace_queue_claim_provenance.sql",
"_graph_read_indexes.sql"):
"_graph_read_indexes.sql", "_trace_inference_claim_provenance.sql"):
for path in sorted(migrations.glob("*" + suffix)):
cls._psql(path.read_text())

Expand Down Expand Up @@ -1003,6 +1003,22 @@ def setUp(self):
def tearDown(self):
inv._execute_override = None

def test_inference_view_projection_preserves_only_typed_claim_evidence(self):
cases = json.loads((Path(__file__).resolve().parents[2]
/ "web/lib/fixtures/trace-inference-claims.json").read_text())
for index, case in enumerate(cases):
meta = json.dumps(case["meta"]).replace("'", "''")
self._psql(
"INSERT INTO public.topology_nodes(account_id,id,kind,label,meta,run_id,class) VALUES "
f"('self','claim-{index}','{case['kind']}','reported','{meta}'::jsonb,'test','trace');"
)
rows = self._execute("SELECT id,meta FROM topology_nodes ORDER BY id")
self.assertEqual([row["meta"] for row in rows], [case["expected"] for case in cases])
permissions = self._execute(
"SELECT has_table_privilege(current_user,'public.topology_nodes','SELECT') AS base_read"
)
self.assertFalse(permissions[0]["base_read"])

def _execute(self, sql, params=None):
# Translate the Data API's named scalar binds into PostgreSQL PREPARE binds.
# JSON ID sets remain scalar string binds. This shim verifies SQL/view semantics;
Expand Down Expand Up @@ -1340,6 +1356,17 @@ def _state(self, status="ok", details=None, captured_at=CAPTURED):
return {"status": status, "attempted_at": self.ATTEMPTED, "captured_at": captured_at,
"details": {"sources": [], "retainedPrevious": False} if details is None else details}

def test_inference_claim_projection_matches_web_contract(self):
cases = json.loads((Path(__file__).resolve().parents[2]
/ "web/lib/fixtures/trace-inference-claims.json").read_text())
for case in cases:
with self.subTest(case=case["name"]):
body, _ = self._read(self._state(), nodes=[{
"id": "claim:one", "kind": case["kind"], "label": "reported target", "meta": case["meta"],
}])
self.assertEqual(body["nodes"][0]["meta"], case["expected"])
self.assertIn("Runtime and requested-model identities", body["note"])

def test_queue_arn_claims_remain_unverified_including_before_projection_migration(self):
cases = json.loads((Path(__file__).resolve().parents[2]
/ "web/lib/fixtures/trace-queue-claims.json").read_text())
Expand Down
34 changes: 32 additions & 2 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,14 +565,44 @@ vetoes binding). Generated or custom templates, Tempo and other sources keep spa
graph mixing them sums both kinds of count.

Publication keeps at most 200 nodes and 500 edges (`nodeDrops`/`edgeDrops`). Nodes are selected
from the heaviest edges first, both endpoints together, so a kept database, queue or workload
always keeps its caller; remaining capacity goes to other nodes by span and metric volume.
from the heaviest edges first, both endpoints together. A kept database, queue, workload,
runtime or model relationship keeps its caller; a `hosts` relationship keeps both its runtime
and reporting service. Remaining capacity goes to other nodes by span and metric volume.

Service-graph metrics contribute service→service `calls` only. Rows whose Tempo
`connection_type` is `database`, `messaging_system` or `virtual_node` name a database, queue or
uninstrumented peer rather than a service and are not drawn; databases and queues come from trace
spans with their host-qualified identity.

### Trace inference claims

The trace graph can also return `runtime` and `model` nodes from explicit span
attributes. A client span with a valid AgentCore runtime ARN produces a `calls`
relationship to that runtime. A reporting service with a matching runtime resource
ARN and non-conflicting account/region produces `runtime → service` with `rel=hosts`.
That relationship is reported execution context, not a traced request. An explicit
provider and requested model on a client span produces a requested-model `calls`
relationship; a valid runtime invocation does not also invent a direct model call.

Runtime claims join only by exact ARN within source/environment. Model claims
retain source/account/region/environment/cluster/namespace/port isolation; an
unknown endpoint is also caller-local. Provider names are normalized to lowercase.
Neither relationship proves successful inference or the final provider behind a
Gateway, and capped publications still disclose partial coverage.

HTTP, MCP and the current SQL-reader view force
`identityProvenance=telemetry_claim` for these kinds, including retained rows.
Only bounded typed `sourceId`, `environment`, runtime `arn`, or model
`provider`/`model`/`endpoint` and a valid numeric `port` survive. Runtime
`claimedAccountId`/`claimedRegion` are derived from a valid ARN; models and invalid
runtime ARNs have null qualifiers. Stored ownership/provenance, `infra_ref`, raw
provider blobs and prompt/response content are excluded.

The SQL-reader projection requires the standalone migration described in the
[inference projection runbook](runbooks/agent-sql-reader.md#trace-inference-projection).
Until that migration is applied, the agent may have the node kind/label but lack
its identity fields. A source merge is not deployment proof.

### Trace edge instance observations

Trace `queries` edges (service → `db`) may carry three additive, optional `meta` fields built by
Expand Down
42 changes: 32 additions & 10 deletions docs/runbooks/agent-sql-reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,9 @@ baseline and this owner; audit the allowlist from this file, not the baseline.
### Current topology evidence contract

The current owner of `sql_reader.topology_nodes.meta` is
[`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql`](../../terraform/foundation/migrations/01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql).
It selects named JSON keys with type checks for telemetry fields and a trace-queue
exception. The current materialized flow writer persists `ownership_evidence` and
[`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`](../../terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql).
It preserves the prior queue/non-claim projection and adds bounded typed runtime/model
claim projections. The current materialized flow writer persists `ownership_evidence` and
`targetCapturedAt` on **target nodes**, with VPC/subnet or ambiguity metadata where
applicable. `targetCapturedAt` dates only the target-group inventory row; it does not
date the independent task/subnet/pod evidence or establish current ownership. Host ECS
Expand Down Expand Up @@ -365,6 +365,10 @@ The projection is not an ownership validator:
destination-ARN-derived `claimedAccountId`/`claimedRegion`. Queue `accountId`,
`region` and `infra_ref` are removed. Neither parsed ARN syntax nor a storage
partition's `account_id` proves telemetry ownership.
- Trace runtime/model nodes always expose `identityProvenance='telemetry_claim'`.
Runtime account/region qualifiers derive only from a valid runtime ARN; model
qualifiers remain null. Both kinds exclude stored ownership, inventory references
and raw provider content.

The node's exposed `captured_at` is graph materialization time, not its underlying
inventory capture or observation time. For collection quality, consult
Expand All @@ -378,13 +382,14 @@ raw provider JSON or widen grants. Computed `metadataTruncated` discloses omitte
`agent/lambda/test_inventory_view_contract.py` still reads the original
`01KYVY9J2E8AMF35WR4J7036A3_agent_sql_reader_role.sql` for its topology assertions.
Those baseline text assertions do **not** enforce the current topology projection.
Inspect the current migration and the queue/view cases in
`scripts/v2/workers/test_graph_collection.py`; this clarification does not retarget
tests or claim a fresh PostgreSQL execution. The current collection-view projection and
Inspect the current migration and shared inference fixtures in
`agent/lambda/test_inventory_read_mcp.py::TestTopologySelectionSQL`.
`scripts/v2/workers/test_graph_collection.py` covers the historical queue/collection
projections, not the latest inference view owner. The current collection-view projection and
API repeatable-read/timeout/cap behavior are independently covered by
`web/lib/graph-read-postgres.test.ts`; the Python reader's actual canonical/raw-ID,
neighbourhood and truncation SQL is covered by `TestTopologySelectionSQL` under the
current collection, queue-provenance and read-index migrations. See
current collection, queue-provenance, inference-provenance and read-index migrations. See
[local test prerequisites](graph-read-contract.md#reader-postgresql-verification).
Its `selection`, `truncation`, `readOutcome` and `snapshotConsistent` envelope is
reader-specific and does not assert HTTP transaction parity. `readOutcome` describes
Expand All @@ -393,21 +398,38 @@ state-read failure or detected publication change without extending the stored
multi-query snapshot. The internal writer scheduling clock `lastSourceAttemptedAtMs`
stays outside the projection and is covered by `web/lib/graph-reader-privacy.test.ts`.
Apply the new collection projection with the existing `make migrate` operator flow;
the queue projection remains separately owned by the migration above.
the node projection is owned by the inference migration above, preserving queue semantics.

### Trace queue projection / 트레이스 큐 투영

`01M279W0J9HNG1QT0MAS60KV8K_topology_graph_collection_state.sql` introduces graph evidence;
`01M2HM8BR5ZC0JZWGQ9ZFV1WT2_graph_projection_parity.sql` is the current collection-state projection owner, adding bounded inventory source clocks/provenance;
`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` supersedes its node projection.
`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` introduced the queue projection;
the current inference migration preserves that branch.
After `make migrate`, spot-check `sql_reader.topology_nodes` where `class='trace' AND kind='queue'`:
`claimedAccountId/claimedRegion` must match only parsed destination ARN qualifiers, including
retained rows; non-ARN/malformed destinations have null claims and no reporter fallback.
`identityProvenance` is always `telemetry_claim`; `accountId`, `region`, `infra_ref` and whole-row
copies stay absent for trace queues. SELECT is granted only on the view, never the base table.
`scripts/v2/workers/test_graph_collection.py` exercises this contract on disposable PostgreSQL,
`scripts/v2/workers/test_graph_collection.py` exercises the historical queue projection on disposable PostgreSQL,
including grant restoration, reapplication, denied base-table reads and denied view writes.

### Trace inference projection

Apply `01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql` through
the reviewed standalone migration procedure before releasing the matching web
and inventory-reader Lambda. `CREATE OR REPLACE VIEW` and `GRANT` are outside the
automatic web migration subset; do not add a bypass or change historical headers.
This preserves the existing view columns and SELECT grant and adds no base-table access.

For `class='trace' AND kind IN ('runtime','model')`, spot-check the named-field
projection, forced provenance and null model qualifiers. Malformed runtime ARNs
must not inherit stored or caller account/region. The shared
`web/lib/fixtures/trace-inference-claims.json` cases cover HTTP, MCP and the actual
PostgreSQL view under `awsops_sql_reader` in `TestTopologySelectionSQL`, including
continued denial of `public.topology_nodes` reads. Inference identity is still
unavailable through an older view even when the Lambda adds a provenance note.

Trace edge instance observations (`instance_observations[]`, `instance_observations_omitted`,
`instance_observations_unbound`; see [API reference](../api-reference.md#trace-edge-instance-observations))
are not part of the agent evidence contract: `sql_reader.topology_edges` projects only numeric
Expand Down
43 changes: 43 additions & 0 deletions docs/runbooks/graph-read-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,49 @@

Missing graph clocks can mean legacy rows without collection state; missing metadata is unknown, not collection failure. A stale publication can have a newer attempt or failed producer. A busy/failed read describes the API, not a collection outcome.

### Missing inference dependencies

Check that the application's telemetry store is registered and has a ready graph
query before interpreting an absent service. The standard ClickHouse adapter
aggregates the whole bounded window; a deployment still using the older raw-span
adapter can lose low-volume calls behind health traffic.

Outbound runtime/model calls also require a `CLIENT` span kind. A table without
`SpanKind` leaves that classification unknown and cannot establish those outgoing
calls; resource-side `hosts` observations may still be present.

Client spans with a valid `agentcore.runtime.arn` create a reported runtime
destination even without a server span. A reporting service with the same valid
runtime resource ARN can connect through a `hosts` relationship when its account
and region do not conflict. These joins remain isolated by source and environment.
Explicit `gen_ai.provider.name`/`gen_ai.system`, requested model and endpoint
attributes describe a requested model dependency. They do not prove successful
inference, the final provider behind a Gateway, or an executing model's region.
Unknown endpoints remain caller-local. Runtime/model cards label the identity as
an unverified telemetry claim; no inventory ownership follows from it.

Producers must supply Kubernetes resource identity for Pod correlation and
runtime resource identity for AgentCore. Do not assign Kubernetes identity to
every span received by a shared collector. No prompts, completions or request
bodies are required by the inference graph mapper.

Verify from `web/`:

```bash
npx vitest run lib/inference-topology.test.ts lib/trace-source.test.ts app/topology/services/page.test.tsx
```

After the reviewed deployment, inspect the registered source, fresh collection
state and one approved synthetic request. A keep-alive HTTP 200 is not model
execution proof.

The matching SQL-reader projection is delivered by
`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`. Run it through
the reviewed standalone migration path before web/Lambda release; views and
grants are not admitted by automatic web migrations. The MCP and HTTP readers
also project retained runtime/model claims at read time, preserving forced
provenance and excluding stored ownership or raw provider content.

### Placement and live VPC connections

`/topology/infra` defaults to the persisted placement view from
Expand Down
21 changes: 21 additions & 0 deletions docs/runbooks/steampipe-quota-and-staleness.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
# Runbook — Steampipe 쿼터 및 인벤토리 신선도 / Steampipe Quota and Inventory Staleness

## S3 location-read denials

The S3 inventory and public-access collectors use paginated `ListBuckets`
`BucketRegion` metadata when present. A denied `GetBucketLocation` therefore does
not erase region evidence that the authorized bucket listing already supplied.
Responses without valid list-region metadata still use the location read; a
failed fallback remains incomplete. The collectors finish enumeration before
returning rows, reject repeated tokens/duplicate identities, and fail closed after
100 pages of at most 1,000 buckets.

Legacy `EU` location constraints normalize to `eu-west-1`. Old rows keyed by
`EU` or an unknown region can coexist with the corrected row while a collection
is incomplete; only a complete sweep authorizes the existing prune phases.

Denied security-property reads remain explicit unknown attributes. A completed
listing is not proof that bucket security was inspected, and release readiness
still rejects unknown attributes. Failed pagination cannot authorize pruning or
successful publication. This fix adds no IAM grant and does not modify bucket
policies. Ship the collector through the reviewed Terraform saved-plan procedure,
then inspect its terminal ledger and graph source qualification.

## Optional host scope / 선택적 호스트 범위

`INVENTORY_HOST_ONLY=true` requires `EXPECTED_HOST_ACCOUNT_ID` and exactly one enabled
Expand Down
Loading
Loading