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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .claude/skills/plotjuggler-plugin/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,56 @@ The host routes payloads by these case-sensitive names.
DataSource may add `"file_extensions": [".csv"]`.
`pj_emit_plugin_manifest` (the pre-0.25 name) still works but is deprecated.

### `min_sdk_required` — the host floor, not the build pin

`min_sdk_required` declares the oldest **host** SDK contract your plugin
functions on. It is independent of the SDK you build against: `pj_base` /
`pj_source` are static libraries compiled into your plugin, so adopting a new
helper (`parseIso8601Utc`, `sliderToWindow`, a codec) **never** raises the
floor. Only **host-contract** surfaces do — runtime-host tail slots, named
services, wire contracts — and the authority for which release introduced each
is `feature_floors.json`, shipped in the package at
`share/plotjuggler_sdk/feature_floors.json`.

When you adopt a surface newer than your floor, pick one:

- **Raise `min_sdk_required`** to that surface's release — mandatory for a
non-negotiated (hard protocol) surface, and right whenever the feature is
essential to what your plugin is for. The reverse is NOT symmetric: the
checker sees only runtime host surfaces, so a floor above anything it
matched may still be correct (compile-time SDK APIs are outside the check)
— never lower a floor just because the checker matched nothing.
- **Declare the degraded range** — when the surface is runtime-negotiated and
the wrapper's error return is acceptable (ignore it and carry on). Two
manifest fields, required together and forbidden when nothing exceeds the
floor:

```json
"min_sdk_required": "0.28.0",
"suggested_sdk_version": "0.32.0",
"floor_test": "McapDatasetMetadata.MetadataPublishDegradesCleanlyOnFloorLevelHost"
```

`suggested_sdk_version` is the full-feature floor (exactly the max introducing
release over every host surface you use — the checker validates it), so hosts
can tell users which PlotJuggler unlocks your whole feature set. `floor_test`
names one gtest, existing in your `tests/`, proving the DEGRADED PATH works
against a floor-level host — drive it with a `struct_size`-truncated host
vtable and poison the slots past the truncation so a missing guard fails the
test. Helper-level granularity is acceptable when the full entry point needs a
live host; the test comment must then state the routing fact (which entry
point routes through the helper). Neither field is ever a gate:
`min_sdk_required` remains the only admission criterion, and
`min_plotjuggler_version` is deprecated.

Check your own plugin with the tool the SDK package ships next to the table:

```bash
python3 <sdk>/share/plotjuggler_sdk/feature_floor_check.py \
--table <sdk>/share/plotjuggler_sdk/feature_floors.json \
--manifest my_plugin/manifest.json --sources my_plugin
```

## Step 6 — The rules that silently break a plugin

These cut across all families. The family references add family-specific traps
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ pj_media/testdata/

# git worktrees live here by policy (see the global CLAUDE.md)
.worktrees/
__pycache__/
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,25 @@
All notable changes to `plotjuggler_sdk` are recorded here. Versioning policy is in
[`CLAUDE.md`](./CLAUDE.md) → "Release Versioning".

## [0.33.0] — unreleased

Host contract: unchanged (no floor impact) — this release adds the floor
table itself and the `suggested_sdk_version` manifest field, no new host surfaces.

- Ship `share/plotjuggler_sdk/feature_floors.json`: the host-contract surface
table (surface → introducing release, runtime-negotiated or not) that a
plugin floor checker validates `min_sdk_required` against. A new host
surface (tail slot, versioned service id, wire flag) must be classified in
the table in the same change — `tools/feature_floors/check_feature_floors.py`
runs in CI and fails on any unclassified surface.
- New optional manifest fields `suggested_sdk_version` (decoded into
`PluginDescriptor::suggested_sdk_version`) and `floor_test`: a plugin whose
used surfaces exceed its functioning floor declares the full-feature floor
(validated by the floor checker to equal max introducing release over its
matched surfaces) plus the gtest proving it functions against a floor-level
host. `min_sdk_required` remains the only admission gate;
`min_plotjuggler_version` is deprecated (host-side removal lands in PJ4).

