aac-cli 0.2.6__tar.gz → 0.2.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {aac_cli-0.2.6 → aac_cli-0.2.8}/PKG-INFO +21 -3
  2. aac_cli-0.2.6/aac_cli.egg-info/PKG-INFO → aac_cli-0.2.8/README.md +14 -23
  3. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/__init__.py +1 -1
  4. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/cli.py +59 -17
  5. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/data/renderer-source.json +6 -4
  6. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/model.py +57 -0
  7. aac_cli-0.2.8/aac_cli/_aeg/provenance.py +194 -0
  8. aac_cli-0.2.8/aac_cli/_aeg/theme.py +136 -0
  9. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/visualizer.py +205 -267
  10. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/agent_cli.py +46 -5
  11. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/cli.py +23 -7
  12. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/config.py +13 -0
  13. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/profiles.py +6 -0
  14. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/reference.py +11 -1
  15. aac_cli-0.2.6/README.md → aac_cli-0.2.8/aac_cli.egg-info/PKG-INFO +41 -1
  16. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli.egg-info/SOURCES.txt +2 -0
  17. {aac_cli-0.2.6 → aac_cli-0.2.8}/pyproject.toml +9 -2
  18. {aac_cli-0.2.6 → aac_cli-0.2.8}/LICENSE +0 -0
  19. {aac_cli-0.2.6 → aac_cli-0.2.8}/MANIFEST.in +0 -0
  20. {aac_cli-0.2.6 → aac_cli-0.2.8}/NOTICE +0 -0
  21. {aac_cli-0.2.6 → aac_cli-0.2.8}/THIRD_PARTY_NOTICES.md +0 -0
  22. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/__main__.py +0 -0
  23. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/__init__.py +0 -0
  24. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/data/action-taken-v1.json +0 -0
  25. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/data/predicates-v1.json +0 -0
  26. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/from_sidecar.py +0 -0
  27. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/inputs.py +0 -0
  28. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/narrative.py +0 -0
  29. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/schemas.py +0 -0
  30. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/temporal.py +0 -0
  31. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/_aeg/timeline_source.py +0 -0
  32. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/admin_key_pem.py +0 -0
  33. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/aeg_cli.py +0 -0
  34. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/aeg_list.py +0 -0
  35. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/agent_config.py +0 -0
  36. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/agent_health.py +0 -0
  37. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/agent_layout.py +0 -0
  38. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/agent_record.py +0 -0
  39. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/api_key_rotation_state.py +0 -0
  40. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/chain_list.py +0 -0
  41. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/config_render.py +0 -0
  42. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/dev_material.py +0 -0
  43. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/idp_recovery.py +0 -0
  44. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/init_cli.py +0 -0
  45. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/material_cases.py +0 -0
  46. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/registration_state.py +0 -0
  47. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/secure_files.py +0 -0
  48. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/sso_login.py +0 -0
  49. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/supplied_material.py +0 -0
  50. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli/trace_query.py +0 -0
  51. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli.egg-info/dependency_links.txt +0 -0
  52. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli.egg-info/requires.txt +0 -0
  53. {aac_cli-0.2.6 → aac_cli-0.2.8}/aac_cli.egg-info/top_level.txt +0 -0
  54. {aac_cli-0.2.6 → aac_cli-0.2.8}/launchers/aac +0 -0
  55. {aac_cli-0.2.6 → aac_cli-0.2.8}/launchers/aeg +0 -0
  56. {aac_cli-0.2.6 → aac_cli-0.2.8}/setup.cfg +0 -0
@@ -1,10 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aac-cli
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
5
5
  Author: Agent Authority Cloud Project
6
6
  License-Expression: Apache-2.0
7
- Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/aeg.html
7
+ Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/cli/
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
8
13
  Requires-Python: >=3.11
9
14
  Description-Content-Type: text/markdown
10
15
  License-File: LICENSE
