bkn-osdk 0.2.1__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 (76) hide show
  1. bkn_osdk-0.2.1/.gitignore +60 -0
  2. bkn_osdk-0.2.1/PKG-INFO +600 -0
  3. bkn_osdk-0.2.1/README.md +573 -0
  4. bkn_osdk-0.2.1/README.zh.md +371 -0
  5. bkn_osdk-0.2.1/bkn_osdk/__init__.py +122 -0
  6. bkn_osdk-0.2.1/bkn_osdk/auth.py +199 -0
  7. bkn_osdk-0.2.1/bkn_osdk/codegen/__init__.py +11 -0
  8. bkn_osdk-0.2.1/bkn_osdk/codegen/cli.py +375 -0
  9. bkn_osdk-0.2.1/bkn_osdk/codegen/diff.py +110 -0
  10. bkn_osdk-0.2.1/bkn_osdk/codegen/emit.py +619 -0
  11. bkn_osdk-0.2.1/bkn_osdk/codegen/kn_rest.py +233 -0
  12. bkn_osdk-0.2.1/bkn_osdk/config.py +522 -0
  13. bkn_osdk-0.2.1/bkn_osdk/errors.py +207 -0
  14. bkn_osdk-0.2.1/bkn_osdk/http.py +245 -0
  15. bkn_osdk-0.2.1/bkn_osdk/identity.py +88 -0
  16. bkn_osdk-0.2.1/bkn_osdk/kn.py +1411 -0
  17. bkn_osdk-0.2.1/bkn_osdk/lifecycle.py +354 -0
  18. bkn_osdk-0.2.1/bkn_osdk/mcp.py +393 -0
  19. bkn_osdk-0.2.1/bkn_osdk/meta.py +181 -0
  20. bkn_osdk-0.2.1/bkn_osdk/metrics.py +234 -0
  21. bkn_osdk-0.2.1/bkn_osdk/oauth.py +701 -0
  22. bkn_osdk-0.2.1/bkn_osdk/py.typed +0 -0
  23. bkn_osdk-0.2.1/bkn_osdk/query.py +659 -0
  24. bkn_osdk-0.2.1/bkn_osdk/schema.py +393 -0
  25. bkn_osdk-0.2.1/bkn_osdk/search.py +137 -0
  26. bkn_osdk-0.2.1/bkn_osdk/subgraph.py +251 -0
  27. bkn_osdk-0.2.1/bkn_osdk/types.py +456 -0
  28. bkn_osdk-0.2.1/pyproject.toml +79 -0
  29. bkn_osdk-0.2.1/tests/conftest.py +79 -0
  30. bkn_osdk-0.2.1/tests/e2e/__init__.py +2 -0
  31. bkn_osdk-0.2.1/tests/e2e/conftest.py +155 -0
  32. bkn_osdk-0.2.1/tests/e2e/test_live.py +467 -0
  33. bkn_osdk-0.2.1/tests/fixtures/golden/demo/__init__.py +85 -0
  34. bkn_osdk-0.2.1/tests/fixtures/golden/demo/_meta.py +10 -0
  35. bkn_osdk-0.2.1/tests/fixtures/golden/demo/metrics.py +9 -0
  36. bkn_osdk-0.2.1/tests/fixtures/golden/demo/object_types.py +72 -0
  37. bkn_osdk-0.2.1/tests/fixtures/golden/demo/py.typed +0 -0
  38. bkn_osdk-0.2.1/tests/fixtures/golden/demo/relation_types.py +22 -0
  39. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/count_filtered.json +48 -0
  40. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/count_unfiltered.json +42 -0
  41. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/flat_condition.json +73 -0
  42. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/get_by_primary_key.json +46 -0
  43. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/in_operator.json +100 -0
  44. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/nested_and_or.json +95 -0
  45. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/no_match.json +18 -0
  46. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/offset_paging.json +68 -0
  47. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/property_selection.json +32 -0
  48. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/sorted_desc.json +73 -0
  49. bkn_osdk-0.2.1/tests/fixtures/query/ecommerce_ops_bkn_public/order/unfiltered_page.json +67 -0
  50. bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/network.json +32 -0
  51. bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/object_types.json +3833 -0
  52. bkn_osdk-0.2.1/tests/fixtures/schema/ecommerce_ops_bkn_public/relation_types.json +941 -0
  53. bkn_osdk-0.2.1/tests/schema_fixtures.py +72 -0
  54. bkn_osdk-0.2.1/tests/test_app_tokens.py +249 -0
  55. bkn_osdk-0.2.1/tests/test_auth.py +290 -0
  56. bkn_osdk-0.2.1/tests/test_cli.py +463 -0
  57. bkn_osdk-0.2.1/tests/test_config.py +283 -0
  58. bkn_osdk-0.2.1/tests/test_emit.py +340 -0
  59. bkn_osdk-0.2.1/tests/test_errors.py +170 -0
  60. bkn_osdk-0.2.1/tests/test_generated_package.py +151 -0
  61. bkn_osdk-0.2.1/tests/test_http.py +285 -0
  62. bkn_osdk-0.2.1/tests/test_identity.py +110 -0
  63. bkn_osdk-0.2.1/tests/test_kn_rest.py +320 -0
  64. bkn_osdk-0.2.1/tests/test_lifecycle_retry.py +244 -0
  65. bkn_osdk-0.2.1/tests/test_metrics_and_negation.py +457 -0
  66. bkn_osdk-0.2.1/tests/test_oauth.py +489 -0
  67. bkn_osdk-0.2.1/tests/test_platform_floor.py +112 -0
  68. bkn_osdk-0.2.1/tests/test_query.py +573 -0
  69. bkn_osdk-0.2.1/tests/test_relations.py +190 -0
  70. bkn_osdk-0.2.1/tests/test_schema.py +127 -0
  71. bkn_osdk-0.2.1/tests/test_schema_details.py +88 -0
  72. bkn_osdk-0.2.1/tests/test_schema_parse.py +351 -0
  73. bkn_osdk-0.2.1/tests/test_search_and_drift.py +345 -0
  74. bkn_osdk-0.2.1/tests/test_subgraph.py +492 -0
  75. bkn_osdk-0.2.1/tests/test_traced.py +1153 -0
  76. bkn_osdk-0.2.1/tests/test_types.py +243 -0