## [0.32.0]

- Add the `set_dataset_metadata` tail slot to the data-source runtime host
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,25 @@ Propose a release only according to plugin impact:

Tail-append optional slots and gate them by `struct_size`; keep minimum vtable
sizes frozen. `abidiff` must show additions only for a MINOR.

The SDK version is **build identity only** — it names what a plugin compiled
against, never what host it runs on. A release that only adds client-side
helpers (static-library code compiled into the plugin) does NOT change runtime
host compatibility and never mints a `pj_base/feature_floors.json` entry; only
host-contract extensions (host-vtable tail slots, versioned service ids, wire
contracts) do, and those MUST be classified there in the same PR (CI-enforced
by `tools/feature_floors/check_feature_floors.py`). The version stays one
monotonic identity; `feature_floors.json` carries the per-release compatibility
semantics; `PJ_ABI_VERSION` remains the orthogonal hard-break counter.
The plugin compatibility contract is `min_sdk_required` (the functioning
floor, the only admission gate) plus the informational
`suggested_sdk_version` (full-feature floor) and `abi_major` for hard
breaks; `min_plotjuggler_version` is deprecated — the decoder keeps reading
it for back-compat, and the host-side gate removal happens in PJ4.
Changelog convention: every release section carries one `Host contract:` line —
either `unchanged (no floor impact)` or `extended: <identifiers> (floor X.Y.Z)`;
the floor checker requires the `extended` form whenever a surface newer than
the last released version exists.
`pj_base/abi/baseline.abi` is refreshed only for an intentional MAJOR break.
See [ABI evolution rules](pj_plugins/docs/ARCHITECTURE.md#0a-abi-stability-and-evolution-rules-v5).

Expand Down
13 changes: 13 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ find_package(FastFloat REQUIRED)

if(PJ_BUILD_TESTS)
enable_testing()
# Host-surface classification gate: a new tail slot / service id / wire flag
# must be classified in pj_base/feature_floors.json in the same change.
find_package(Python3 QUIET COMPONENTS Interpreter)
if(Python3_Interpreter_FOUND)
add_test(NAME feature_floors_check
COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/tools/feature_floors/check_feature_floors.py)
endif()
endif()

add_subdirectory(pj_base)
Expand Down Expand Up @@ -145,6 +152,12 @@ if(PJ_INSTALL_SDK)
DESTINATION share/plotjuggler_sdk/docs
)

# Host-surface floor table + the reusable checker that consumes it: the
# machine-readable authority (and tool) a plugin validates min_sdk_required
# against (see tools/feature_floors/).
install(FILES pj_base/feature_floors.json tools/feature_floors/feature_floor_check.py
DESTINATION share/plotjuggler_sdk)