@@ -32,6 +37,10 @@ incoming ones against public trust material the tenant publishes.
32
37
  New to AAC? [What AAC is for](https://cascadeauth.github.io/aac-starter-guide/#what-aac-is-for)
33
38
  explains what delegated authority between agents buys you, and what it does not.
34
39
 
40
+ The [CLI user guide and complete command reference](https://cascadeauth.github.io/aac-starter-guide/cli/)
41
+ cover developer and enterprise onboarding, everyday administration and recovery.
42
+ Select documentation matching `aac --version`.
43
+
35
44
  The `aac` command is how a tenant's operator does everything that is not
36
45
  done by the sidecar itself: register the tenant, sign in, manage keys,
37
46
  workloads and domains, and prepare a runnable local workload. Everything
@@ -59,7 +68,7 @@ pip install aac-cli
59
68
  aac --version
60
69
  ```
61
70
 
62
- Python 3.11 or newer is required.
71
+ Python 3.11 to 3.14 is supported. Release checks test all four versions.
63
72
 
64
73
  > **Mind the name.** `pip install aac` installs an unrelated project that
65
74
  > happens to share the acronym. The AAC package is **`aac-cli`**; the
@@ -796,6 +805,15 @@ AAC.
796
805
 
797
806
  ## Output and exit codes
798
807
 
808
+ For scripts, `aac profile show NAME --field tenant-id` prints the effective
809
+ tenant ID (including `AAC_TENANT_ID` overrides), and
810
+ `aac tenant assign-hosted-domain --field trust-domain` prints the validated
811
+ server-returned domain. Existing agents expose read-only identities through
812
+ `aac agent status --agent NAME --field tenant-id`, `--field hosted-trust-domain`
813
+ or `--field workload-spiffe-id`. A successful scalar is one unquoted line;
814
+ an unavailable value or unhealthy agent fails with empty stdout. `--field`
815
+ cannot be combined with explicit `--output`, or with agent `--remote`.
816
+
799
817
  Every command that reports a result prints one JSON document to standard
800
818
  output by default, with progress notes and diagnostics on standard error,
801
819
  so the JSON stays parseable in scripts; `--output table` prints a readable
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: aac-cli
3
- Version: 0.2.6
4
- Summary: The aac platform CLI — headless operator interface to the AAC control plane (tenant registration, chain audit).
5
- Author: Agent Authority Cloud Project
6
- License-Expression: Apache-2.0
7
- Project-URL: Documentation, https://cascadeauth.github.io/aac-starter-guide/aeg.html
8
- Requires-Python: >=3.11
9
- Description-Content-Type: text/markdown
10
- License-File: LICENSE
11
- License-File: NOTICE
12
- License-File: THIRD_PARTY_NOTICES.md
13
- Requires-Dist: httpx>=0.28
14
- Requires-Dist: cryptography>=42.0
15
- Requires-Dist: PyYAML>=6.0
16
- Requires-Dist: pyvis<0.4,>=0.3.2
17
- Requires-Dist: jsonschema>=4.18
18
- Provides-Extra: test
19
- Requires-Dist: pytest>=8.0; extra == "test"
20
- Requires-Dist: pytest-httpx>=0.30; extra == "test"
21
- Dynamic: license-file
22
-
23
1
  # aac-cli — the `aac` command
24
2
 
25
3
  `aac` is the command-line tool for tenants of Agent Authority Cloud (AAC).
@@ -32,6 +10,10 @@ incoming ones against public trust material the tenant publishes.
32
10
  New to AAC? [What AAC is for](https://cascadeauth.github.io/aac-starter-guide/#what-aac-is-for)
33
11
  explains what delegated authority between agents buys you, and what it does not.
34
12
 
13
+ The [CLI user guide and complete command reference](https://cascadeauth.github.io/aac-starter-guide/cli/)
14
+ cover developer and enterprise onboarding, everyday administration and recovery.
15
+ Select documentation matching `aac --version`.
16
+
35
17
  The `aac` command is how a tenant's operator does everything that is not
36
18
  done by the sidecar itself: register the tenant, sign in, manage keys,
37
19
  workloads and domains, and prepare a runnable local workload. Everything
@@ -59,7 +41,7 @@ pip install aac-cli
59
41
  aac --version
60
42
  ```
61
43
 
62
- Python 3.11 or newer is required.
44
+ Python 3.11 to 3.14 is supported. Release checks test all four versions.
63
45
 
64
46
  > **Mind the name.** `pip install aac` installs an unrelated project that
65
47
  > happens to share the acronym. The AAC package is **`aac-cli`**; the
@@ -796,6 +778,15 @@ AAC.
796
778
 
797
779
  ## Output and exit codes
798
780
 
781
+ For scripts, `aac profile show NAME --field tenant-id` prints the effective
782
+ tenant ID (including `AAC_TENANT_ID` overrides), and
783
+ `aac tenant assign-hosted-domain --field trust-domain` prints the validated
784
+ server-returned domain. Existing agents expose read-only identities through
785
+ `aac agent status --agent NAME --field tenant-id`, `--field hosted-trust-domain`
786
+ or `--field workload-spiffe-id`. A successful scalar is one unquoted line;
787
+ an unavailable value or unhealthy agent fails with empty stdout. `--field`
788
+ cannot be combined with explicit `--output`, or with agent `--remote`.
789
+
799
790
  Every command that reports a result prints one JSON document to standard
800
791
  output by default, with progress notes and diagnostics on standard error,
801
792
  so the JSON stays parseable in scripts; `--output table` prints a readable
@@ -10,4 +10,4 @@ entrypoint (`aac_cli.cli:entrypoint`).
10
10
  # test in bin/tests/test_cli_version_parity.py. A literal keeps the
11
11
  # shipped code free of import-time metadata lookups (and their
12
12
  # not-installed failure mode).
13
- __version__ = "0.2.6"
13
+ __version__ = "0.2.8"
@@ -23,6 +23,7 @@ from .inputs import (
23
23
  unjoinable_diagnostics,
24
24
  )
25
25
  from .model import extract_token_relation_graph
26
+ from .provenance import SourceInput, summarize_sources
26
27
  from .visualizer import build_visual_aeg, write_html_aeg
27
28
 
28
29
  Query = Callable[[str, str], tuple[str, list]]
@@ -98,7 +99,7 @@ def _query(profile: str, root: str):
98
99
  raise InputError("online rendering requires the aeg command supplied by aac-cli")
99
100
 
100
101
 
101
- def _render(args, events, query: Query) -> int:
102
+ def _render(args, events, query: Query, sources: list[SourceInput]) -> int:
102
103
  if args.profile is not None and not args.profile.strip():
103
104
  raise InputError("--profile must name an existing AAC CLI profile")
104
105
  if not (args.events or args.actions or args.profile or args.trace_json):
@@ -117,6 +118,14 @@ def _render(args, events, query: Query) -> int:
117
118
  if args.trace_json:
118
119
  trace_root, trace_events = read_trace(read_json(args.trace_json), str(args.trace_json))
119
120
  events.extend(trace_events)
121
+ sources.append(
122
+ SourceInput(
123
+ "Control-plane trace data",
124
+ str(args.trace_json),
125
+ "Saved export; original fetch time/status unavailable",
126
+ trace_events,
127
+ )
128
+ )
120
129
  if root and root != trace_root:
121
130
  raise InputError("selected root does not match --trace-json root_token_id")
122
131
  if root is None:
@@ -138,13 +147,47 @@ def _render(args, events, query: Query) -> int:
138
147
  if queried_root != root:
139
148
  raise InputError("central response root does not match selected root")
140
149
  events.extend(central)
141
- central_status = "available; best-effort metadata, not a complete execution record"
150
+ sources.append(
151
+ SourceInput(
152
+ "Control-plane trace data",
153
+ f"Profile: {args.profile}",
154
+ "Live API query",
155
+ central,
156
+ "succeeded",
157
+ )
158
+ )
159
+ central_status = (
160
+ "Live API query succeeded"
161
+ if central
162
+ else "Live API query succeeded with no observations"
163
+ )
142
164
  except InputError as exc:
143
165
  query_failed = True
144
- central_status = str(exc)
145
- print(central_status, file=sys.stderr)
166
+ central_status = (
167
+ "Live API query failed; local graph is partial. See the command diagnostic."
168
+ )
169
+ # Preserve stable public codes without copying transport URLs/secrets.
170
+ for code in (
171
+ "ERR_TRACE_NOT_FOUND",
172
+ "ERR_API_KEY_INVALID",
173
+ "ERR_DATABASE_UNAVAILABLE",
174
+ "ERR_RATE_LIMITED",
175
+ "ERR_RATE_LIMITER_UNAVAILABLE",
176
+ ):
177
+ if re.search(r"\b" + code + r"\b", str(exc)):
178
+ central_status += f" {code}."
179
+ sources.append(
180
+ SourceInput(
181
+ "Control-plane trace data",
182
+ f"Profile: {args.profile}",
183
+ "Live API query",
184
+ status="failed",
185
+ )
186
+ )
187
+ # Transport diagnostics may contain URLs; never copy them into a shareable HTML artifact.
188
+ print(str(exc), file=sys.stderr)
146
189
  elif args.trace_json:
147
- central_status = f"saved trace: {args.trace_json}; offline metadata export"
190
+ central_status = "Saved export read offline; original fetch time/status unavailable"
148
191
  selected, diagnostics = select_events(events, root)
149
192
  graph = extract_token_relation_graph(selected)
150
193
  if not graph.tokens:
@@ -152,16 +195,6 @@ def _render(args, events, query: Query) -> int:
152
195
  "no reconstructable token observations for the selected root; supply sidecar evidence or an available central trace"
153
196
  )
154
197
  graph.diagnostics.extend(diagnostics)
155
- graph.diagnostics.extend(
156
- [
157
- f"Selected root: {root}",
158
- f"Central evidence: {central_status}",
159
- "Partial evidence: only supplied observations are shown. Missing participants, ancestors, fields and outcomes are not success or failure.",
160
- "Central metadata intentionally omits private business details, authority caps, exact expiry and full receipts. Forwarding may also omit or delay events.",
161
- "Application records are reports, not verified business truth. A mint/receive success does not prove business completion. Receipt verdicts are observations, not re-verification by this tool.",
162
- "Distinct action records are retained; these formats do not establish exactly-once business-action counts.",
163
- ]
164
- )
165
198
  protected = [
166
199
  *args.events,
167
200
  *args.actions,
@@ -171,6 +204,7 @@ def _render(args, events, query: Query) -> int:
171
204
  if args.output.resolve() in {path.resolve() for path in protected}:
172
205
  raise InputError("--output must not overwrite an evidence input")
173
206
  visual = build_visual_aeg(graph)
207
+ visual.provenance = summarize_sources(sources, selected, central_status=central_status)
174
208
  with tempfile.TemporaryDirectory(prefix="aac-aeg-") as directory:
175
209
  rendered = write_html_aeg(visual, root, out_dir=Path(directory))
176
210
  args.output.parent.mkdir(parents=True, exist_ok=True)
@@ -334,9 +368,17 @@ def main(
334
368
  args = parser(prog=prog, version_text=version_text).parse_args(argv)
335
369
  events = []
336
370
  try:
337
- events = read_events(args.events) + read_actions(args.actions)
371
+ sources = []
372
+ for paths, reader, kind in (
373
+ (args.events, read_events, "Sidecar telemetry"),
374
+ (args.actions, read_actions, "Application action records"),
375
+ ):
376
+ for path in paths:
377
+ records = reader([path])
378
+ events.extend(records)
379
+ sources.append(SourceInput(kind, str(path), "Local file", records))
338
380
  return (
339
- _render(args, events, query or _query)
381
+ _render(args, events, query or _query, sources)
340
382
  if args.command == "render"
341
383
  else _list(args, events, list_query)
342
384
  )
@@ -1,18 +1,20 @@
1
1
  {
2
- "content_sha256": "f0477c0f8e73bffe5e111842d8aca3f5745345acb8cb58bed911deba7d826798",
2
+ "content_sha256": "6e8bcd00bd94c415e04af65deb527be4d1581a6a5396d74c95708d6dda97fca4",
3
3
  "files": {
4
4
  "__init__.py": "80d80a1ba35353d271cddf02532801c60b57f5ec06fb268ae823747742ce8979",
5
- "cli.py": "8fa9605246ba0ddbd24e6034b9582049fac11df139d47ee9e2ee0f322ff53c4c",
5
+ "cli.py": "98aa2b0c528fefb5f1f90a16584a6fbda1f075ebfcf61605f52948cdb940a1af",
6
6
  "data/action-taken-v1.json": "d863343afe7f314646a73cf063480a7e650f93857d2ed5ec3a7b1f9838671fdf",
7
7
  "data/predicates-v1.json": "35e83a29feada10e8cfbef7009245146a9717d4f4fd58d74124e0ec91ae922c8",
8
8
  "from_sidecar.py": "6ac023f6925a13e94e0fee762656a02ca98981f2add194b052e9014ca48e2219",
9
9
  "inputs.py": "ebf1ca77957d29d59683348f6916bac3c21e2037f087f26ff7c7242d75edc77c",
10
- "model.py": "b5cc3955c000b7f99881ff5f7f63d482bdc6434347cf2dbc5a2b50e29bfc5bc7",
10
+ "model.py": "b631dcd7af587dcc89c18e1058bc81fb1043f04250a0d6ea585f9c595fd75cfc",
11
11
  "narrative.py": "8ac4b12add84cc8be907cff5f25e58731ab1144888aec8d376e286de461a6179",
12
+ "provenance.py": "d310e13bfd45cb16edcc72e7b2bc28c916e3ff45208e2b44240e589f0ec0558d",
12
13
  "schemas.py": "c47e0138fb8bfeaf1db1725a00d4a37ff4a136a9e8f5907356b1006f301a9ded",
13
14
  "temporal.py": "b69081e9280d578dc1261a276da3178e979a02efaf1137f720b4520ed543a36d",
15
+ "theme.py": "ded47be13f5c591da95a1bb91ee1b665c1570c929d842bdaa1a41325d0b40caa",
14
16
  "timeline_source.py": "dc875458d69c6135b3ffe0db9c35e511da603b5d52409f780f3e1b74dc464520",
15
- "visualizer.py": "98896508855684c5f91052919d4d70cc7aea116ce532e964c767be3586fc22ee"
17
+ "visualizer.py": "5eb7b863652c51a4a5751bcd8ae735b688e6e3e61089f0c158473ebb52ae6655"
16
18
  },
17
19
  "legal": {
18
20
  "LICENSE": "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594",
@@ -215,6 +215,9 @@ class TerminalNode:
215
215
  action_payload: dict = field(default_factory=dict)
216
216
  action_taken_by_spiffe_id: str | None = None
217
217
  action_taken_timestamp: str | None = None
218
+ # Canonical associations observed for this leaf and exact receiver workload.
219
+ # A trust-domain match, another token, or a wildcard is not such evidence.
220
+ observed_tenant_ids: tuple[str, ...] = ()
218
221
 
219
222
 
220
223
  @dataclass(frozen=True)
@@ -267,6 +270,9 @@ class TokenRelationGraph:
267
270
  actions: dict[str, list[UnifiedEvent]] = field(default_factory=dict)
268
271
  diagnostics: list[str] = field(default_factory=list)
269
272
  receipts: dict[str, list[UnifiedEvent]] = field(default_factory=dict)
273
+ # Retain conflicting creation claims even when one token's merged fields
274
+ # become unknown. Receiving/reporting observations cannot supply this map.
275
+ creator_tenant_claims: dict[str, tuple[str, ...]] = field(default_factory=dict)
270
276
 
271
277
 
272
278
  # ---------------------------------------------------------------------------
@@ -298,6 +304,37 @@ def extract_token_relation_graph(events: list[UnifiedEvent]) -> TokenRelationGra
298
304
  terminals,
299
305
  )
300
306
 
307
+ creation_events = [
308
+ event
309
+ for event in events
310
+ if event.event_type in {"mint_root", "attenuate", "composite_mint"}
311
+ and event.token_id in tokens
312
+ and not tokens[event.token_id].placeholder
313
+ ]
314
+ token_creators: dict[str, set[str]] = {}
315
+ for event in creation_events:
316
+ actor = event.raw_event.get("presenter_spiffe_id")
317
+ candidates = token_creators.setdefault(event.token_id, set())
318
+ if isinstance(actor, str) and actor:
319
+ candidates.add(actor)
320
+ # Another observation of this exact token may supply its agreed
321
+ # creator workload. Receiving tenant identity is never used here.
322
+ if tokens[event.token_id].creator_spiffe_id:
323
+ candidates.add(tokens[event.token_id].creator_spiffe_id)
324
+ creator_claims: dict[str, set[str]] = {}
325
+ for event in creation_events:
326
+ actor = event.raw_event.get("presenter_spiffe_id")
327
+ tenant = event.raw_event.get("tenant_id")
328
+ if isinstance(tenant, str) and tenant:
329
+ # A sparse creation row still disputes the same token's tenant.
330
+ # Preserve its possible tenant for the recorded creator candidates
331
+ # instead of silently turning a conflict into a unique association.
332
+ actors = (
333
+ (actor,) if isinstance(actor, str) and actor else token_creators[event.token_id]
334
+ )
335
+ for candidate in actors:
336
+ creator_claims.setdefault(candidate, set()).add(tenant)
337
+
301
338
  return TokenRelationGraph(
302
339
  tokens=tokens,
303
340
  originators=originators,
@@ -306,6 +343,9 @@ def extract_token_relation_graph(events: list[UnifiedEvent]) -> TokenRelationGra
306
343
  leaf_token_ids=leaf_token_ids,
307
344
  root_token_ids=root_token_ids,
308
345
  chain_failures=chain_failures,
346
+ creator_tenant_claims={
347
+ actor: tuple(sorted(tenants)) for actor, tenants in creator_claims.items()
348
+ },
309
349
  actions={
310
350
  tid: sorted(
311
351
  (e for e in events if e.event_type == "action_taken" and e.token_id == tid),
@@ -787,7 +827,14 @@ def _extract_terminals(
787
827
  for the same token_id, its summary + payload + presenter are attached.
788
828
  """
789
829
  action_by_token: dict[str, list[UnifiedEvent]] = {}
830
+ receiver_claims: dict[tuple[str, str], set[str]] = {}
790
831
  for e in events:
832
+ if e.event_type in {"token_received", "receipt_observed", "action_taken"} or (
833
+ e.event_type == "chain_failure" and e.raw_event.get("wire_event_type") == "receive"
834
+ ):
835
+ actor, tenant = e.raw_event.get("actor_spiffe_id"), e.raw_event.get("tenant_id")
836
+ if isinstance(actor, str) and isinstance(tenant, str) and actor and tenant:
837
+ receiver_claims.setdefault((e.token_id, actor), set()).add(tenant)
791
838
  # TENANT_SOURCE_PREFIX (defined in aeg.schemas) is the shared
792
839
  # producer/consumer contract — `from_sidecar.py` sets
793
840
  # `source=f"{TENANT_SOURCE_PREFIX}{name}"` on per-tenant SIEM
@@ -823,6 +870,14 @@ def _extract_terminals(
823
870
  continue
824
871
  tenant_id = _tenant_from_spiffe_pattern(audience)
825
872
  workload_label = _workload_label_from_spiffe(audience)
873
+ # Readers accept identity strings, including opaque imported values.
874
+ # A pattern actor must not establish an exact receiver association even
875
+ # if it happens to equal the audience string; the view also guards it.
876
+ observed_tenants = (
877
+ tuple(sorted(receiver_claims.get((tid, audience), ())))
878
+ if not any(c in audience for c in "*?[")
879
+ else ()
880
+ )
826
881
  actions = sorted(
827
882
  action_by_token.get(tid, []),
828
883
  key=lambda e: (e.timestamp_iso, json.dumps(e.raw_event, sort_keys=True)),
@@ -845,6 +900,7 @@ def _extract_terminals(
845
900
  leaf_token_id=tid,
846
901
  tenant_id=tenant_id,
847
902
  workload_label=workload_label,
903
+ observed_tenant_ids=observed_tenants,
848
904
  action_summary=raw.get("action_summary"),
849
905
  action_payload=raw.get("action_payload") or {},
850
906
  # B44.3: read the spec-canonical field name `actor_spiffe_id`
@@ -864,6 +920,7 @@ def _extract_terminals(
864
920
  leaf_token_id=tid,
865
921
  tenant_id=tenant_id,
866
922
  workload_label=workload_label,
923
+ observed_tenant_ids=observed_tenants,
867
924
  )
868
925
  )
869
926
  return out
@@ -0,0 +1,194 @@
1
+ """Render-time input inventory. Never infers acquisition from event contents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from html import escape
8
+
9
+ from .schemas import UnifiedEvent
10
+
11
+
12
+ def participant_observations(events: list[UnifiedEvent]) -> tuple[tuple[str, str], ...]:
13
+ """Recorded reporting identity; a received token's presenter is not its reporter."""
14
+ pairs = set()
15
+ for event in events:
16
+ # Use normalized identity fields. Imported _record extensions remain
17
+ # opaque even when they happen to be dictionaries.
18
+ raw = event.raw_event
19
+ tenant = raw.get("tenant_id") or ""
20
+ actor = raw.get("actor_spiffe_id") or ""
21
+ if not actor and event.event_type in {"mint_root", "attenuate", "composite_mint"}:
22
+ actor = raw.get("presenter_spiffe_id") or ""
23
+ if tenant or actor:
24
+ pairs.add((str(tenant), str(actor)))
25
+ return tuple(sorted(pairs))
26
+
27
+
28
+ @dataclass
29
+ class SourceInput:
30
+ kind: str
31
+ label: str
32
+ acquisition: str
33
+ events: list[UnifiedEvent] = field(default_factory=list)
34
+ status: str = "read"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class SourceSummary:
39
+ kind: str
40
+ label: str
41
+ acquisition: str
42
+ status: str
43
+ matching_records: int
44
+ participants: tuple[tuple[str, str], ...]
45
+ first_observed: str | None
46
+ last_observed: str | None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Provenance:
51
+ sources: tuple[SourceSummary, ...]
52
+ central_status: str
53
+
54
+
55
+ def summarize_sources(
56
+ sources: list[SourceInput], selected: list[UnifiedEvent], *, central_status: str
57
+ ) -> Provenance:
58
+ # Selection retains the original event objects. Track membership by identity,
59
+ # not by a filename, timestamp or caller-controlled event field that could
60
+ # make an overlapping local record look like a successful central query.
61
+ selected_ids = {id(event) for event in selected}
62
+ summaries = []
63
+ for source in sources:
64
+ matching = [event for event in source.events if id(event) in selected_ids]
65
+ records = {event.raw_event.get("_source", id(event)) for event in matching}
66
+ times = sorted(
67
+ datetime.fromisoformat(event.timestamp_iso.replace("Z", "+00:00"))
68
+ .astimezone(timezone.utc)
69
+ .isoformat()
70
+ for event in matching
71
+ )
72
+ summaries.append(
73
+ SourceSummary(
74
+ source.kind,
75
+ source.label,
76
+ source.acquisition,
77
+ source.status,
78
+ len(records),
79
+ participant_observations(matching),
80
+ times[0] if times else None,
81
+ times[-1] if times else None,
82
+ )
83
+ )
84
+ return Provenance(tuple(summaries), central_status)
85
+
86
+
87
+ def source_panel(provenance: Provenance | None, diagnostics: list[str]) -> str:
88
+ """One escaped, initially collapsed panel shared by CLI and hosted renders."""
89
+ sources = provenance.sources if provenance else ()
90
+ used = {source.kind for source in sources if source.matching_records}
91
+ summary = (
92
+ " + ".join(
93
+ kind
94
+ for kind in (
95
+ "Sidecar telemetry",
96
+ "Control-plane trace data",
97
+ "Application action records",
98
+ )
99
+ if kind in used
100
+ )
101
+ or "Source inventory not supplied"
102
+ )
103
+ parts = [
104
+ '<details id="aac-sources"><summary>Data sources used for this graph'
105
+ f'<span class="aac-source-summary">{escape(summary)}</span></summary>'
106
+ '<div class="aac-source-body">'
107
+ ]
108
+ if provenance is None:
109
+ parts.append(
110
+ "<p>This caller did not supply a source inventory. Files, acquisition method "
111
+ "and control-plane query status are unknown; they must not be inferred from the graph.</p>"
112
+ )
113
+ else:
114
+ parts.append(
115
+ f"<p><strong>Control-plane trace:</strong> {escape(provenance.central_status)}</p>"
116
+ )
117
+ parts.append(
118
+ '<div class="aac-source-table"><table><thead><tr><th>Source and input</th>'
119
+ "<th>Observed tenant / workload</th><th>Matching records</th>"
120
+ "<th>Observed record span (UTC)</th></tr></thead><tbody>"
121
+ )
122
+ for source in sources:
123
+ identities = (
124
+ "<br>".join(
125
+ f"{escape(tenant or 'Tenant not recorded')} / {escape(actor or 'Workload not recorded')}"
126
+ for tenant, actor in source.participants
127
+ )
128
+ or "No matching identity observations"
129
+ )
130
+ span = (
131
+ f"{escape(source.first_observed)} → {escape(source.last_observed or '')}"
132
+ if source.first_observed
133
+ else "No matching observations"
134
+ )
135
+ contribution = "Contributed" if source.matching_records else "Did not contribute"
136
+ parts.append(
137
+ f"<tr><td><strong>{escape(source.kind)}</strong><br><code>{escape(source.label)}</code>"
138
+ f"<br>{escape(source.acquisition)} · {escape(source.status)} · {contribution}</td>"
139
+ f"<td>{identities}</td><td>{source.matching_records}</td><td>{span}</td></tr>"
140
+ )
141
+ parts.append(
142
+ "</tbody></table></div><p>Counts describe matching input rows for files or normalized "
143
+ "observations for hosted data, not unique business actions. Timestamp spans describe "
144
+ "available records, not execution start/end or continuous coverage.</p>"
145
+ )
146
+ parts.append(
147
+ "<h2>Limitations for these inputs</h2><ul><li>Partial evidence: only supplied, "
148
+ "correlated observations are shown. Missing records do not establish success or failure.</li>"
149
+ "<li>An intended recipient is named by an audience constraint; that constraint alone "
150
+ "does not prove delivery or completion. Mint/transport success does not prove a business result.</li>"
151
+ )
152
+ if "Control-plane trace data" in used:
153
+ parts.append(
154
+ "<li>Central metadata omits private business details, authority caps, exact expiry "
155
+ "and full receipts. Best-effort forwarding may omit or delay events.</li>"
156
+ )
157
+ if "Application action records" in used:
158
+ parts.append(
159
+ "<li>Application records are reports, not independently verified business truth.</li>"
160
+ )
161
+ if "Sidecar telemetry" in used:
162
+ parts.append(
163
+ "<li>Receipt verdicts are recorded sidecar observations; AEG does not re-verify signatures.</li>"
164
+ )
165
+ parts.append(
166
+ "<li>Repeated records do not establish exactly-once business-action counts.</li></ul>"
167
+ )
168
+ if diagnostics:
169
+ parts.append(
170
+ "<h3>Input diagnostics</h3><ul>"
171
+ + "".join(f"<li>{escape(note)}</li>" for note in dict.fromkeys(diagnostics))
172
+ + "</ul>"
173
+ )
174
+ parts.append(
175
+ '<section class="aac-next-steps"><h2>If an action or result is missing</h2>'
176
+ "<p>Check the chain root/attempt, input files, observed times and query status first.</p><ul>"
177
+ "<li><strong>Missing tokens, participants or handoffs:</strong> add authorized participants’ "
178
+ "retained sidecar files with <code>--events FILE</code>. An authorized trace via "
179
+ "<code>--profile PROFILE</code> or a saved <code>--trace-json FILE</code> export may add "
180
+ "forwarded observations. For a sidecar + application graph (combo 3), adding a profile "
181
+ "uses combo 4; it does not guarantee a missing result.</li>"
182
+ "<li><strong>Missing business details/results:</strong> add matching structured "
183
+ "<code>action_taken</code> records with <code>--actions FILE</code>; check token IDs and "
184
+ "the token-to-root mapping. Arbitrary application logs are not supported. More central "
185
+ "data cannot supply private business payloads or full receipts.</li>"
186
+ "<li><strong>Failed query:</strong> follow the diagnostic; check profile/access configuration "
187
+ "and connectivity, then retry. Failure is not a successful empty query.</li>"
188
+ "<li><strong>Possibly delayed forwarding:</strong> render again later or use retained local "
189
+ "files. Data never recorded, never forwarded or no longer retained may remain unavailable.</li>"
190
+ "</ul><p>More source categories help only when matching records exist. Otherwise leave the "
191
+ "outcome unknown. Rendering again reads evidence; it does not rerun the business action.</p>"
192
+ "</section></div></details>"
193
+ )
194
+ return "".join(parts)