@@ -0,0 +1,60 @@
1
+ # Dependencies
2
+ # No trailing slash: that form matches directories only, and a symlinked
3
+ # node_modules — how a worktree usually borrows the parent's install — is not
4
+ # a directory, so it slipped past and got committed.
5
+ node_modules
6
+ .pnp/
7
+ .pnp.js
8
+
9
+ # Build output
10
+ dist/
11
+ build/
12
+ .tmp-example/
13
+ *.tsbuildinfo
14
+
15
+ # Python (python/ — the OSDK runtime + generator)
16
+ __pycache__/
17
+ *.py[cod]
18
+ .venv/
19
+ .mypy_cache/
20
+ .pytest_cache/
21
+ .ruff_cache/
22
+ *.egg-info/
23
+
24
+ # Test / coverage
25
+ coverage/
26
+ .nyc_output/
27
+ test-results/
28
+ *.lcov
29
+
30
+ # Logs
31
+ *.log
32
+ npm-debug.log*
33
+ pnpm-debug.log*
34
+ yarn-debug.log*
35
+ yarn-error.log*
36
+
37
+ # Env / secrets
38
+ .env
39
+ .env.*
40
+ !.env.example
41
+ *.local
42
+
43
+ # Local CLI state (BKN tokens/config — never commit)
44
+ .bkn/
45
+
46
+ # Editor / OS
47
+ .DS_Store
48
+ .idea/
49
+ .vscode/*
50
+ !.vscode/extensions.json
51
+ *.swp
52
+
53
+ # Package manager artifacts
54
+ .npm/
55
+ *.tgz
56
+
57
+ .cursor/
58
+
59
+ # Claude Code local worktrees
60
+ .claude/
@@ -0,0 +1,600 @@
1
+ Metadata-Version: 2.5
2
+ Name: bkn-osdk
3
+ Version: 0.2.1
4
+ Summary: Generated, read-only ontology SDK for BKN knowledge networks
5
+ Project-URL: Homepage, https://github.com/openbkn-ai/bkn-sdk
6
+ Project-URL: Source, https://github.com/openbkn-ai/bkn-sdk/tree/main/python
7
+ Project-URL: Issues, https://github.com/openbkn-ai/bkn-sdk/issues
8
+ Author: OpenBKN
9
+ License: Apache-2.0
10
+ Keywords: bkn,knowledge-network,ontology,osdk
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: httpx>=0.27
20
+ Provides-Extra: dev
21
+ Requires-Dist: mypy>=1.11; extra == 'dev'
22
+ Requires-Dist: pytest>=8; extra == 'dev'
23
+ Requires-Dist: pyyaml>=6; extra == 'dev'
24
+ Requires-Dist: ruff>=0.6; extra == 'dev'
25
+ Requires-Dist: types-pyyaml>=6; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # bkn-osdk
29
+
30
+ *[中文版](README.zh.md)*
31
+
32
+ A Python SDK for BKN, in two layers.
33
+
34
+ **The ontology layer** is typed, read-only, and specific to **one** knowledge
35
+ network — generated from that network's schema, the way Palantir's OSDK is:
36
+
37
+ ```python
38
+ from bkn.object_types import People
39
+
40
+ People.where(People.age > 30).take(10)
41
+ ```
42
+
43
+ **The platform layer** addresses the platform itself: any REST route by path,
44
+ and any tool the deploy publishes by name.
45
+
46
+ ```python
47
+ bkn_osdk.call("/api/agent-observability/v1/traces", query={"limit": 10})
48
+ bkn_osdk.call_tool(ctx, kn_id, "describe_resource", {"resource_id": "…", ...})
49
+ ```
50
+
51
+ The layers are named for what they address, not for how they were built — one
52
+ happens to be generated and the other hand-written, which is an implementation
53
+ fact rather than something a caller reasons about. Both go through the same
54
+ credentials, the same transports, and the same managed turn, so dropping to the
55
+ platform layer for one call and coming back up keeps the evidence on one chain.
56
+
57
+ This is not a port of the TypeScript SDK. That one wraps eleven backend
58
+ namespaces in HTTP calls, which buys a Python caller nothing that
59
+ `bkn_osdk.call("/api/…")` does not. Generated classes over an ontology cannot be
60
+ replaced by a raw call, which is why they are worth a second language.
61
+
62
+ **Targets platform 0.1.5.** Payloads are taken as the platform sends them, and
63
+ the 0.1.4 line does not send the same ones — it wraps `get_kn_detail`,
64
+ `list_resources`, `list_skills`, `get_object_types` and `list_knowledge_networks`
65
+ in a `result` key, and serves a `semantic-search` route that 0.1.5 withdrew.
66
+ Reading against an older deploy is possible but is the caller's own adaptation,
67
+ not this SDK's.
68
+
69
+ ## Install
70
+
71
+ From PyPI. The version follows the SDK release that ships it, in PEP 440
72
+ spelling (`v0.2.1-rc.2` is `0.2.1rc2`); pip skips prereleases unless pinned or
73
+ asked with `--pre`:
74
+
75
+ ```bash
76
+ pip install "bkn-osdk==0.2.1"
77
+ ```
78
+
79
+ Its only dependency is `httpx`, so an image build is one line:
80
+
81
+ ```dockerfile
82
+ RUN pip install --no-cache-dir "bkn-osdk==0.2.1"
83
+ ```
84
+
85
+ A generated package declares the runtime minor line it was built for
86
+ (`REQUIRES_RUNTIME`, `>=0.2,<0.3` by default) and refuses to import under any
87
+ other. Packages generated by the earlier `0.1.0` runtime declare `>=0.1,<0.2`:
88
+ regenerate them with this runtime when upgrading.
89
+
90
+ To install an unreleased commit instead, the package lives in a subdirectory of
91
+ this repository, which pip addresses directly:
92
+
93
+ ```bash
94
+ pip install "bkn-osdk @ git+https://github.com/openbkn-ai/bkn-sdk@<sha>#subdirectory=python"
95
+ ```
96
+
97
+ **Pin a commit, not a branch.** A direct URL carries no index, so nothing
98
+ resolves a "latest" for you and moving versions means editing that line —
99
+ which is the point. A branch name looks like it saves that edit and does not:
100
+ pip's wheel cache is keyed by URL, so a rebuild against the same branch can
101
+ reinstall the build it already has, reporting success while installing the old
102
+ commit. `direct_url.json` in the installed distribution records which commit
103
+ actually landed.
104
+
105
+ Where git is not installed — the sandbox image is one such place — the archive
106
+ URL takes the same fragment:
107
+
108
+ ```bash
109
+ pip install "bkn-osdk @ https://github.com/openbkn-ai/bkn-sdk/archive/<sha>.zip#subdirectory=python"
110
+ ```
111
+
112
+ ## Generate
113
+
114
+ ```bash
115
+ openbkn auth login https://your-platform # the CLI owns the credential store
116
+ bkn-osdk generate <kn-id> --out ./bkn
117
+ ```
118
+
119
+ `--out` is the package: Python imports it by that directory's name. The command
120
+ authenticates and fetches the whole schema **before** writing anything, and
121
+ refuses a directory it did not produce, so a mistyped path cannot overwrite
122
+ source. Regenerating an unchanged schema rewrites the same bytes, so a real
123
+ regeneration shows up as a reviewable `git diff`.
124
+
125
+ ```text
126
+ bkn/
127
+ __init__.py # KN_ID, BRANCH, OBJECT_TYPES, RELATION_TYPES, METRICS,
128
+ # search(), search_instances()
129
+ object_types.py # one class per object type
130
+ relation_types.py # relation endpoints and their join columns
131
+ metrics.py # one class per metric, with the dimensions it allows
132
+ _meta.py # fingerprint, format version, runtime range
133
+ ```
134
+
135
+ Commit the generated package, pin `bkn-osdk~=0.1`, and run `bkn-osdk check` in
136
+ CI: it compares the live schema against the fingerprint and exits non-zero on
137
+ drift, classifying it as *additive* (new object type or property — existing code
138
+ keeps working) or *breaking* (removed, retyped, or rekeyed).
139
+
140
+ Object-type catalogues may contain summaries. Generation fetches missing object
141
+ properties from each public detail endpoint using the same caller and branch;
142
+ a missing or mismatched detail stops generation instead of silently dropping
143
+ properties. Metric UUIDs starting with digits receive a `Metric` class-name
144
+ prefix; the original ID remains unchanged on requests. Multi-hop traversal keeps
145
+ the starting row's bound connection and caller context unless the call supplies
146
+ an explicit context.
147
+
148
+ ## Credentials
149
+
150
+ Both commands take `--base-url`, `--token` or `--token-file`, `--user` and
151
+ `--insecure`, which beat the environment and the store. That is what a CI job
152
+ wants, and what lets one Makefile generate from two deploys without editing its
153
+ own environment between the calls:
154
+
155
+ ```bash
156
+ bkn-osdk generate <kn-id> --out ./bkn --base-url https://staging --token-file /run/secrets/bkn
157
+ bkn-osdk check ./bkn --base-url https://staging --token-file - # or read it from stdin
158
+ ```
159
+
160
+ A token passed as `--token` is visible in `ps` and in shell history; outside a
161
+ local shell, mount it and use `--token-file`.
162
+
163
+ Nothing is compiled into the generated package: the same KN commonly exists on a
164
+ dev and a prod platform, so it pins only `kn_id` and `branch`. Credentials
165
+ resolve at call time, innermost first:
166
+
167
+ 1. the active `session(...)` scope,
168
+ 2. the process default from `bkn_osdk.configure(...)`,
169
+ 3. `BKN_TOKEN` / `BKN_BASE_URL`,
170
+ 4. `~/.bkn/…/token.json` — the store `openbkn auth login` writes.
171
+
172
+ ```python
173
+ import bkn_osdk
174
+ from bkn_osdk import session
175
+ from bkn.object_types import People
176
+
177
+ People.take(10) # notebook: nothing to configure
178
+
179
+ bkn_osdk.configure(base_url=PROD, token=TOKEN) # one platform, whole process
180
+
181
+ with session(token=user_token): # a scope per request
182
+ People.take(10)
183
+ ```
184
+
185
+ The scope lives in a `ContextVar`, so threads and asyncio tasks each carry their
186
+ own — a multi-tenant server cannot leak one user's token into another's request.
187
+
188
+ A stored session refreshes itself when its access token expires mid-process. If
189
+ the platform rotates the refresh token, the replacement is written back to the
190
+ store, because spending the CLI's credential and keeping the new one would force
191
+ a re-login.
192
+
193
+ ## Sign users in to your app
194
+
195
+ An app that acts as its own users needs an OAuth token per user and has to keep
196
+ it alive. `bkn_osdk.oauth` does the sign-in (PKCE, the loopback callback, the
197
+ code exchange) and the refresh grant, and `configure` / `session` refresh the
198
+ tokens it returns the way they refresh a stored session:
199
+
200
+ ```python
201
+ import bkn_osdk
202
+ from bkn_osdk import oauth
203
+
204
+ # Once: a public client for the app (or ask an operator for one).
205
+ client = oauth.register_client(PLATFORM, "My app")
206
+
207
+ # A CLI, desktop app, script or notebook: opens the browser, waits on
208
+ # http://127.0.0.1:8765/callback, exchanges the code.
209
+ tokens = oauth.login(PLATFORM, client_id=client.client_id)
210
+ save(tokens.to_dict()) # keep it; see the caveats below
211
+
212
+ bkn_osdk.configure(
213
+ base_url=PLATFORM,
214
+ tokens=tokens,
215
+ client_id=client.client_id, # refresh must name the client the tokens were issued to
216
+ on_refresh=lambda fresh: save(fresh.to_dict()),
217
+ )
218
+ People.take(10) # refreshed shortly before expiry and on a 401
219
+ ```
220
+
221
+ A server with many users binds each request's tokens instead; the scope is a
222
+ `ContextVar`, so one user's tokens never reach another's request:
223
+
224
+ ```python
225
+ with bkn_osdk.session(tokens=tokens_for(user), client_id=CLIENT_ID, on_refresh=save_for(user)):
226
+ People.take(10)
227
+ ```
228
+
229
+ A web app receives the callback in its own handler. Keep the `AuthorizationRequest`
230
+ (it holds the `state` and PKCE verifier) in the user's server-side session:
231
+
232
+ ```python
233
+ req = oauth.authorization_request(PLATFORM, CLIENT_ID, redirect_uri="https://app.example/cb")
234
+ # redirect the browser to req.url; later, in the /cb handler:
235
+ tokens = oauth.exchange_code(PLATFORM, req, code=params["code"], state=params["state"])
236
+ ```
237
+
238
+ On a machine without a browser, `oauth.device_login(PLATFORM)` prints a code to
239
+ approve elsewhere (pass `on_prompt` to show it yourself); it signs in as the
240
+ CLI's `openbkn-sdk` client, which is also `configure`'s default `client_id`.
241
+ `oauth.refresh(PLATFORM, tokens, client_id=…)` is the refresh grant on its own.
242
+ A sign-in that did not happen raises `AuthError`, with `error` and `description`.
243
+
244
+ Four things to know:
245
+
246
+ 1. **Save the refresh token from every callback.** Each refresh spends the old
247
+ one and the platform issues a new one; `on_refresh` receives the whole new
248
+ set. An app that kept only the first must sign the user in again on its next
249
+ start.
250
+ 2. **Never refresh one user's tokens from two processes.** The platform treats a
251
+ reused refresh token as theft and revokes the whole chain, signing the user
252
+ out everywhere. One process is safe (concurrent requests share a single
253
+ refresh); several instances need a shared store with a lock, or one place
254
+ that does the refreshing.
255
+ 3. **A failed refresh means sign in again.** The refresh token expired, was
256
+ revoked, or the user disconnected the app under connected clients. The
257
+ request then fails with its original 401 and a hint saying so.
258
+ 4. **`login` serves loopback callbacks only.** A web app uses
259
+ `authorization_request` + `exchange_code` from its own handler, and an
260
+ operator must allow a non-loopback callback origin for the client.
261
+ `register_client` raises `AuthError` where the platform has no
262
+ self-registration (0.2.0 has none; it can also be disabled): use
263
+ `device_login`, or ask an operator.
264
+
265
+ ## Query
266
+
267
+ ```python
268
+ from decimal import Decimal
269
+ from bkn.object_types import Order
270
+
271
+ Order.get(10357) # Order | None
272
+ Order.count() # respects the filter
273
+ Order.where(Order.order_status == "pending_payment").count()
274
+ Order.where((Order.total_amount > Decimal("10000")) & Order.paid_at.exists()).order_by(
275
+ Order.total_amount.desc()
276
+ ).select(Order.order_no, Order.total_amount).take(20)
277
+
278
+ for order in Order.iterate(page_size=500): # cursor paging, or limit/offset on older deploys
279
+ ...
280
+
281
+ page = Order.objects().page(limit=100)
282
+ next_page = Order.objects().page(limit=100, cursor=page.next_cursor) # None on the last page
283
+ ```
284
+
285
+ The current ontology-query contract pages by an opaque cursor: the response's
286
+ `paging.next_cursor` is sent back as `cursor` with the same query, and is `null`
287
+ on the last page. Deploys built before that change (0.1.5 releases in the field)
288
+ return no `paging` block but honour `offset`, so `iterate()` follows the cursor
289
+ where the first response carries one and otherwise falls back to `limit`/`offset`,
290
+ stopping on a short page. `page(offset=...)` still sends `offset`; passing both
291
+ `offset` and `cursor` raises.
292
+
293
+ `~` negates a filter where the platform can express it: comparison operators
294
+ invert into each other, `in`/`like`/`exist` have paired negatives, and `and`/`or`
295
+ negate by De Morgan. `match` and `knn` have no opposite in the operator enum, so
296
+ `~` on them raises rather than inventing one.
297
+
298
+ `Order.total_amount` is a `PropertyRef` used to build filters; `order.total_amount`
299
+ is a `Decimal`. One name, two jobs — and both typed, so `People.age > 30` checks
300
+ and `People.name > 30` does not.
301
+
302
+ Decoding follows the declared type: a `decimal` arrives as a JSON string and
303
+ becomes an exact `Decimal`, a `datetime` keeps its offset. A property the query
304
+ did not return raises rather than reading as `None`.
305
+
306
+ Three things about the platform's filter grammar are worth knowing, all verified
307
+ against a live deploy:
308
+
309
+ - `like` semantics belong to the **backing resource**, and the two deploys probed
310
+ disagree: a Postgres-backed object type read the value as a plain substring
311
+ (`like("2026")` matched, `like("2026%")` did not), while a Vega-catalog-backed
312
+ one read it as a SQL pattern (`like("%FIFA%")` matched, `like("FIFA")` did
313
+ not). The value is sent verbatim; a wrong guess returns zero rows, not an
314
+ error, so try both against your own object type.
315
+ - `match` is in the operator enum but a Postgres-backed object type answers 500.
316
+ - `limit` is 1–10000, and `total_count` is omitted entirely when nothing matched.
317
+
318
+ ## Aggregate
319
+
320
+ There is no `sum()` or `group_by()` over an object set, because there is no
321
+ endpoint for one: an instance query takes a condition, a sort, a limit, a cursor (or
322
+ offset) and a property selection, and `need_total` returns a row count. Pulling every row back
323
+ to aggregate client-side would be a lie dressed as an API.
324
+
325
+ The platform's aggregation surface is published, versioned **Object Metrics V1**.
326
+ The generated class pins a published version and permits only runtime choices
327
+ declared by that definition:
328
+
329
+ ```python
330
+ from bkn.metrics import Gmv
331
+
332
+ Gmv.query(
333
+ time={"start": 1751328000000, "end": 1753920000000}, # unix ms
334
+ group_by=["channel_id"],
335
+ )
336
+ ```
337
+
338
+ `Gmv.__version__` and `Gmv.__dimensions__` come from the published definition.
339
+ An undeclared or ambiguous dimension is refused locally. `start`/`end` must be
340
+ integer Unix milliseconds; old `instant` and `step` fields are not accepted.
341
+ `analysis_dimensions` is retained as a compatibility keyword, but both forms
342
+ send the canonical runtime field `group_by`.
343
+ Without an interaction, the transport is the bare ontology-query request
344
+ `POST …/object-metrics/{metric_id}/data?version=N`. Inside
345
+ `session(traced=True)`, or when the context already carries conversation and
346
+ interaction IDs, it uses Context Loader's managed `query_metric` tool instead.
347
+ The same query fields and generated branch are forwarded, the managed response
348
+ is normalized to the same V1 `model`, `entries`, `status`, and `warnings`
349
+ envelope, and its receipt is accumulated on the interaction. Lifecycle
350
+ `bkn_context` is never added to ontology-query's strict request body. Fixed
351
+ business qualifiers and statistical grain cannot be overridden at query time.
352
+
353
+ ## Traverse
354
+
355
+ One hop is a filter on the target object type, using the join columns the schema
356
+ declares — no session, no second grammar:
357
+
358
+ ```python
359
+ order = Order.get(10357)
360
+ order.order_user.take(10) # User.where(User.user_id == order.user_id)
361
+ ```
362
+
363
+ Several hops have to be joined server-side, which rides the REST subgraph
364
+ endpoint — still an ordinary read, no session:
365
+
366
+ ```python
367
+ Order.order_user.then(User.user_address).of(order, step_limit=20)
368
+ ```
369
+
370
+ That endpoint's seed-based form walks *every* relation up to the path length, so
371
+ the requested chain is selected from the paths it reports back, and a `where()`
372
+ on the far end is applied locally (it filters only its starting object type).
373
+ Three hops is its ceiling.
374
+
375
+ ## Evidence
376
+
377
+ Reads through `session(traced=True)` go over MCP inside one managed interaction —
378
+ opened on the first read, reused, finished on exit — and come back with a
379
+ receipt: its status, evidence durability, the evidence refs, and the business
380
+ refs resolved down to property granularity. That is the slim receipt a
381
+ completed call carries (foundry #1417); Core keeps the full record, read with
382
+ `openbkn trace interactions operations <interaction-id>`. A receipt whose status
383
+ is `failed` raises `ToolError("receipt_failed")`; a `pending` one comes back
384
+ with no value.
385
+
386
+ ```python
387
+ with bkn_osdk.session(traced=True):
388
+ page = Order.objects().page(limit=10)
389
+ page.receipt["receipt_status"]
390
+ page.rows[0].__receipt__ # the same receipt, on each row it accounts for
391
+ ```
392
+
393
+ `order_by` travels to the tool as `sort`; opaque `cursor` continuation also
394
+ stays on MCP and keeps receipts. The tool takes no `need_total` and none of
395
+ the REST query flags (`options(...)`), so a query
396
+ wanting one of those takes the REST path even inside a traced scope — carrying
397
+ the scope's turn, so it is still recorded, but answering without an in-band
398
+ receipt. Untraced reads take REST throughout, which is faster, and carry no
399
+ receipt.
400
+
401
+ Which calls need a turn is a matter of surface, not of tool. The capability
402
+ surface — the MCP tools and their REST twins under `/kn/` — refuses a
403
+ context-free call: every tool in the catalog bar the two lifecycle ones declares
404
+ `bkn_context` required. So `search`, `search_instances` and any direct
405
+ `call_tool` carry a turn on the first attempt, joining the scope's own where
406
+ there is one and opening a short-lived turn where there is not. The instance
407
+ and subgraph read routes under `ontology-query`, and an untraced metric read,
408
+ serve a bare request and mint nothing. A metric read that already has a turn
409
+ deliberately uses Context Loader's managed tool; it never retries a strict
410
+ ontology-query body with `bkn_context` added.
411
+
412
+ A caller that already has a turn passes it in without a traced scope, which is
413
+ how the sandbox does it — `BKN_CONVERSATION_ID` and `BKN_INTERACTION_ID` in the
414
+ environment, inherited with no argument passing, and never finished by this SDK
415
+ because it does not own them.
416
+
417
+ ## Search
418
+
419
+ Search is network-level — its request has no object-type dimension — so it lives
420
+ on the package. It calls the `search_schema` MCP tool, the same one the
421
+ TypeScript SDK calls, and answers with the object, relation, action and metric
422
+ types a question touches:
423
+
424
+ ```python
425
+ import bkn
426
+
427
+ bkn.search("who owns supply chain")
428
+ bkn.search("orders and their buyers", max_concepts=3, search_scope={"include_action_types": False})
429
+ ```
430
+
431
+ `search_instances` asks the other question — not which types a question touches
432
+ but which rows answer it — over the `search_instance` tool. Recall runs two
433
+ channels, vector and full text, so only properties whose `condition_operations`
434
+ include `match` or `knn` take part and a type with no index contributes nothing:
435
+
436
+ ```python
437
+ bkn.search_instances("Lionel Messi")
438
+ bkn.search_instances("欠款最多的客户", object_types=["customer"], rerank=True)
439
+ ```
440
+
441
+ It is where to start when neither the type name nor the field name is known.
442
+ Once both are, a typed query is cheaper and exact.
443
+
444
+ ## The platform layer
445
+
446
+ Everything the generated classes do not cover — every other network, every
447
+ capability, every route — is reached here:
448
+
449
+ ```python
450
+ bkn_osdk.call("/api/agent-observability/v1/traces", query={"limit": 10}) # REST, by path
451
+ bkn_osdk.call("/api/safe/v1/me/api-keys") # anything else
452
+
453
+ ctx = bkn_osdk.resolve_context()
454
+ bkn_osdk.tool_catalog(ctx) # what this deploy publishes
455
+ bkn_osdk.call_tool(ctx, kn_id, "run_sql", {"kn_id": kn_id, "sql": …, "bkn_context": …})
456
+ ```
457
+
458
+ `call_tool` is the raw seam: it takes the arguments the catalog declares and
459
+ returns what the tool answered, with no shape of its own — including the
460
+ envelope, which differs by build. Passing a `bkn_context` is the caller's job
461
+ there, and `ensure_interaction` is how to get one:
462
+
463
+ ```python
464
+ from bkn_osdk.lifecycle import ensure_interaction
465
+
466
+ with ensure_interaction(ctx, kn_id) as turn:
467
+ arguments = {"kn_id": kn_id, "bkn_context": turn.bkn_context}
468
+ bkn_osdk.call_tool(ctx, kn_id, "list_skills", arguments)
469
+ ```
470
+
471
+ ### Who is calling
472
+
473
+ `me()` returns the record of the user the current credential belongs to, read
474
+ live from bkn-safe:
475
+
476
+ ```python
477
+ user = bkn_osdk.me()
478
+ user.id, user.account, user.name, user.email, user.telephone
479
+ user.departments, user.roles # department ids; role names
480
+ ```
481
+
482
+ Inside a Function the credential is the invoking caller's own, so `me()` names
483
+ whoever ran it — for a Function behind an action, the person who triggered the
484
+ action. It is a REST call through `BKN_BASE_URL`. If that is empty in the
485
+ sandbox, it raises `InputError` ("No base URL"), like every other OSDK call;
486
+ if it is set but the sandbox network does not allow the gateway, it fails with
487
+ a connection error.
488
+
489
+ ### Named functions for the capability routes
490
+
491
+ The context-loader surface — 25 routes under `/api/agent-retrieval/v1/kn/` — is
492
+ also generated, from foundry's own OpenAPI:
493
+
494
+ ```python
495
+ from bkn_osdk import kn
496
+
497
+ kn.list_resources(KN_ID)
498
+ kn.run_sql(KN_ID, "SELECT COUNT(*) AS n FROM {{d9hff…}}")
499
+ kn.run_cypher(
500
+ KN_ID,
501
+ "MATCH (o:order)-[:rel_order_customer]->(c:customer) RETURN c.city AS city, count(*) AS n",
502
+ )
503
+ kn.query_object_instance(KN_ID, "order", limit=10, response_format="json")
504
+ ```
505
+
506
+ Three things the wrapper knows and a raw call does not: **which arguments go in
507
+ the query string** rather than the body (`query_object_instance` and both
508
+ subgraph routes split them, and sending `kn_id` in the body answers
509
+ `Public.NotFound` — "对象不存在" — which reads as a missing object type); which
510
+ arguments are required; and that every route on this surface needs a turn, so
511
+ `bkn_context` is attached rather than being a parameter. `kn_id` is the first
512
+ argument everywhere, because the turn belongs to a network even where the route
513
+ does not send one.
514
+
515
+ The spec is frozen in `contracts/kn-rest.json` and regenerated by
516
+ `scripts/capture_kn_contract.py`; `bkn_osdk/kn.py` is committed, and a test
517
+ fails if regenerating would change it. `call_tool` remains for the tools with no
518
+ REST twin, and for arguments a route accepts but does not publish.
519
+
520
+ `search` and `search_instances` are the same thing wrapped by hand.
521
+ [`examples/platform/`](examples/platform) runs the whole surface.
522
+
523
+ ## Examples
524
+
525
+ Six runnable scripts under [`examples/`](examples): [`credentials.py`](examples/credentials.py)
526
+ (which platform, as whom, and where that was decided), three under `ontology/`
527
+ (explore, query, evidence) and two under `platform/` (the tool surface, the
528
+ sandbox).
529
+
530
+ Instance and subgraph query options use `ignore_local_index=True` to bypass a
531
+ Table resource local index and read its original source. The default remains
532
+ `False`; full-text and vector conditions require the index and return 400 when
533
+ bypassed. `Page.query_source` reports `"local_index"`, `"source"`, or `None` if no
534
+ resource was queried. It replaces `Page.search_from_index`; the old
535
+ `ignoring_store_cache` option is removed. Upgrade with the matching Foundry #844
536
+ server change and restart cursors created before the upgrade.
537
+
538
+ The generated `kn.query_object_instance(...)` also replaces `search_after` with
539
+ `cursor`: pass the previous response's opaque `cursor` string unchanged for the
540
+ next page. On the first page, omit `cursor` or pass `cursor=None`. Remove any
541
+ `search_after` argument, including `search_after=None`; that keyword is no longer
542
+ accepted. `cursor` and `offset` are mutually exclusive.
543
+
544
+ ## Upgrades
545
+
546
+ | What moves | You run | Guard |
547
+ | --- | --- | --- |
548
+ | runtime | `pip install -U bkn-osdk` | `REQUIRES_RUNTIME`, checked at import |
549
+ | KN schema | `bkn-osdk generate` again | `SCHEMA_FINGERPRINT` + `bkn-osdk check` |
550
+ | emitted code shape | regenerate | `FORMAT_VERSION` |
551
+
552
+ A runtime upgrade never requires regeneration — that is why generated packages
553
+ carry declarations and no logic. `configure(check_schema=True)` adds one
554
+ fingerprint check on the first query, for callers who would rather fail loudly
555
+ than read a stale attribute.
556
+
557
+ ## Not in this release
558
+
559
+ Writes and action execution, aggregation over an object set (no endpoint exists —
560
+ see above), typed wrappers for the catalog's MCP-only tools — the lifecycle
561
+ pair and the sandbox tools, reachable today only through `call_tool` — an async
562
+ client, and offline generation from a local
563
+ `.bkn` directory. Each has a section in
564
+ [the design](../docs/superpowers/specs/2026-08-11-python-osdk-design.md)
565
+ describing the shape it takes when it lands.
566
+
567
+ ## Development
568
+
569
+ ```bash
570
+ uv venv && uv pip install -e ".[dev]"
571
+ python -m pytest -q # unit tests, all offline
572
+ python -m mypy # strict
573
+ python -m ruff check . && python -m ruff format --check .
574
+ ```
575
+
576
+ Wire behaviour is pinned by exchanges recorded from a live platform under
577
+ `tests/fixtures/`; re-record with `scripts/capture_schema_fixtures.py` and
578
+ `scripts/capture_query_fixtures.py` when a contract moves.
579
+
580
+ Recorded fixtures cannot notice a route being withdrawn, so there is a live
581
+ suite as well. It generates a package for a real network, type-checks it, reads
582
+ through it and closes a real interaction — and it skips itself unless asked for:
583
+
584
+ ```bash
585
+ BKN_E2E=1 BKN_E2E_KN=ecommerce_ops_bkn_public BKN_BASE_URL=https://your-platform \
586
+ python -m pytest tests/e2e -q
587
+ ```
588
+
589
+ `BKN_E2E_OBJECT_TYPE` pins the class under test; without it the suite picks a
590
+ populated type that has relations to walk. Credentials resolve as they do for
591
+ any caller, so `openbkn auth login` is enough. Run it against more than one
592
+ deploy: the two this SDK was built against have disagreed about route names,
593
+ metrics, and which reads their data resources can serve.
594
+
595
+ ### Function Trace parent
596
+
597
+ When the sandbox supplies `BKN_PARENT_OPERATION_ID` together with its conversation
598
+ and interaction IDs, internal reads send it as `bkn_context.parent_operation_id`.
599
+ Each read keeps its own operation and receipt. An explicit override to a different
600
+ turn does not inherit the sandbox's parent. Business functions need no new argument.