# Fixture sources come from the authoritative PJ_SDK_TEST_FIXTURES list
# (cmake/PjSdkTestFixtures.cmake, included above).
foreach(_fixture IN LISTS PJ_SDK_TEST_FIXTURES)
Expand Down
125 changes: 125 additions & 0 deletions pj_base/feature_floors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
{
"schema_version": 1,
"minimum_supported_floor": "0.28.0",
"since": {
"0.30.0": {
"PJ_INGEST_COMPLETION_FLAGS_V1_MASK": "",
"PJ_INGEST_COMPLETION_FLAG_ATTESTS_EMPTY_TOPICS": "",
"PJ_INGEST_COMPLETION_FLAG_NONE": "",
"completeIngest": "PJ_data_source_runtime_host_vtable_t::complete_ingest",
"discardParserIngest": "PJ_toolbox_runtime_host_vtable_t::discard_parser_ingest"
},
"0.32.0": {
"setDatasetMetadata": "PJ_data_source_runtime_host_vtable_t::set_dataset_metadata"
}
},
"baseline": [
"PJ_DATA_PROCESSOR_FLAG_EPHEMERAL",
"PJ_DESCRIPTOR_IMPORT_START_FLAGS_V1_MASK",
"PJ_DESCRIPTOR_IMPORT_START_FLAG_NONE",
"PJ_MODULE_PARSE_INPUT_FLAG_HAS_TIMESTAMP",
"PJ_PARSER_ROUTE_FLAG_OBJECT_V1",
"PJ_PARSER_ROUTE_FLAG_SCALAR_V1",
"PJ_colormap_registry_vtable_t::register_map",
"PJ_colormap_registry_vtable_t::unregister_map",
"PJ_data_processors_host_vtable_t::create_data_processor",
"PJ_data_processors_host_vtable_t::data_processor_config",
"PJ_data_processors_host_vtable_t::list_data_processor_ids",
"PJ_data_processors_host_vtable_t::remove_data_processor",
"PJ_data_processors_host_vtable_t::validate_data_processor_script",
"PJ_data_source_runtime_host_vtable_t::attach_source_record",
"PJ_data_source_runtime_host_vtable_t::ensure_parser_binding",
"PJ_data_source_runtime_host_vtable_t::is_stop_requested",
"PJ_data_source_runtime_host_vtable_t::list_available_encodings",
"PJ_data_source_runtime_host_vtable_t::notify_available_topics",
"PJ_data_source_runtime_host_vtable_t::notify_state",
"PJ_data_source_runtime_host_vtable_t::progress_finish",
"PJ_data_source_runtime_host_vtable_t::progress_start",
"PJ_data_source_runtime_host_vtable_t::progress_update",
"PJ_data_source_runtime_host_vtable_t::push_message",
"PJ_data_source_runtime_host_vtable_t::report_message",
"PJ_data_source_runtime_host_vtable_t::request_stop",
"PJ_data_source_runtime_host_vtable_t::show_message_box",
"PJ_object_read_host_vtable_t::entry_count",
"PJ_object_read_host_vtable_t::get_bytes",
"PJ_object_read_host_vtable_t::list_topics",
"PJ_object_read_host_vtable_t::lookup_topic",
"PJ_object_read_host_vtable_t::lookup_topic_on_dataset",
"PJ_object_read_host_vtable_t::read_latest_at",
"PJ_object_read_host_vtable_t::release_bytes",
"PJ_object_read_host_vtable_t::time_range",
"PJ_object_read_host_vtable_t::topic_metadata",
"PJ_object_write_host_vtable_t::push_lazy",
"PJ_object_write_host_vtable_t::push_owned",
"PJ_object_write_host_vtable_t::register_topic",
"PJ_object_write_host_vtable_t::set_retention_budget",
"PJ_parser_object_write_host_vtable_t::push_lazy",
"PJ_parser_object_write_host_vtable_t::push_owned",
"PJ_parser_runtime_host_vtable_t::report_diagnostic",
"PJ_parser_write_host_vtable_t::append_arrow_stream",
"PJ_parser_write_host_vtable_t::append_bound_record",
"PJ_parser_write_host_vtable_t::append_record",
"PJ_parser_write_host_vtable_t::ensure_field",
"PJ_playback_host_vtable_t::get_state",
"PJ_playback_host_vtable_t::pause",
"PJ_playback_host_vtable_t::play",
"PJ_playback_host_vtable_t::seek",
"PJ_playback_host_vtable_t::set_playback_rate",
"PJ_playback_host_vtable_t::to_display_time",
"PJ_playback_host_vtable_t::to_display_time_for_source",
"PJ_plot_tab_host_vtable_t::add_curve",
"PJ_plot_tab_host_vtable_t::clear_tab",
"PJ_plot_tab_host_vtable_t::close_tab",
"PJ_plot_tab_host_vtable_t::create_tab",
"PJ_plot_tab_host_vtable_t::list_tab_ids",
"PJ_plot_tab_host_vtable_t::remove_curve",
"PJ_plot_tab_host_vtable_t::tab_config",
"PJ_service_registry_vtable_t::get_service",
"PJ_settings_store_vtable_t::contains",
"PJ_settings_store_vtable_t::get_string",
"PJ_settings_store_vtable_t::get_string_list",
"PJ_settings_store_vtable_t::remove_key",
"PJ_settings_store_vtable_t::set_string",
"PJ_settings_store_vtable_t::set_string_list",
"PJ_source_promotion_host_vtable_t::promote_to_file_source",
"PJ_source_write_host_vtable_t::append_arrow_stream",
"PJ_source_write_host_vtable_t::append_bound_record",
"PJ_source_write_host_vtable_t::append_record",
"PJ_source_write_host_vtable_t::ensure_field",
"PJ_source_write_host_vtable_t::ensure_topic",
"PJ_toolbox_host_vtable_t::acquire_catalog_snapshot",
"PJ_toolbox_host_vtable_t::append_arrow_stream",
"PJ_toolbox_host_vtable_t::append_bound_record",
"PJ_toolbox_host_vtable_t::append_record",
"PJ_toolbox_host_vtable_t::create_data_source",
"PJ_toolbox_host_vtable_t::ensure_field",
"PJ_toolbox_host_vtable_t::ensure_topic",
"PJ_toolbox_host_vtable_t::push_owned_object",
"PJ_toolbox_host_vtable_t::read_series_arrow",
"PJ_toolbox_host_vtable_t::register_object_topic",
"PJ_toolbox_host_vtable_t::register_object_topic_on_dataset",
"PJ_toolbox_host_vtable_t::set_object_topic_retention",
"PJ_toolbox_runtime_host_vtable_t::create_parser_ingest",
"PJ_toolbox_runtime_host_vtable_t::notify_data_changed",
"PJ_toolbox_runtime_host_vtable_t::release_parser_ingest",
"PJ_toolbox_runtime_host_vtable_t::report_message",
"PJ_viewport_host_vtable_t::zoom_reset",
"PJ_viewport_host_vtable_t::zoom_to_time_range",
"pj.colormap.v1",
"pj.data_processors.v1",
"pj.parser_object_write.v1",
"pj.parser_runtime.v1",
"pj.parser_write.v1",
"pj.playback.v1",
"pj.plot_tabs.v1",
"pj.runtime.v1",
"pj.settings.v1",
"pj.source_object_write.v1",
"pj.source_promotion.v1",
"pj.source_write.v1",
"pj.toolbox_object_read.v1",
"pj.toolbox_runtime.v1",
"pj.toolbox_write.v1",
"pj.viewport.v1"
]
}
8 changes: 8 additions & 0 deletions pj_plugins/docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
Seven rules the loader and every plugin author rely on. Breaking any of
these is an ABI break and requires a future `PJ_ABI_VERSION` bump.

