kentaro 0.1.0__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 (37) hide show
  1. kentaro-0.1.0/LICENSE +21 -0
  2. kentaro-0.1.0/PKG-INFO +138 -0
  3. kentaro-0.1.0/README.md +112 -0
  4. kentaro-0.1.0/kentaro/__init__.py +0 -0
  5. kentaro-0.1.0/kentaro/__main__.py +3 -0
  6. kentaro-0.1.0/kentaro/api/__init__.py +0 -0
  7. kentaro-0.1.0/kentaro/api/demo.py +48 -0
  8. kentaro-0.1.0/kentaro/api/live.py +214 -0
  9. kentaro-0.1.0/kentaro/cli.py +264 -0
  10. kentaro-0.1.0/kentaro/config/__init__.py +0 -0
  11. kentaro-0.1.0/kentaro/config/loader.py +48 -0
  12. kentaro-0.1.0/kentaro/dashboard/__init__.py +0 -0
  13. kentaro-0.1.0/kentaro/dashboard/render.py +146 -0
  14. kentaro-0.1.0/kentaro/data_products.py +227 -0
  15. kentaro-0.1.0/kentaro/governance/__init__.py +14 -0
  16. kentaro-0.1.0/kentaro/governance/evidence.py +143 -0
  17. kentaro-0.1.0/kentaro/governance/policy.py +103 -0
  18. kentaro-0.1.0/kentaro/governance/schemas.py +314 -0
  19. kentaro-0.1.0/kentaro/reports/__init__.py +0 -0
  20. kentaro-0.1.0/kentaro/reports/brief.py +40 -0
  21. kentaro-0.1.0/kentaro/resources/__init__.py +1 -0
  22. kentaro-0.1.0/kentaro/resources/demo-portfolio.json +25 -0
  23. kentaro-0.1.0/kentaro/workspace.py +82 -0
  24. kentaro-0.1.0/kentaro.egg-info/PKG-INFO +138 -0
  25. kentaro-0.1.0/kentaro.egg-info/SOURCES.txt +35 -0
  26. kentaro-0.1.0/kentaro.egg-info/dependency_links.txt +1 -0
  27. kentaro-0.1.0/kentaro.egg-info/entry_points.txt +2 -0
  28. kentaro-0.1.0/kentaro.egg-info/requires.txt +3 -0
  29. kentaro-0.1.0/kentaro.egg-info/top_level.txt +1 -0
  30. kentaro-0.1.0/pyproject.toml +42 -0
  31. kentaro-0.1.0/setup.cfg +4 -0
  32. kentaro-0.1.0/tests/test_cli_defaults.py +119 -0
  33. kentaro-0.1.0/tests/test_data_product_cli.py +132 -0
  34. kentaro-0.1.0/tests/test_distribution_install.py +67 -0
  35. kentaro-0.1.0/tests/test_governance.py +167 -0
  36. kentaro-0.1.0/tests/test_live_client.py +98 -0
  37. kentaro-0.1.0/tests/test_vertical_slice.py +87 -0
