diff --git a/docs/reference/client.md b/docs/reference/client.md
index c9fe9dc..012a084 100644
--- a/docs/reference/client.md
+++ b/docs/reference/client.md
@@ -74,8 +74,9 @@ let api = blocking::create_api_service();
```
The blocking client covers most of the async one, not all of it. It has no `subscriptions`, and
-it lacks `time_series.filter`, `resources.filter`, `resources.get_by_id`,
-`resources.fetch_nearest`, `events.get`, `events.update` and `events.count`. `api.async_api()`
+it lacks `time_series.filter`, `time_series.listen_datapoints`, `resources.filter`,
+`resources.get_by_id`, `resources.fetch_nearest`, `events.get`, `events.update` and
+`events.count`. `api.async_api()`
hands you the async service for those.
@@ -96,26 +97,22 @@ hands you the async service for those.
| Subscriptions | `client.subscriptions()` | `client.subscriptions` | `api.subscriptions` |
| Edges | `client.edges()` | `client.edges` | `api.edges` |
| [Labels](./labels) | `client.labels()` | `client.labels` | `api.labels` |
+| [Tenant](./tenant) | `client.tenant()` | `client.tenant` | `api.tenant` |
Assets and functions are the `ASSET`- and `FUNCTION`-labelled resources, typed. A service being
in a client does not mean every call is: each reference page has a "What each client covers"
table.
-The Java client adds three more services:
+The Java client adds two more services:
| Service | Java | Covers |
| --- | --- | --- |
| [Policies](./policies) | `client.policies()` | `/policies`, including the naming dry-run |
| [Governance templates](./policies#governance-templates) | `client.governance()` | `/governance/templates` |
-| [Tenant](./tenant) | `client.tenant()` | `/tenant/features` and `/tenant/settings` |
-:::note Two endpoints no client wraps, on purpose
+:::note One endpoint no client wraps, on purpose
`GET /stats` is internal to the console and marked hidden: it is not part of the published
-contract, so nothing should call it. The browser datapoint tail
-`/timeseries/datapoints/listen` authenticates with a `?token=` query parameter, which is for a
-page that cannot set a header. Server-side code wants the durable equivalent instead,
-[`subscriptions().listen`](./subscriptions#live-delivery), which authenticates the upgrade with
-the usual `Authorization` header and survives a reconnect.
+contract, so nothing should call it.
:::
## Authentication
diff --git a/docs/reference/datasets.md b/docs/reference/datasets.md
index 092f5ab..f39ccde 100644
--- a/docs/reference/datasets.md
+++ b/docs/reference/datasets.md
@@ -71,12 +71,16 @@ api.datasets.create(&vec![dataset]).await?;
## Look up & delete
+`by_ids` takes ids and external ids together and leaves out what it cannot find rather than
+failing. `GET /datasets/{id}` reads one data set by numeric id and answers `404` for a miss.
+
```java
DataWrapper some = client.datasets()
.byIds(List.of(IdCollection.createFromExternalId("plant_a")));
+DataWrapper one = client.datasets().getById(5677892L);
client.datasets().delete(List.of(IdCollection.createFromExternalId("plant_a")));
```
@@ -86,6 +90,7 @@ client.datasets().delete(List.of(IdCollection.createFromExternalId("plant_a")));
```python
some = client.datasets.by_ids(["plant_a"])
+one = client.datasets.get_by_id(5677892)
client.datasets.delete(["plant_a"])
```
@@ -96,6 +101,7 @@ client.datasets.delete(["plant_a"])
use intellistream_datahub_sdk::generic::IdAndExtId;
let some = api.datasets.by_ids(&vec![IdAndExtId::from_external_id("plant_a")]).await?;
+let one = api.datasets.get_by_id(5677892).await?;
api.datasets.delete(&vec![IdAndExtId::from_external_id("plant_a")]).await?;
```
@@ -241,6 +247,7 @@ A changed grant therefore takes effect within about a minute, without a new toke
| Operation | Java | Python | Rust |
| --- | --- | --- | --- |
| Create | `datasets().create` | `datasets.create` | `datasets.create` |
+| Get by numeric id | `datasets().getById` | `datasets.get_by_id` | `datasets.get_by_id` |
| Look up by id / external id | `datasets().byIds` | `datasets.by_ids` | `datasets.by_ids` |
| List (`GET /datasets`) | `datasets().list(limit)` | `datasets.list(limit=None)` | `datasets.list(limit)` |
| Filter | `datasets().filter` | `datasets.filter` | `datasets.filter` |
diff --git a/docs/reference/resources.md b/docs/reference/resources.md
index 62794ba..db2dc73 100644
--- a/docs/reference/resources.md
+++ b/docs/reference/resources.md
@@ -896,9 +896,13 @@ The nodes and relationships it creates count against their own daily quotas thro
ordinary create path. A reverse proxy in front of the API has to pass the upload through
unbuffered and uncapped, as the shipped nginx examples do for `/resources/import`.
-The Java client wraps the pair. `export` hands back the file as bytes and `importGraph` takes
-the same bytes, so moving a sub-graph between two tenants is two calls and no temporary file
-unless you want one:
+Every client wraps the pair. Export hands back the file as bytes and import takes the same
+bytes, so moving a sub-graph between two tenants is two calls and no temporary file unless you
+want one. Python and Rust also stream either end to or from a path, for a file too large to hold
+in memory.
+
+
+
```java
import ai.intellistream.datahub.api.graphtransfer.GraphImportResult;
@@ -914,7 +918,45 @@ System.out.println(result.nodesCreated() + " created, "
`GraphImportResult` is a record, so its fields read as `result.nodesCreated()` and so on, one
per key of the JSON above.
-Python and Rust call the endpoints over HTTP with the bearer token the client already holds:
+
+
+
+```python
+graph = source.resources.export_graph(5677892) # bytes
+
+result = target.resources.import_graph(graph)
+print(result.nodes_created, "created,",
+ result.nodes_skipped_existing, "already there,",
+ result.nodes_skipped_timeseries, "to create as series first")
+
+# Or through a file, never held whole in memory:
+source.resources.export_graph_to_path(5677892, "plant_oslo.dhgraph")
+target.resources.import_graph_from_path("plant_oslo.dhgraph")
+```
+
+
+
+
+```rust
+let graph: Vec = source.resources.export_graph(5677892).await?;
+
+let result = target.resources.import_graph(graph).await?;
+println!("{} created, {} already there, {:?} to create as series first",
+ result.nodes_created, result.nodes_skipped_existing, result.nodes_skipped_timeseries);
+
+// Or through a file, never held whole in memory:
+source.resources.export_graph_to_path(5677892, "plant_oslo.dhgraph").await?;
+target.resources.import_graph_from_path("plant_oslo.dhgraph").await?;
+```
+
+
+
+
+The export walks the graph, which settles a moment after a write. A component created and
+exported straight away can come back short; read it with `fetch_related` first when that
+matters.
+
+Over plain HTTP, send the bearer token the client already holds:
```bash
curl -fsS -H "Authorization: Bearer $TOKEN" \
@@ -1056,33 +1098,41 @@ DataWrapper page = client.functions().filter(criteria);
```python
# entity objects, external-id strings, or numeric ids
some = client.functions.by_ids(["fn_rolling_average"])
+
+page = client.functions.filter(labels=["AGGREGATION"]) # a Page; page.next_cursor pages on
+hits = client.functions.search("rolling average")
```
+`filter` takes the criteria as keywords or a prepared `FunctionFilter` as `filter=`, never both,
+plus `limit`, `sort_by`, `sort_order` and `cursor`. `search` takes `query`, an optional
+`FunctionFilter` and `limit`.
+
```rust
+use intellistream_datahub_sdk::filters::NodeFilter;
+use intellistream_datahub_sdk::functions::{FunctionFilter, FunctionFilterForm};
use intellistream_datahub_sdk::generic::IdAndExtId;
let some = api.functions.by_ids(&vec![
IdAndExtId::from_external_id("fn_rolling_average"),
]).await?;
+
+let criteria = FunctionFilter {
+ node: NodeFilter { labels: Some(vec!["AGGREGATION".into()]), ..Default::default() },
+ data_set_id: None,
+};
+let page = api.functions.filter(&FunctionFilterForm::new(criteria)).await?;
+let hits = api.functions.search_by_query("rolling average").await?;
```
+`FunctionFilterForm` carries `limit` and a `PageRequest` for `sort` and `cursor`; `search` takes a
+`SearchAndFilterForm`, and `search_by_query` is the shorthand for a bare phrase.
+
-:::note Python and Rust are catching up
-`filter` and `search` on functions are in the Java client today. Python and Rust will get them
-under the names their `assets` service already uses: `functions.filter(...)` taking keyword
-criteria in Python and a filter form in Rust, and `functions.search(query, ...)`. Until then, ask
-[`resources.filter`](#filter) narrowed to the `function` node type.
-
-In those two clients `functions.by_ids` still reads the listing and matches on the client, so it
-only sees the newest 10 000 functions. It moves to `POST /functions/byids` in the same change;
-the call itself stays as it is.
-:::
-
## What each client covers {#client-coverage}
| Operation | Java | Python | Rust |
@@ -1097,7 +1147,7 @@ the call itself stays as it is.
| Filter | `resources().filter` | `resources.filter` | `resources.filter` |
| Traverse (`fetch-related`) | `resources().fetchRelated` | `resources.fetch_related` | `resources.fetch_related` |
| Nearest N (`fetch-nearest`) | `resources().fetchNearest` | `resources.fetch_nearest` | `resources.fetch_nearest` |
-| [Export / import a graph](#graph-transfer) | `resources().export` / `importGraph` | HTTP only | HTTP only |
+| [Export / import a graph](#graph-transfer) | `resources().export` / `importGraph` | `resources.export_graph` / `import_graph`, plus `_to_path` / `_from_path` | `resources.export_graph` / `import_graph`, plus `_to_path` / `_from_path` |
Relations have their own client surface in all three clients, `edges()` in Java, `edges` in
diff --git a/docs/reference/subscriptions.md b/docs/reference/subscriptions.md
index b67d801..1167fb3 100644
--- a/docs/reference/subscriptions.md
+++ b/docs/reference/subscriptions.md
@@ -254,6 +254,60 @@ Ack a message only after you've durably handled it. If your process dies before
the server redelivers it, so make your handler idempotent.
:::
+## A live tail without a subscription {#live-tail}
+
+`/timeseries/datapoints/listen` streams the datapoints written to a set of series with no
+subscription behind it: nothing to create first, nothing to ack, nothing left behind when the
+socket closes. It is the lighter tool and the weaker one. It starts at the latest point, keeps no
+cursor, and does not replay what was written while the socket was down, so a reader that must
+see every point uses [live delivery](#live-delivery) instead.
+
+The token travels as a WebSocket subprotocol, not a header, because a browser cannot set one on
+the handshake: offer `datahub.bearer.` and `datahub.v1`, and the server echoes `datahub.v1`.
+An `externalIds=a,b,c` query parameter seeds the set, and
+`{"action": "set" | "subscribe" | "unsubscribe", "externalIds": [...]}` changes it. Every change
+is narrowed to the series whose data set you may read, silently: an id you cannot read, or that
+does not exist, is dropped rather than refused. A frame is
+`{"datapoints": [{"externalId", "valueType", "timestamp", "value"}, ...]}`, `value` a string.
+
+A bad token or a missing role is refused after the upgrade, with a **1008** close; a connection
+over the [WebSocket cap](./limits#websockets) gets the error frame described there first.
+
+
+
+
+The Java client does not wrap the tail. Use [live delivery](#live-delivery).
+
+
+
+
+```python
+with client.timeseries.listen_datapoints(["engine_temperature"]) as tail:
+ for point in tail:
+ print(point.external_id, point.timestamp, point.value)
+```
+
+`subscribe`, `unsubscribe` and `set_timeseries` change the set while it runs. On an
+`AsyncDataHubClient` it returns a `DatapointListenerAsync`, driven with `async for`.
+
+
+
+
+```rust
+let mut tail = api.time_series.listen_datapoints(&["engine_temperature"]).await?;
+while let Some(point) = tail.next().await {
+ let point = point?;
+ println!("{} {} {}", point.external_id, point.timestamp, point.value);
+}
+```
+
+`subscribe`, `unsubscribe` and `set_timeseries` change the set while it runs. A dropped
+connection is reopened with a fresh token and the current set; a refusal is returned as an
+error instead of retried.
+
+
+
+
## What each client covers {#client-coverage}
| Operation | Java | Python | Rust |
@@ -263,5 +317,6 @@ the server redelivers it, so make your handler idempotent.
| Filter | `subscriptions().filter` | `subscriptions.filter(form=None, *, timeseries=, limit=, sort=)` | `subscriptions.filter` |
| Delete | `subscriptions().delete` | `subscriptions.delete` | `subscriptions.delete` |
| Live delivery | `subscriptions().listen` | `subscriptions.listen` (`SubscriptionListenerAsync` with `async for` on the async client) | `subscriptions.listen` |
+| [Live tail](#live-tail), no subscription | — | `timeseries.listen_datapoints` | `time_series.listen_datapoints` |
diff --git a/docs/reference/tenant.md b/docs/reference/tenant.md
index c7f6e42..100c0ac 100644
--- a/docs/reference/tenant.md
+++ b/docs/reference/tenant.md
@@ -3,16 +3,23 @@ sidebar_position: 12
title: Tenant
---
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
# Tenant
Which optional features the tenant your token belongs to may use, and the settings your
-organization administers for itself. The Java client reaches them through `client.tenant()`.
+organization administers for itself: `client.tenant()` in Java, `client.tenant` in Python and
+`api.tenant` in Rust. Every answer is a plain object, not an `items` list.
## Feature flags {#features}
`GET /tenant/features` says which optional features the tenant may use. A disabled feature's
endpoints answer `404` or `403`.
+
+
+
```java
import ai.intellistream.datahub.tenant.TenantFeatures;
@@ -22,16 +29,38 @@ if (features.isFilesEnabled()) {
}
```
-| Wire field | Java | Feature |
-| --- | --- | --- |
-| `files` | `isFilesEnabled()` | The [file storage](./files) endpoints. |
-| `policy` | `isPolicyFeatureEnabled()` | [Policies and governance](./policies). |
-| `streaming` | `isStreamingFeatureEnabled()` | Streaming ingest. |
-| `chat` | `isChatFeatureEnabled()` | The AI assistant, configured below. |
+
+
+
+```python
+features = client.tenant.features()
+if features.files:
+ ... # show the file browser
+```
+
+
+
+
+```rust
+let features = api.tenant.features().await?;
+if features.files {
+ // show the file browser
+}
+```
+
+
+
+
+| Wire field | Java | Python and Rust | Feature |
+| --- | --- | --- | --- |
+| `files` | `isFilesEnabled()` | `files` | The [file storage](./files) endpoints. |
+| `policy` | `isPolicyFeatureEnabled()` | `policy` | [Policies and governance](./policies). |
+| `streaming` | `isStreamingFeatureEnabled()` | `streaming` | Streaming ingest. |
+| `chat` | `isChatFeatureEnabled()` | `chat` | The AI assistant, configured below. |
`policy`, `streaming` and `chat` fall back to the deployment default when the tenant's
-configuration does not carry them; `files` reads absent as off. The Java accessors return
-primitive booleans.
+configuration does not carry them; `files` reads absent as off. Every client reads each flag as
+a plain boolean.
## What you may change {#settings-permissions}
@@ -43,6 +72,9 @@ imply read. The one scope is `llm`.
`GET /tenant/settings/permissions` says which grants you hold, with wildcards resolved, so
every scope the platform knows is listed by name. The endpoint is ungated.
+
+
+
```java
import ai.intellistream.datahub.models.tenant.SettingsPermission;
@@ -52,12 +84,36 @@ llm.read();
llm.write();
```
+
+
+
+```python
+grants = client.tenant.settings_permissions() # dict[str, SettingsPermission]
+grants["llm"].read
+grants["llm"].write
+```
+
+
+
+
+```rust
+let grants = api.tenant.settings_permissions().await?; // HashMap
+grants["llm"].read;
+grants["llm"].write;
+```
+
+
+
+
## Model configuration {#llm-settings}
`GET /tenant/settings/llm` and `PUT /tenant/settings/llm` read and replace the model your
organization's assistant runs on. The API key is never returned: `apiKeySet` says whether one
is stored, and `configured` says whether the settings amount to a model that can be called.
+
+
+
```java
import ai.intellistream.datahub.models.tenant.TenantLlmSettings;
import ai.intellistream.datahub.models.tenant.TenantLlmSettingsForm;
@@ -73,8 +129,50 @@ TenantLlmSettings saved = client.tenant().updateLlmSettings(new TenantLlmSetting
```
`TenantLlmSettings` and `TenantLlmSettingsForm` are records, so the fields read as
-`current.provider()` and so on. `apiKey` is write-only: it is carried on the form and never
-returned by a read.
+`current.provider()` and so on.
+
+
+
+
+```python
+current = client.tenant.llm_settings()
+current.api_key_set
+current.configured
+
+# Keywords rather than a form. Every one you leave out is sent unset, so carry the rest over;
+# api_key left out keeps the stored credential.
+saved = client.tenant.update_llm_settings(
+ provider=current.provider, model="the-model-id", base_url=current.base_url,
+ reasoning_effort=current.reasoning_effort, effort="high",
+ turn_timeout=current.turn_timeout, max_output_tokens=current.max_output_tokens,
+ max_iterations=current.max_iterations, instructions=current.instructions,
+)
+```
+
+
+
+
+```rust
+use intellistream_datahub_sdk::tenant::TenantLlmSettingsForm;
+
+let current = api.tenant.llm_settings().await?;
+current.api_key_set;
+current.configured;
+
+// A form built from what is stored saves it unchanged, `api_key: None` keeping the credential;
+// change only what you mean to.
+let form = TenantLlmSettingsForm {
+ model: Some("the-model-id".into()),
+ effort: Some("high".into()),
+ ..TenantLlmSettingsForm::from(¤t)
+};
+let saved = api.tenant.update_llm_settings(&form).await?;
+```
+
+
+
+
+`apiKey` is write-only: it is carried on the form and never returned by a read.
The `PUT` replaces the configuration with what you send, absent meaning unset, except for
`apiKey`: absent or blank leaves the stored credential alone, and only a non-blank value
@@ -94,7 +192,7 @@ cache the tenant registry and pick the change up within five minutes.
| Operation | Java | Python | Rust |
| --- | --- | --- | --- |
-| Feature flags (`GET /tenant/features`) | `tenant().features` | — | — |
-| Settings permissions (`GET /tenant/settings/permissions`) | `tenant().settingsPermissions` | — | — |
-| Read model configuration (`GET /tenant/settings/llm`) | `tenant().llmSettings` | — | — |
-| Change model configuration (`PUT /tenant/settings/llm`) | `tenant().updateLlmSettings` | — | — |
+| Feature flags (`GET /tenant/features`) | `tenant().features` | `tenant.features` | `tenant.features` |
+| Settings permissions (`GET /tenant/settings/permissions`) | `tenant().settingsPermissions` | `tenant.settings_permissions` | `tenant.settings_permissions` |
+| Read model configuration (`GET /tenant/settings/llm`) | `tenant().llmSettings` | `tenant.llm_settings` | `tenant.llm_settings` |
+| Change model configuration (`PUT /tenant/settings/llm`) | `tenant().updateLlmSettings` | `tenant.update_llm_settings` | `tenant.update_llm_settings` |
diff --git a/docs/reference/timeseries.md b/docs/reference/timeseries.md
index 8afa883..e5b0e98 100644
--- a/docs/reference/timeseries.md
+++ b/docs/reference/timeseries.md
@@ -96,6 +96,9 @@ heuristic: small low-precision ranges map to `DECIMAL32`, wide-magnitude analog
`FLOAT32`, and a unit it does not know gets a compact default with `recognized` false. Use it to
pre-select the type in a create form; it is advice, not a constraint.
+
+
+
```java
import ai.intellistream.datahub.api.responses.ValueTypeRecommendation;
@@ -105,6 +108,29 @@ hint.getReason(); // why, in a sentence you can show a user
hint.isRecognized(); // false means you got the generic default
```
+
+
+
+```python
+hint = client.timeseries.recommend_value_type("temperature_deg_c")
+hint.recommended_value_type # the suggested type, upper case
+hint.reason # why, in a sentence you can show a user
+hint.recognized # False means you got the generic default
+```
+
+
+
+
+```rust
+let hint = api.time_series.recommend_value_type("temperature_deg_c").await?;
+hint.recommended_value_type; // the suggested type, upper case
+hint.reason; // why, in a sentence you can show a user
+hint.recognized; // false means you got the generic default
+```
+
+
+
+
Every value crosses the wire as a string, whatever the type. For an exact decimal, send the
string form rather than a float: `Datapoint.of(ts, "12.34")` in Java, `DatapointString(ts,
"12.34")` with `insert_datapoints` in Python (`insert_from_lists` takes floats and converts
@@ -361,6 +387,11 @@ loop {
## List and update series {#list-and-update}
+`GET /timeseries/{id}` reads one series by numeric id and answers `404` when there is none, or
+none you may read; `timeseries().getById` in Java, `timeseries.get_by_id` in Python and
+`time_series.get_by_id` in Rust. [Look up](#filter-series) by external id, or several at once,
+with `by_ids`, which leaves out what it cannot find rather than failing.
+
`GET /timeseries?limit=` is the cheap "what have I got" read: the first `limit` series, newest
created first, no criteria. Add `dataSetId` (an id or an external id) to restrict it to one data
set and everything beneath it in the `BELONGS_TO` hierarchy. Anything narrower belongs in
@@ -926,10 +957,12 @@ if (!result.isComplete()) {
| Operation | Java | Python | Rust |
| --- | --- | --- | --- |
| Create | `timeseries().create` | `timeseries.create` | `time_series.create` / `create_one` |
+| Get by numeric id | `timeseries().getById` | `timeseries.get_by_id` | `time_series.get_by_id` |
| Look up by id / external id | `timeseries().byIds` | `timeseries.by_ids` | `time_series.by_ids` |
| Filter | `timeseries().filter` | `timeseries.filter` | `time_series.filter` |
| Search | `timeseries().search` | `timeseries.search` | `time_series.search` |
-| List | `timeseries().list` | `timeseries.list` | `time_series.list` / `list_with_limit` |
+| List | `timeseries().list` | `timeseries.list` | `time_series.list` |
+| [Value-type hint](#value-type-hint) | `timeseries().recommendValueType` | `timeseries.recommend_value_type` | `time_series.recommend_value_type` |
| Update | `timeseries().update` | `timeseries.update` | `time_series.update` |
| Delete | `timeseries().delete` | `timeseries.delete` | `time_series.delete` |
| Write datapoints | `insertDatapoints` / `ingest` | `insert_datapoints` / `insert_from_lists` | `insert_datapoint` / `insert_datapoints` |
@@ -938,10 +971,11 @@ if (!result.isComplete()) {
| Read datapoints (raw and [aggregated](#retrieve-datapoints)) | `retrieve` / `retrieveAggregated` | `retrieve_datapoints` | `retrieve_datapoints` |
| [Latest datapoint](#latest-datapoints) | `timeseries().latest` | `retrieve_latest_datapoints` | `retrieve_latest_datapoint` |
| Delete datapoints | `deleteDatapoints` | `timeseries.delete_datapoints` | `time_series.delete_datapoints` |
+| [Live datapoint tail](./subscriptions#live-tail) | — | `timeseries.listen_datapoints` | `time_series.listen_datapoints` |
Java is the one with `ingest`, the chunking, parallelising, retrying path described above, and
with `binaryBuffer`. Python and Rust chunk and send concurrently too, and retry through the
durable spool when buffering is on; with it off, the first failed request is the call's error.
All three have the [binary path](#binary-ingest), Python on its synchronous client only. Python
-can create a series of only three [value types](#value-types). Java also has
-`timeseries().recommendValueType`, the [value-type hint](#value-type-hint).
+can create a series of only three [value types](#value-types). Java has no
+[live datapoint tail](./subscriptions#live-tail); use `subscriptions().listen` there.
diff --git a/doctests/plans/reference__resources.toml b/doctests/plans/reference__resources.toml
index 5d50d46..93827b0 100644
--- a/doctests/plans/reference__resources.toml
+++ b/doctests/plans/reference__resources.toml
@@ -18,9 +18,9 @@ page = "docs/reference/resources.md"
# rust #5 L264 Traverse the graph
[blocks]
-python = 10
+python = 11
java = 11
-rust = 9
+rust = 10
[python]
timeout = 600
diff --git a/doctests/plans/reference__subscriptions.toml b/doctests/plans/reference__subscriptions.toml
index 8019a09..9a54da7 100644
--- a/doctests/plans/reference__subscriptions.toml
+++ b/doctests/plans/reference__subscriptions.toml
@@ -10,9 +10,9 @@ page = "docs/reference/subscriptions.md"
# rust #2 L130 Live delivery
[blocks]
-python = 2
+python = 3
java = 3
-rust = 2
+rust = 3
[python]
timeout = 600
diff --git a/doctests/plans/reference__tenant.toml b/doctests/plans/reference__tenant.toml
index af02469..cb0b3d8 100644
--- a/doctests/plans/reference__tenant.toml
+++ b/doctests/plans/reference__tenant.toml
@@ -1,16 +1,30 @@
page = "docs/reference/tenant.md"
# Blocks on this page:
-# java #1 L16 Feature flags {#features}
-# java #2 L46 What you may change {#settings-permissions}
-# java #3 L61 Model configuration {#llm-settings}
+# java #1 L23 Feature flags {#features}
+# python #1 L35 Feature flags {#features}
+# rust #1 L44 Feature flags {#features}
+# java #2 L78 What you may change {#settings-permissions}
+# python #2 L90 What you may change {#settings-permissions}
+# rust #2 L99 What you may change {#settings-permissions}
+# java #3 L117 Model configuration {#llm-settings}
+# python #3 L137 Model configuration {#llm-settings}
+# rust #3 L155 Model configuration {#llm-settings}
# Pinned so an edit to the page fails this plan instead of silently
# re-pointing its block selections. Update deliberately, after re-reading.
[blocks]
+python = 3
java = 3
+rust = 3
+
+[python]
+# A reference page, not a tutorial: three unrelated reads of tenant settings, each
+# self-contained. Every tab is compiled or run one block at a time; the Java and Rust
+# sections inherit this when they do not say otherwise.
+independent = true
+disabled = "Python scenario not written yet."
[java]
disabled = "Java scenario not written yet; compiled by test_compile.py."
-# A reference page, not a tutorial: three unrelated reads of tenant settings.
independent = true
diff --git a/doctests/plans/reference__timeseries.toml b/doctests/plans/reference__timeseries.toml
index 12b8ea6..8e26d45 100644
--- a/doctests/plans/reference__timeseries.toml
+++ b/doctests/plans/reference__timeseries.toml
@@ -35,9 +35,9 @@ page = "docs/reference/timeseries.md"
# java #14 L734 IngestResult
[blocks]
-python = 10
+python = 11
java = 17
-rust = 10
+rust = 11
[python]
timeout = 600