One classification obligation rides alongside these rules: **a new host
surface — a host-vtable tail slot, a versioned service/extension id, or a
wire-contract flag — must be classified in `pj_base/feature_floors.json` in
the same PR.** CI enforces it (`tools/feature_floors/check_feature_floors.py`
diffs the extracted host-surface inventory against the checked-in snapshot),
and the installed copy of the table is what downstream plugin repositories
validate `min_sdk_required` against.

1. **Boot-level ABI symbol.** Every plugin .so exports
`pj_plugin_abi_version` as a `uint32_t` symbol independent of any
vtable. The host `dlsym`s it BEFORE fetching the family vtable;
Expand Down
6 changes: 6 additions & 0 deletions pj_plugins/include/pj_plugins/host/plugin_catalog.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ struct PluginDescriptor {
/// Optional SDK contract floor; "" means undeclared. This is a hard host-side
/// load gate, distinct from both the application release and the compile SDK.
std::string min_sdk_required;
/// Optional full-feature floor: the SDK release at which every capability
/// this plugin uses is available ("" in manifests that do not declare it).
/// min_sdk_required remains the FUNCTIONING floor; a host between the two
/// runs the plugin with some optional features inactive. Informational, for
/// host degradation display — never an admission criterion.
std::string suggested_sdk_version;
};

