diff --git a/docs/CONTRACT-STABILITY.md b/docs/CONTRACT-STABILITY.md
index c2cdcb62..ec7d7a31 100644
--- a/docs/CONTRACT-STABILITY.md
+++ b/docs/CONTRACT-STABILITY.md
@@ -47,6 +47,7 @@ Recorded in the comment block in `shopdb/__init__.py`:
| 0.25.0 | Added `caller_holds(permission)`, which answers whether the current caller holds a permission WITHOUT refusing the request - the complement to `require_permission` for a route that serves everyone but should only offer what the caller can act on. The endpoints services panel is the case: it emitted Start/Stop row actions to every reader, because the emission never consulted the caller. Clicking them was always refused server-side, so nothing was exploitable, but offering a read-only viewer controls over a shop-floor PC is the wrong signal, and it becomes a real problem once a site grants that panel to guests. Guests are included in the answer, since a signed-out visitor holds whatever the guest role was granted | additive surface (minor) |
| 0.26.0 | Dashboard cards may declare `shortname`. The dashboard merged its per-source cards into one worst-first queue, and a card's `title` is written as a heading ("Endpoints with crashes or shutdowns") which as a row badge ran wider than the hostname it qualified. Optional, defaults to the title, so a plugin that does not declare one is unaffected | additive field (minor) |
| 0.27.0 | Added `Script` so a plugin can read the ShopDB-hosted script library that backs endpoint `run_script` delivery without importing core model internals. The endpoints plugin resolves a delivered script by name and serves its body by digest to the agent | additive surface (minor) |
+| 0.29.0 | Added an optional `group` to `get_asset_panels`, naming which detail-page tab a panel belongs to: `details` (the default) / `software` / `metrics` / `actions` / `activity`. The detail pages are tabbed, and a page that placed panels by knowing their ids would defeat the hook. Additive: an absent or unrecognised group resolves to `details` server-side, so every existing plugin keeps working with no change | additive surface (minor) |
| 0.28.0 | Added `client_ip` and `strip_port` so a plugin does not hand-roll caller-IP extraction. Two plugins had, and both read the first `X-Forwarded-For` hop, which the caller supplies - so a rate limit keyed on it was defeated by rotating the header. The helper resolves from `remote_addr`, which ProxyFix has already filled from the header when `TRUSTED_PROXY_COUNT` says a proxy is really in front | additive surface (minor) |
The source comment block documents 0.3.0, 0.4.0, 0.6.0, 0.7.0, 0.9.0, 0.10.0,
diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md
index 3775fef3..54f2ccdf 100644
--- a/docs/PROJECT-MAP.md
+++ b/docs/PROJECT-MAP.md
@@ -12,7 +12,7 @@ never by editing this file.
| series | value | governed by |
|---|---|---|
| product `__version__` | `0.12.0` | ADR-007 |
-| plugin contract `__contract_version__` | `0.28.0` | ADR-002 |
+| plugin contract `__contract_version__` | `0.29.0` | ADR-002 |
They move independently. A contract bump is not a release.
@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size
-- test functions defined: **1575** (parametrised cases collect higher)
+- test functions defined: **1579** (parametrised cases collect higher)
- documented API paths: **346** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
diff --git a/frontend/src/components/AssetDetailTabs.vue b/frontend/src/components/AssetDetailTabs.vue
new file mode 100644
index 00000000..a4c49778
--- /dev/null
+++ b/frontend/src/components/AssetDetailTabs.vue
@@ -0,0 +1,99 @@
+
+
+