kentaro-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Maysano
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
kentaro-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: kentaro
3
+ Version: 0.1.0
4
+ Summary: Read-only Agentic Maysano Harness for sourced portfolio context and local governance validation
5
+ Author: Maysano
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://maysano.com/kentaro
8
+ Project-URL: Documentation, https://github.com/Maysano/Kentaro/tree/main/docs
9
+ Project-URL: Repository, https://github.com/Maysano/Kentaro
10
+ Project-URL: Issues, https://github.com/Maysano/Kentaro/issues
11
+ Project-URL: ODPS Specifications, https://opendataproducts.org/
12
+ Keywords: agents,governance,maysano,odps,portfolio,read-only
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Provides-Extra: odps
24
+ Requires-Dist: open-data-products<0.5,>=0.3.4; extra == "odps"
25
+ Dynamic: license-file
26
+
27
+ # Kentaro
28
+
29
+ Kentaro is the open-source, read-only Agentic Maysano Harness. It gives you a
30
+ working local portfolio briefing and sourced dashboard immediately, then lets you
31
+ opt in to a bounded read-only Maysano source without turning Kentaro into a system
32
+ that acts on your behalf.
33
+
34
+ Learn more at [maysano.com/kentaro](https://maysano.com/kentaro).
35
+
36
+ ## Install
37
+
38
+ Kentaro requires Python 3.11 or later.
39
+
40
+ ```shell
41
+ python -m pip install kentaro
42
+ ```
43
+
44
+ ## Run the demo
45
+
46
+ Run the complete synthetic demonstration with no account, configuration file,
47
+ repository checkout, API key, or network request:
48
+
49
+ ```shell
50
+ kentaro demo
51
+ ```
52
+
53
+ It prints a sourced portfolio briefing and writes a dashboard to a new timestamped
54
+ file under `.kentaro/workspace/`. Kentaro creates that directory automatically and
55
+ prints the complete local path.
56
+
57
+ ## Use Kentaro locally
58
+
59
+ The normal local commands use the packaged synthetic portfolio context:
60
+
61
+ ```shell
62
+ kentaro portfolio brief
63
+ kentaro dashboard
64
+ ```
65
+
66
+ `kentaro dashboard` always creates a fresh timestamped HTML file in
67
+ `.kentaro/workspace/`; it never overwrites an existing dashboard.
68
+
69
+ ## Connect a read-only Maysano source
70
+
71
+ Live access is explicit and opt-in. Configure a Maysano-issued read-only key through
72
+ the guided local setup, then select the source for an individual read:
73
+
74
+ ```shell
75
+ kentaro configure maysano
76
+ kentaro portfolio brief --source maysano
77
+ ```
78
+
79
+ The key is stored only in Kentaro's ignored local workspace and is never printed.
80
+ The pilot source is GET-only. It does not currently provide owners, decisions,
81
+ risks, evidence, approval history, or milestones; Kentaro labels those fields as
82
+ unavailable rather than inventing them.
83
+
84
+ ## Safety boundary
85
+
86
+ Kentaro v0.1 retrieves bounded sourced context, validates local versioned governance
87
+ and delivery objects, generates drafts and reports, classifies proposed requests, and
88
+ exports local evidence. It does not create external records, write to Maysano or a
89
+ business-context graph, execute business actions, request or process approvals, or
90
+ claim immutable or compliance-grade audit. The optional local draft commands create
91
+ local artifacts only; a hosted provider requires explicit acknowledgement before it
92
+ receives source content.
93
+
94
+ Maysano remains the system of record for portfolio state, approvals, graph updates,
95
+ tenant controls, authoritative revocation, retention, and platform audit. An
96
+ `allowed` classification means only that a request is structurally in scope; it is
97
+ not permission to execute.
98
+
99
+ ## Advanced configuration and governance
100
+
101
+ TOML profiles are an advanced team configuration mechanism for named environments,
102
+ custom API endpoints or workspace paths, model declarations, organisation policies,
103
+ and non-default profiles. They are never needed for the normal local experience.
104
+
105
+ To create proposed local data-product, use-case, or objective drafts from Markdown or
106
+ text, install the optional SDK integration. These local artifacts align with the
107
+ [Open Data Products (ODPS) family of specifications](https://opendataproducts.org/):
108
+
109
+ ```shell
110
+ python -m pip install "kentaro[odps]"
111
+ kentaro data-product draft ./business-context.md
112
+ kentaro use-case draft ./business-context.md
113
+ kentaro objective draft ./business-context.md
114
+ ```
115
+
116
+ Validate a saved local draft without generating anything:
117
+
118
+ ```shell
119
+ kentaro data-product validate ./data-product.yaml
120
+ kentaro use-case validate ./use-case.yaml
121
+ kentaro objective validate ./objective.yaml
122
+ ```
123
+
124
+ The default provider is local Ollama. A hosted provider requires an explicit provider
125
+ configuration and `--allow-hosted-content`, because it may receive the source document.
126
+ Each result remains a local draft; it never creates a Maysano record or grants authority.
127
+
128
+ Detailed material is kept out of onboarding:
129
+
130
+ - [Getting started](https://github.com/Maysano/Kentaro/blob/main/docs/getting-started.md)
131
+ - [Advanced configuration](https://github.com/Maysano/Kentaro/blob/main/docs/configuration.md)
132
+ - [Local draft artifacts](https://github.com/Maysano/Kentaro/blob/main/docs/data-product-drafts.md)
133
+ - [Governance and delivery object model](https://github.com/Maysano/Kentaro/blob/main/docs/governance-and-delivery-object-model.md)
134
+ - [Architecture](https://github.com/Maysano/Kentaro/blob/main/docs/architecture.md)
135
+ - [Security and data boundaries](https://github.com/Maysano/Kentaro/blob/main/docs/security-and-data-boundaries.md)
136
+ - [Developer guide](https://github.com/Maysano/Kentaro/blob/main/docs/developer-guide.md)
137
+
138
+ [MIT License](LICENSE) © 2026 Maysano.
@@ -0,0 +1,112 @@
1
+ # Kentaro
2
+
3
+ Kentaro is the open-source, read-only Agentic Maysano Harness. It gives you a
4
+ working local portfolio briefing and sourced dashboard immediately, then lets you
5
+ opt in to a bounded read-only Maysano source without turning Kentaro into a system
6
+ that acts on your behalf.
7
+
8
+ Learn more at [maysano.com/kentaro](https://maysano.com/kentaro).
9
+
10
+ ## Install
11
+
12
+ Kentaro requires Python 3.11 or later.
13
+
14
+ ```shell
15
+ python -m pip install kentaro
16
+ ```
17
+
18
+ ## Run the demo
19
+
20
+ Run the complete synthetic demonstration with no account, configuration file,
21
+ repository checkout, API key, or network request:
22
+
23
+ ```shell
24
+ kentaro demo
25
+ ```
26
+
27
+ It prints a sourced portfolio briefing and writes a dashboard to a new timestamped
28
+ file under `.kentaro/workspace/`. Kentaro creates that directory automatically and
29
+ prints the complete local path.
30
+
31
+ ## Use Kentaro locally
32
+
33
+ The normal local commands use the packaged synthetic portfolio context:
34
+
35
+ ```shell
36
+ kentaro portfolio brief
37
+ kentaro dashboard
38
+ ```
39
+
40
+ `kentaro dashboard` always creates a fresh timestamped HTML file in
41
+ `.kentaro/workspace/`; it never overwrites an existing dashboard.
42
+
43
+ ## Connect a read-only Maysano source
44
+
45
+ Live access is explicit and opt-in. Configure a Maysano-issued read-only key through
46
+ the guided local setup, then select the source for an individual read:
47
+
48
+ ```shell
49
+ kentaro configure maysano
50
+ kentaro portfolio brief --source maysano
51
+ ```
52
+
53
+ The key is stored only in Kentaro's ignored local workspace and is never printed.
54
+ The pilot source is GET-only. It does not currently provide owners, decisions,
55
+ risks, evidence, approval history, or milestones; Kentaro labels those fields as
56
+ unavailable rather than inventing them.
57
+
58
+ ## Safety boundary
59
+
60
+ Kentaro v0.1 retrieves bounded sourced context, validates local versioned governance
61
+ and delivery objects, generates drafts and reports, classifies proposed requests, and
62
+ exports local evidence. It does not create external records, write to Maysano or a
63
+ business-context graph, execute business actions, request or process approvals, or
64
+ claim immutable or compliance-grade audit. The optional local draft commands create
65
+ local artifacts only; a hosted provider requires explicit acknowledgement before it
66
+ receives source content.
67
+
68
+ Maysano remains the system of record for portfolio state, approvals, graph updates,
69
+ tenant controls, authoritative revocation, retention, and platform audit. An
70
+ `allowed` classification means only that a request is structurally in scope; it is
71
+ not permission to execute.
72
+
73
+ ## Advanced configuration and governance
74
+
75
+ TOML profiles are an advanced team configuration mechanism for named environments,
76
+ custom API endpoints or workspace paths, model declarations, organisation policies,
77
+ and non-default profiles. They are never needed for the normal local experience.
78
+
79
+ To create proposed local data-product, use-case, or objective drafts from Markdown or
80
+ text, install the optional SDK integration. These local artifacts align with the
81
+ [Open Data Products (ODPS) family of specifications](https://opendataproducts.org/):
82
+
83
+ ```shell
84
+ python -m pip install "kentaro[odps]"
85
+ kentaro data-product draft ./business-context.md
86
+ kentaro use-case draft ./business-context.md
87
+ kentaro objective draft ./business-context.md
88
+ ```
89
+
90
+ Validate a saved local draft without generating anything:
91
+
92
+ ```shell
93
+ kentaro data-product validate ./data-product.yaml
94
+ kentaro use-case validate ./use-case.yaml
95
+ kentaro objective validate ./objective.yaml
96
+ ```
97
+
98
+ The default provider is local Ollama. A hosted provider requires an explicit provider
99
+ configuration and `--allow-hosted-content`, because it may receive the source document.
100
+ Each result remains a local draft; it never creates a Maysano record or grants authority.
101
+
102
+ Detailed material is kept out of onboarding:
103
+
104
+ - [Getting started](https://github.com/Maysano/Kentaro/blob/main/docs/getting-started.md)
105
+ - [Advanced configuration](https://github.com/Maysano/Kentaro/blob/main/docs/configuration.md)
106
+ - [Local draft artifacts](https://github.com/Maysano/Kentaro/blob/main/docs/data-product-drafts.md)
107
+ - [Governance and delivery object model](https://github.com/Maysano/Kentaro/blob/main/docs/governance-and-delivery-object-model.md)
108
+ - [Architecture](https://github.com/Maysano/Kentaro/blob/main/docs/architecture.md)
109
+ - [Security and data boundaries](https://github.com/Maysano/Kentaro/blob/main/docs/security-and-data-boundaries.md)
110
+ - [Developer guide](https://github.com/Maysano/Kentaro/blob/main/docs/developer-guide.md)
111
+
112
+ [MIT License](LICENSE) © 2026 Maysano.
File without changes
@@ -0,0 +1,3 @@
1
+ from kentaro.cli import main
2
+
3
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,48 @@
1
+ """Local fixture contract. This module contains no network transport."""
2
+ import json
3
+ from datetime import datetime, timezone
4
+ from importlib.resources import as_file, files
5
+
6
+
7
+ DEMO_SCOPE = "portfolio-demo"
8
+
9
+
10
+ def read_packaged_demo() -> dict:
11
+ """Read the synthetic demo packaged with Kentaro; no configuration or network is used."""
12
+ resource = files("kentaro.resources").joinpath("demo-portfolio.json")
13
+ with as_file(resource) as fixture:
14
+ return DemoClient(fixture, DEMO_SCOPE).read_portfolio()
15
+
16
+
17
+ class DemoClient:
18
+ def __init__(self, fixture, scope):
19
+ self._fixture = fixture
20
+ self._scope = scope
21
+
22
+ def request(self, method):
23
+ if method != "GET":
24
+ raise ValueError("Only GET is permitted in Kentaro v0.1")
25
+ return self.read_portfolio()
26
+
27
+ def read_portfolio(self):
28
+ with open(self._fixture, encoding="utf-8") as stream:
29
+ data = json.load(stream)
30
+ if data.get("schema_version") != 1 or data.get("synthetic") is not True:
31
+ raise ValueError("Expected a version 1 synthetic fixture")
32
+ records = [r for r in data["records"] if r["scope"] == self._scope]
33
+ if not records:
34
+ raise ValueError("No demo records available in the selected scope")
35
+ ids = {r["id"] for r in records}
36
+ if len(ids) != len(records):
37
+ raise ValueError("Duplicate source IDs")
38
+ for record in records:
39
+ for key in ("id", "kind", "title", "owner", "state", "version", "updated_at", "source_ref"):
40
+ if key not in record:
41
+ raise ValueError("Incomplete source metadata: " + key)
42
+ relationships = [r for r in data["relationships"] if r["scope"] == self._scope
43
+ and r["source"] in ids and r["target"] in ids]
44
+ return {"scope": self._scope, "records": records, "relationships": relationships,
45
+ "retrieved_at": datetime.now(timezone.utc).isoformat(),
46
+ "limitations": ["Synthetic fixture; live Maysano API capabilities unverified.",
47
+ "Fixture load time is not platform freshness; use record timestamps.",
48
+ "Acceptance criteria, delivery milestones and approval history unavailable."]}
@@ -0,0 +1,214 @@
1
+ """Read-only client for the documented Maysano external pilot API."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import ssl
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import quote, urlencode, urlsplit
10
+ from urllib.request import Request, urlopen
11
+
12
+
13
+ RESOURCE_SPECS = (
14
+ ("products", "data_product", "name"),
15
+ ("business-objectives", "objective", "title"),
16
+ ("business-use-cases", "use_case", "name"),
17
+ )
18
+ LINK_KINDS = {
19
+ "products": "data_product",
20
+ "objectives": "objective",
21
+ "useCases": "use_case",
22
+ "kpis": "kpi",
23
+ }
24
+
25
+
26
+ def _trusted_tls_context():
27
+ """Keep TLS verification enabled, preferring certifi when the runtime supplies it."""
28
+ try:
29
+ import certifi
30
+ except ModuleNotFoundError:
31
+ return ssl.create_default_context()
32
+ return ssl.create_default_context(cafile=certifi.where())
33
+
34
+
35
+ class RequestBudgetExhausted(Exception):
36
+ """Raised before exceeding the documented per-key request allowance."""
37
+
38
+
39
+ def load_live_environment(path):
40
+ """Read only the expected key/value pairs without executing a dotenv file."""
41
+ values = {}
42
+ try:
43
+ lines = Path(path).read_text(encoding="utf-8").splitlines()
44
+ except OSError as error:
45
+ raise ValueError("Unable to read live environment file") from error
46
+ for line in lines:
47
+ stripped = line.strip()
48
+ if not stripped or stripped.startswith("#"):
49
+ continue
50
+ key, marker, value = line.partition("=")
51
+ if not marker or key not in {"API_BASE_URL", "API_KEY"}:
52
+ raise ValueError("Live environment file contains an unsupported setting")
53
+ if key in values:
54
+ raise ValueError("Live environment file contains a duplicate setting")
55
+ values[key] = value.strip()
56
+ if not values.get("API_BASE_URL") or not values.get("API_KEY"):
57
+ raise ValueError("Live environment requires API_BASE_URL and API_KEY")
58
+ parsed = urlsplit(values["API_BASE_URL"])
59
+ if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.password:
60
+ raise ValueError("API_BASE_URL must be an HTTPS URL without user credentials")
61
+ if parsed.query or parsed.fragment:
62
+ raise ValueError("API_BASE_URL must not contain query or fragment components")
63
+ values["API_BASE_URL"] = values["API_BASE_URL"].rstrip("/")
64
+ return values
65
+
66
+
67
+ class LiveClient:
68
+ """Retrieves read-only portfolio metadata without persisting API responses."""
69
+
70
+ def __init__(self, environment_file, scope, *, timeout=30, request_budget=40):
71
+ self._settings = load_live_environment(environment_file)
72
+ self._scope = scope
73
+ self._timeout = timeout
74
+ self._request_budget = request_budget
75
+ self._attempts = 0
76
+ self._tls_context = _trusted_tls_context()
77
+
78
+ def request(self, method):
79
+ if method != "GET":
80
+ raise ValueError("Only GET is permitted in Kentaro v0.1")
81
+ return self.read_portfolio()
82
+
83
+ def _get_json(self, path, parameters=None):
84
+ if self._attempts >= self._request_budget:
85
+ raise RequestBudgetExhausted
86
+ query = urlencode(parameters or {})
87
+ url = self._settings["API_BASE_URL"] + path + ("?" + query if query else "")
88
+ request = Request(url, headers={"Authorization": "Bearer " + self._settings["API_KEY"]}, method="GET")
89
+ self._attempts += 1
90
+ try:
91
+ with urlopen(request, timeout=self._timeout, context=self._tls_context) as response:
92
+ return json.load(response)
93
+ except HTTPError as error:
94
+ raise ValueError(f"Maysano API request failed with HTTP status {error.code}") from error
95
+ except URLError as error:
96
+ raise ValueError("Maysano API connection failed") from error
97
+ except (json.JSONDecodeError, UnicodeDecodeError) as error:
98
+ raise ValueError("Maysano API returned invalid JSON") from error
99
+
100
+ def _list_resource(self, resource):
101
+ rows = []
102
+ offset = 0
103
+ total = None
104
+ complete = False
105
+ while True:
106
+ try:
107
+ payload = self._get_json("/" + resource, {"limit": 100, "offset": offset})
108
+ except RequestBudgetExhausted:
109
+ return rows, False, total
110
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
111
+ raise ValueError(f"Maysano API returned an invalid {resource} list response")
112
+ pagination = payload.get("pagination")
113
+ if not isinstance(pagination, dict) or not isinstance(pagination.get("total"), int):
114
+ raise ValueError(f"Maysano API returned invalid {resource} pagination")
115
+ rows.extend(payload["data"])
116
+ total = pagination["total"]
117
+ if not payload["data"] or offset + 100 >= total:
118
+ complete = True
119
+ break
120
+ offset += 100
121
+ return rows, complete, total
122
+
123
+ @staticmethod
124
+ def _normalise_record(row, resource, kind, title_key):
125
+ if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not row["id"]:
126
+ raise ValueError(f"Maysano API returned a {resource} record without an ID")
127
+ record_id = row["id"]
128
+ title = row.get(title_key)
129
+ return {
130
+ "id": record_id,
131
+ "kind": kind,
132
+ "title": title if isinstance(title, str) and title else "not supplied",
133
+ "owner": None,
134
+ "state": row.get("status") if isinstance(row.get("status"), str) else "not supplied",
135
+ "version": str(row["version"]) if row.get("version") is not None else "not supplied",
136
+ "updated_at": row.get("updatedAt") if isinstance(row.get("updatedAt"), str) else "not supplied",
137
+ "source_ref": f"maysano:/{resource}/{quote(record_id, safe='')}",
138
+ "resource": resource,
139
+ }
140
+
141
+ def _links_for_record(self, record, known_ids, limitations):
142
+ try:
143
+ detail = self._get_json("/" + record["resource"] + "/" + quote(record["id"], safe=""))
144
+ except RequestBudgetExhausted:
145
+ limitations.append("Relationship retrieval stopped before the documented request budget was exceeded.")
146
+ return [], False
147
+ if not isinstance(detail, dict) or detail.get("id") != record["id"]:
148
+ raise ValueError(f"Maysano API returned an invalid detail response for {record['id']}")
149
+ links = detail.get("links", {})
150
+ truncated = detail.get("linksTruncated", {})
151
+ if not isinstance(links, dict) or not isinstance(truncated, dict):
152
+ raise ValueError(f"Maysano API returned invalid link metadata for {record['id']}")
153
+ relationships = []
154
+ for collection, target_kind in LINK_KINDS.items():
155
+ values = links.get(collection)
156
+ if values is None:
157
+ continue
158
+ if not isinstance(values, list) or any(not isinstance(value, str) for value in values):
159
+ raise ValueError(f"Maysano API returned invalid {collection} links for {record['id']}")
160
+ if truncated.get(collection) is True:
161
+ limitations.append(f"Links from {record['id']} to {collection} are truncated by the source API.")
162
+ for target in values:
163
+ if target not in known_ids:
164
+ limitations.append(f"A documented link from {record['id']} to {target} could not be represented in the retrieved record set.")
165
+ continue
166
+ relationships.append({
167
+ "id": f"client-link:{record['id']}:{collection}:{target}",
168
+ "source": record["id"],
169
+ "target": target,
170
+ "type": "related_" + target_kind,
171
+ "version": "not supplied",
172
+ "updated_at": "not supplied",
173
+ "source_ref": record["source_ref"] + "#links." + collection,
174
+ })
175
+ return relationships, True
176
+
177
+ def read_portfolio(self):
178
+ records = []
179
+ limitations = [
180
+ "Live Maysano pilot API snapshot; the key is organization-bound and permissions are independent.",
181
+ "The external API does not provide owner fields; ownership is unknown unless another approved source is added.",
182
+ "The external API does not provide relationship IDs, versions, or timestamps; displayed link references are client-derived from detail responses.",
183
+ "List pagination is not a consistent snapshot; records may change while pages are read.",
184
+ "Responses are not cached because the API specifies Cache-Control: no-store.",
185
+ "Decisions, risks, evidence, approval history, and delivery milestones are not available through the documented external endpoints.",
186
+ ]
187
+ for resource, kind, title_key in RESOURCE_SPECS:
188
+ rows, complete, total = self._list_resource(resource)
189
+ if not complete:
190
+ limitations.append(f"{resource} retrieval is incomplete after {len(rows)} of {total if total is not None else 'unknown'} listed records.")
191
+ records.extend(self._normalise_record(row, resource, kind, title_key) for row in rows)
192
+ ids = {record["id"] for record in records}
193
+ if len(ids) != len(records):
194
+ raise ValueError("Maysano API returned duplicate record IDs across retrieved resources")
195
+ relationships = []
196
+ links_complete = True
197
+ for record in records:
198
+ record_links, complete = self._links_for_record(record, ids, limitations)
199
+ relationships.extend(record_links)
200
+ links_complete = links_complete and complete
201
+ if not complete:
202
+ break
203
+ if not links_complete:
204
+ limitations.append("Relationship coverage is incomplete because detail retrieval stopped at the client request budget.")
205
+ return {
206
+ "scope": self._scope,
207
+ "records": records,
208
+ "relationships": relationships,
209
+ "retrieved_at": datetime.now(timezone.utc).isoformat(),
210
+ "source_label": "Live Maysano pilot API",
211
+ "cache_label": "Cache disabled (API no-store)",
212
+ "owner_fields_available": False,
213
+ "limitations": list(dict.fromkeys(limitations)),
214
+ }