/// Diagnostic for a candidate DSO that could not produce a valid descriptor.
Expand Down
5 changes: 5 additions & 0 deletions pj_plugins/src/plugin_catalog.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,10 @@ Expected<PluginDescriptor> decodeManifest(
if (!min_plotjuggler_version) {
return unexpected(min_plotjuggler_version.error());
}
auto suggested_sdk_version = optional_string("suggested_sdk_version");
if (!suggested_sdk_version) {
return unexpected(suggested_sdk_version.error());
}
auto min_sdk_required = optional_string("min_sdk_required");
if (!min_sdk_required) {
return unexpected(min_sdk_required.error());
Expand All @@ -283,6 +287,7 @@ Expected<PluginDescriptor> decodeManifest(
d.category = *category;
d.min_plotjuggler_version = *min_plotjuggler_version;
d.min_sdk_required = *min_sdk_required;
d.suggested_sdk_version = *suggested_sdk_version;
d.file_extensions = *file_extensions;
d.capabilities = *capabilities;

Expand Down
15 changes: 15 additions & 0 deletions pj_plugins/tests/plugin_catalog_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,21 @@ TEST_F(PluginCatalogTest, MinSdkRequiredDefaultsToEmptyWhenAbsent) {
EXPECT_TRUE(descriptor->min_sdk_required.empty());
}

TEST_F(PluginCatalogTest, SuggestedSdkVersionRoundTripsAndDefaultsEmpty) {
// Informational full-feature floor: round-trips when declared, and a
// manifest without the field still decodes (back-compat).
auto with_field = decodeManifest(
"static:sdk-test", PluginFamily::kDataSource,
R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0","suggested_sdk_version":"0.33.0"})");
ASSERT_TRUE(with_field.has_value()) << with_field.error();
EXPECT_EQ(with_field->suggested_sdk_version, "0.33.0");

auto without_field = decodeManifest(
"static:sdk-test", PluginFamily::kDataSource, R"({"id":"sdk-test","name":"SDK Test","version":"1.0.0"})");
ASSERT_TRUE(without_field.has_value()) << without_field.error();
EXPECT_TRUE(without_field->suggested_sdk_version.empty());
}

TEST_F(PluginCatalogTest, MinSdkRequiredAcceptsEmptyString) {
auto descriptor = decodeManifest(
"static:sdk-test", PluginFamily::kDataSource,
Expand Down
27 changes: 27 additions & 0 deletions test_sdk_install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,5 +140,32 @@ if nm -C "$STAGING_DIR/lib/libpj_base.a" | grep -E " [TWuVvW] " | grep -q "fmt::
exit 1
fi

# ---------------------------------------------------------------------------
# 7. Verify the feature-floors table + checker are installed and work
# ---------------------------------------------------------------------------

echo ""
echo "--- Step 7: Verify feature_floors.json and feature_floor_check.py install ---"

FLOORS="$STAGING_DIR/share/plotjuggler_sdk/feature_floors.json"
FLOOR_CHECK="$STAGING_DIR/share/plotjuggler_sdk/feature_floor_check.py"
for f in "$FLOORS" "$FLOOR_CHECK"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: $f not installed"
exit 1
fi
done
# The installed checker is the one parser of the installed table: loading the
# table through it validates both, in the exact combination users receive.
python3 -c "
import importlib.util, pathlib, sys
spec = importlib.util.spec_from_file_location('feature_floor_check', sys.argv[1])
module = importlib.util.module_from_spec(spec)
sys.modules['feature_floor_check'] = module
spec.loader.exec_module(module)
table = module.load_surface_table(pathlib.Path(sys.argv[2]))
assert table.surfaces, 'empty feature_floors.json'
" "$FLOOR_CHECK" "$FLOORS"

echo ""
echo "=== plotjuggler_sdk install test PASSED ==="
Loading
Loading