From c97313c95c8628c57bb3653a7a7a9e80e619c43a Mon Sep 17 00:00:00 2001 From: Dave Clarke Date: Mon, 17 Aug 2026 09:04:40 +0100 Subject: [PATCH 1/2] Teach build-plugin skill to create correlation rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correlation rules are the only way a low-code plugin can create edges between the objects it imports — index definitions create vertices only, with no relationship syntax at all. The skill had no coverage of them. Adds references/correlation-rules.md (file layout and ruleName stability, what deploy stamps vs what you author, condition/bridge/object-group rules, matchable properties, limits, validation, lifecycle, common mistakes) and wires it into the flow: - Phase 5b, between the import definitions and Checkpoint B, so the rules ship in that redeploy and the same import evaluates them - A Relationships item in the Phase 2 plan, so join keys are decided before the import mapping is committed and don't force a later re-index - Checkpoint B step 5 to confirm edges, noting correlation is triggered fire-and-forget after a successful import rather than as part of it - correlationRules/ in the Phase 3 scaffold Also from the same pass: - deploy-plugin: version-bump rows for correlation rules, warning that deleting or renaming a rule file drops the rule and all its edges - REVIEW.md: per-file review guidance for correlationRules/, matching the existing sections - index-defs.md: document the { "prop": { "value": } } form of objectMapping.properties, verified against squaredup validate - data-streams.md: fix defaultTimeframe, which implied it takes a timeframe enum. It accepts only "none" or "dashboard", and the response-size-cap remediation suggested setting a "conservative defaultTimeframe", which is not possible — restricting the timeframes array is the only lever. This misled four parallel sub-agents mid-build. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T3pxj55BWSS4FpuGwVrYkm --- .claude/skills/build-plugin/SKILL.md | 40 ++- .../build-plugin/references/checkpoints.md | 1 + .../references/correlation-rules.md | 252 ++++++++++++++++++ .../build-plugin/references/data-streams.md | 11 +- .../build-plugin/references/index-defs.md | 8 + .claude/skills/deploy-plugin/SKILL.md | 5 +- REVIEW.md | 11 + 7 files changed, 319 insertions(+), 9 deletions(-) create mode 100644 .claude/skills/build-plugin/references/correlation-rules.md diff --git a/.claude/skills/build-plugin/SKILL.md b/.claude/skills/build-plugin/SKILL.md index e842a27e..5a1ef4aa 100644 --- a/.claude/skills/build-plugin/SKILL.md +++ b/.claude/skills/build-plugin/SKILL.md @@ -48,7 +48,8 @@ Create a TaskCreate task for each phase. The flow deploys early and tests as it - [ ] **Phase 4** — Write `metadata.json`, `ui.json`, `configValidation.json` + its backing stream — the deployable **shell** → [metadata.md](references/metadata.md), [ui.md](references/ui.md) - [ ] **Checkpoint A** — Deploy the shell and authenticate (invoke `deploy-plugin`, probe auth) → [checkpoints.md](references/checkpoints.md) - [ ] **Phase 5** — Write import definitions and import streams; test each in parallel sub-agents. Repeat per dependency level if any step `dependsOn` another → [index-defs.md](references/index-defs.md), [test-agent.md](references/test-agent.md) -- [ ] **Checkpoint B** — Redeploy, then trigger + await the import via the CLI so objects exist → [checkpoints.md](references/checkpoints.md) +- [ ] **Phase 5b** — Write `correlationRules/*.json` for the relationships planned in Phase 2 → [correlation-rules.md](references/correlation-rules.md) +- [ ] **Checkpoint B** — Redeploy, then trigger + await the import via the CLI so objects exist (and correlation runs) → [checkpoints.md](references/checkpoints.md) - [ ] **Phase 6** — Build + test data streams in parallel sub-agents → [test-agent.md](references/test-agent.md), [data-streams.md](references/data-streams.md) - [ ] **Phase 7** — Build OOB default content in a sub-agent (it reads [oob-content.md](references/oob-content.md)) - [ ] **Phase 8** — Write `custom_types.json` → [common-patterns.md](references/common-patterns.md) @@ -80,7 +81,8 @@ This phase produces a written plan and a user-approval gate before any files are 1. **Object types** — Every type that should appear in the SquaredUp graph. These go in `objectTypes` in `metadata.json` and as `sourceType` throughout. 2. **Import steps** — Let the API shape dictate: one step returning many types, or separate steps per type. If an object type is only listable in the context of an already-imported parent (the API has "list X for parent Y" but no "list all X"), plan it as a **dependent step** instead — `dependsOn` the parent step and `scope` to its objects; see [index-defs.md](references/index-defs.md#scoped-dependent-steps). -3. **Data streams** — For each object type, plan: +3. **Relationships** — How the object types connect to each other (a device belongs to a site, an alert was raised by a device). Import definitions create objects only; edges come from **correlation rules**, which match a property on one object type against a property on another. For each relationship plan the **source type** (the side holding the foreign key), the **target type**, the **join key on each side**, and the **forward/reverse labels**. Then check the join keys are in that step's `objectMapping.properties` — a property the import doesn't map can never be matched, and adding it later means a full re-index. See [correlation-rules.md](references/correlation-rules.md). +4. **Data streams** — For each object type, plan: - A **summary/current state** stream (`"timeframes": false`, returns current values) - A **history/metrics** stream (supports timeframes, returns time-series rows) - Any **cross-object** streams scoped to a parent (e.g. alarms for an installation) @@ -89,10 +91,10 @@ This phase produces a written plan and a user-approval gate before any files are - `false` when the endpoint exposes no time-range parameter — the user can't choose a range (returns a fixed snapshot or current values regardless). - An **array** when the endpoint accepts a range but aggregates coarsely: don't leave the default `true`, because a daily-granularity endpoint can't serve `last1hour`. Restrict `timeframes` to the smallest window the granularity supports and up (e.g. daily → `last7days`+). - `true` when the endpoint accepts a range at fine granularity and any timeframe works. -4. **What's intentionally omitted** — API capabilities not being implemented, and why. Highest-value section for catching scope creep. -5. **Authentication** — Auth mechanism and any UX concerns (token expiry, rate limits, hard-to-obtain credentials). -6. **OOB dashboards** — A **top-level summary dashboard** plus **one perspective per object type** scoped via a dashboard variable. -7. **sourceId format** — Use the raw API ID wherever possible. +5. **What's intentionally omitted** — API capabilities not being implemented, and why. Highest-value section for catching scope creep. +6. **Authentication** — Auth mechanism and any UX concerns (token expiry, rate limits, hard-to-obtain credentials). +7. **OOB dashboards** — A **top-level summary dashboard** plus **one perspective per object type** scoped via a dashboard variable. +8. **sourceId format** — Use the raw API ID wherever possible. ### Plan format @@ -110,6 +112,14 @@ Post the plan as markdown with one `###` heading per item above. Short example: - `installations` — one step, returns both types +### Relationships + +| Source (holds the key) | Target | Join | Labels | +| ---------------------- | ------------------ | --------------------------------- | ---------------------------- | +| `My Device` | `My Installation` | `siteId` → `rawId` | installed at / contains | + +`siteId` must be mapped in the devices step's `objectMapping.properties`. + ### Data streams | Stream | Scope | Time range? / granularity | `timeframes` | @@ -185,6 +195,8 @@ my-plugin/ README.md # REQUIRED: shown in-product when users add the plugin indexDefinitions/ default.json + correlationRules/ # one file per relationship; filename is the stable rule name + relate-device-to-site.json dataStreams/ myStream.json scripts/ @@ -265,6 +277,21 @@ Run this pass at the end of each Phase 5 build (the root pass, and each dependen --- +## Phase 5b: Correlation rules (relationships) + +Skip only if the Phase 2 plan has no relationships. Write one `correlationRules/.json` per relationship — read [correlation-rules.md](references/correlation-rules.md). These live here, immediately after the import definitions and **before** Checkpoint B, for two reasons: the join keys they match are properties of the import you just wrote, and correlation is evaluated automatically after each successful import — so shipping the rules in Checkpoint B's redeploy means that same import produces the edges. + +Author them inline in the main agent (they're small, and they depend on the import mapping you're holding in context). For each planned relationship: + +1. **Confirm both join keys exist on the object.** A condition can only match `name`, `rawId`, `sourceType`, or something in that step's `objectMapping.properties` — a column the stream returns but the step doesn't map does not exist on the object and will never match. +2. **If a join key is missing, add it to `objectMapping.properties` now.** Adding it here is free if that step's objects haven't been imported yet; if they have (a dependent-step build where Checkpoint B already ran for a lower level), every existing object is now stale — apply the [re-indexing rule](#re-indexing-rule--a-definition-change-leaves-imported-objects-stale). +3. **Write the rule** — source is the side holding the foreign key; always write both `forward` and `reverse` labels; `"operator": "equals"` on every condition. Never write `pluginId`, `ruleType`, `schemaVersion`, or condition `id`s — those are stamped for you. +4. **Validate** — run `squaredup validate --json` from the plugin dir and confirm the summary's `Correlation Rules` count equals the number of files you wrote. An invalid rule fails the whole plugin validation, so fix any error before Checkpoint B. + +Edges can't be confirmed until Checkpoint B's import has run — verification is a step there. + +--- + ## Checkpoint B: Redeploy & run the first import Scoped data streams can't be tested until objects exist, which means the import steps must be live and an import must have run. The CLI triggers and tracks the import for you, so **drive it yourself — don't ask the user to run it in the UI.** @@ -273,6 +300,7 @@ Scoped data streams can't be tested until objects exist, which means the import 2. **Trigger** — `squaredup index --datasource-id --no-wait --json`. `--no-wait` returns immediately with a `since` anchor (capture it) instead of blocking until the import finishes — you poll for completion in the next step. (Plain `squaredup index` now waits and prints progress itself, which can outlast an agent command timeout on a long import; `--no-wait` is the orchestration path.) If an import was already running it reports `alreadyRunning: true` and adopts that run — poll with the `since` it returns either way. 3. **Wait** — poll `squaredup index-status --datasource-id --since --json` until `done` is `true`, passing the `since` from step 2. `succeeded: true` means objects are indexed; `succeeded: false` means the import failed — read the run-level `message` and the per-step `steps[]` (which step has `status: "failed"` and its `errorReason`) to pinpoint the break, fix that import stream, and re-trigger before continuing. Imports can take several minutes; use a generous timeout. See [checkpoints.md](references/checkpoints.md). 4. **Confirm** — check objects landed with an **inline scope**: `squaredup objects --matches '{"sourceType":{"type":"equals","value":""}}' --plugin-id --datasource-id --json` should return a non-empty list. `` is a `sourceType` from the `objectTypes` you defined in `metadata.json` / `indexDefinitions/default.json`. Use `--matches` here, **not** `objects `: that form resolves a data stream file's `matches`, but no scoped data stream exists yet (those come in Phase 6) and the import streams written so far have no `matches` to resolve. For the same reason, pass **inline** JSON — `--matches @.json` won't work, as an import stream's `matches` is `none`/absent. +5. **Confirm relationships** — only if Phase 5b shipped correlation rules. Correlation is triggered automatically once the import succeeds, but fire-and-forget: it is _not_ part of the import status, so edges appear shortly **after** `index-status` reports done. The CLI has no edge-query command, so confirm in the tenant — ask the user to open an object of the source type and check its relationships — or use the SquaredUp MCP server's `graph_query` if one is connected. Zero edges from a rule that validated almost always means an unmapped join key or a `types` string that doesn't match `objectTypes`; see the common-mistakes table in [correlation-rules.md](references/correlation-rules.md). ### ⚠️ Re-indexing rule — a definition change leaves imported objects stale diff --git a/.claude/skills/build-plugin/references/checkpoints.md b/.claude/skills/build-plugin/references/checkpoints.md index 2fb123f8..af67d342 100644 --- a/.claude/skills/build-plugin/references/checkpoints.md +++ b/.claude/skills/build-plugin/references/checkpoints.md @@ -60,6 +60,7 @@ Notes: - **`status` is the run's lifecycle, not the outcome.** While running it's `ready`/`inProgress`; once `done` it's one of `succeeded`, `failed`, `warning`, or `cancelled`. `succeeded` and `warning` both report `succeeded: true` (a `warning` run finished but a step emitted warnings — check the `steps[]`); `failed` and `cancelled` report `succeeded: false`. A datasource that has never imported reports `status: "notRun"`, `done: false`. - `--since` is **exclusive** (`scheduledStart > since`): always pass the `since` from `index` so `done` can't latch on a stale previous run. - If `index` reports `alreadyRunning: true`, it adopted the in-flight run — poll with the `since` it returned. Imports can take several minutes (object import allows up to ~10 min); use a generous overall timeout. +- **Correlation runs after the import, not as part of it.** If the plugin ships `correlationRules/*.json`, a successful import triggers correlation fire-and-forget — `done: true, succeeded: true` says nothing about whether edges were written, and they land shortly afterwards. There is no CLI command for edges; confirm relationships in the tenant UI. See [correlation-rules.md](correlation-rules.md). - The `--matches` confirm in step 3 must be **inline JSON** — `--matches @.json` only resolves a real scope, and an import stream's `matches` is `none`/absent. Likewise `objects ` needs a scoped stream, which doesn't exist until Phase 6. ## Imported objects are frozen at import time — re-index to refresh them diff --git a/.claude/skills/build-plugin/references/correlation-rules.md b/.claude/skills/build-plugin/references/correlation-rules.md new file mode 100644 index 00000000..23f84a27 --- /dev/null +++ b/.claude/skills/build-plugin/references/correlation-rules.md @@ -0,0 +1,252 @@ +# correlationRules/ Reference — relationships between imported objects + +Import definitions create **objects** (graph vertices). Nothing in `indexDefinitions/*.json` creates a **relationship** between them. Relationships come from correlation rules: one JSON file per rule in `v1/correlationRules/`, each describing "when this property on object A equals that property on object B, draw an edge". + +Edges are what make a Pod appear under its Node, a device under its site, an alert under the resource that raised it — they drive the object drilldown graph, perspective scoping, and relationship-aware tiles. + +--- + +## File layout + +``` +my-plugin/ + v1/ + correlationRules/ + relate-device-to-site.json + relate-alert-to-device.json +``` + +- **The filename (minus `.json`) is the `ruleName`** — the stable identifier used to match rules across plugin upgrades. Same filename on the next deploy = the rule is updated in place; a filename that disappears = the rule (and its edges) is removed. **Renaming a file deletes the old rule and creates a new one**, so keep filenames stable once shipped. +- Rules deploy automatically with the plugin — the CLI zips the whole plugin folder, and `squaredup validate` reports `Correlation Rules: N` in its summary. +- One rule per file. There is no index or manifest to register them in. + +--- + +## What you write vs what the platform stamps + +Write only the rule's *shape*. These fields are filled in for you and **must not** appear in the file: + +| Field | Filled in by | +| ------------------------------------ | ------------------------------------------------------------------ | +| `ruleName` | the filename | +| `ruleType` | inferred — `bridge` if the rule has a `bridge` leg, else `condition` | +| `schemaVersion` | always `2` | +| `id`, condition `id`s | positional (`"1"`, `"2"`, … in array order) — referenced by `conditionLogic` | +| `pluginId` (rule, source, target, bridge) | deploy time — scoped to this plugin | +| `origin`, `edgeSource`, `configs` | deploy time | + +Two consequences of this: + +- **A plugin rule can only relate object types from its own plugin.** Cross-plugin correlation exists, but only as a user-authored rule in Settings → Correlation Rules — a plugin can't ship one. +- **Only condition and bridge rules are shippable.** `ruleType` is inferred from the file's shape, so the platform's other rule types — semantic (fuzzy name matching) and direct (one specific object to another) — can't be authored in a plugin. Writing `semanticThreshold` into a file doesn't produce a semantic rule; it's read as a condition rule with no conditions and fails validation. + +--- + +## The condition rule (what you'll write 95% of the time) + +```json +{ + "displayName": "Node runs Pod", + "source": { + "types": "Pod" + }, + "target": { + "types": "Node" + }, + "conditions": [ + { + "sourceProperty": "nodeName", + "operator": "equals", + "targetProperty": "name" + } + ], + "labels": { + "forward": "scheduled on", + "reverse": "hosts" + } +} +``` + +- **`displayName`** (required, ≤256 chars) — how the rule reads in Settings → Correlation Rules. Name the *relationship*, source-first: "Node runs Pod", "Device reports to Site". +- **`source.types` / `target.types`** (required) — a `sourceType` string, or an array of them (max 25, each ≤128 chars). **Must exactly match `objectTypes` entries in `metadata.json`.** The wildcard `"*"` is rejected on both sides. +- **`conditions`** (required, 1–25) — each needs `sourceProperty`, `targetProperty`, and `"operator": "equals"`. `equals` is the only operator, and it is **not** optional — omitting it fails validation. +- **`labels.forward`** (required, ≤128 chars) — reads source → target. Above: "Pod *scheduled on* Node". +- **`labels.reverse`** (optional, ≤128 chars) — reads target → source: "Node *hosts* Pod". Defaults to the forward label when omitted, which reads badly in one direction — always supply it. + +Never set `pluginId` or `configs` on `source`/`target`/`bridge`. They're stamped at deploy, and supplying both `pluginId` and `configs` on one leg is a validation error. + +### Which side is "source"? + +The side that **carries the foreign key**. In the example the Pod holds `nodeName`, so Pod is the source and Node the target. Getting this backwards still produces edges (matching is symmetric) but the labels read the wrong way round. + +### Multiple conditions + +Multiple conditions are **AND-ed by default** — use this whenever a single key isn't unique (a name that's only unique within a namespace, a device id that repeats per site): + +```json +"conditions": [ + { "sourceProperty": "selectorAppLabel", "operator": "equals", "targetProperty": "appLabel" }, + { "sourceProperty": "namespace", "operator": "equals", "targetProperty": "namespace" } +] +``` + +For anything other than AND-of-all, add `conditionLogic` — a boolean expression over **1-based positional indexes** into the `conditions` array, using only `AND`, `OR`, and parentheses (max nesting depth 5, ≤256 chars): + +```json +"conditionLogic": "(1 OR 2) AND 3" +``` + +A condition the logic never references is silently never evaluated, so keep the two in sync. + +### Format expressions + +When the two properties hold the same identity in different shapes, transform one side with `sourceExpression` / `targetExpression` — a `{{ }}` template running full JavaScript over `value` (the same expression engine as tiles), ≤512 chars, and it **must** contain a `{{ }}` block: + +```json +{ + "sourceProperty": "resourceUri", + "sourceExpression": "{{ value.split('/').pop() }}", + "operator": "equals", + "targetProperty": "rawId" +} +``` + +Common shapes: `{{ value.toLowerCase() }}` (matching is case-**sensitive**), `{{ value.split(':')[1] }}`, `{{ 'prefix-' + value }}`. + +Prefer mapping a clean join key in the import over an expression — an expression runs per candidate vertex on every correlation run. + +--- + +## Bridge rules — matching through a join object + +When source and target share no property, but a third imported object joins them (a membership, an assignment, a mapping row), add a `bridge` leg. Its presence is what makes it a bridge rule: + +```json +{ + "displayName": "Member belongs to Team", + "source": { "types": "Member" }, + "bridge": { "types": "Team Membership" }, + "target": { "types": "Team" }, + "sourceConditions": [ + { "sourceProperty": "rawId", "operator": "equals", "targetProperty": "memberId" } + ], + "targetConditions": [ + { "sourceProperty": "teamId", "operator": "equals", "targetProperty": "rawId" } + ], + "labels": { "forward": "member of", "reverse": "has member" } +} +``` + +- **`sourceConditions`** (required, 1–25) compare **source → bridge**: `sourceProperty` reads the source object, `targetProperty` reads the bridge object. +- **`targetConditions`** (required, 1–25) compare **bridge → target**: `sourceProperty` reads the **bridge**, `targetProperty` reads the target. +- **`conditions`** (optional) compare source → target directly, as an extra filter. +- All three arrays are AND-ed. `conditionLogic` does not apply to bridge rules. +- The edge is written **source → target**; the bridge object isn't part of it. +- Bridge rules cannot use the canonical `"is"` label (see Merge below). + +Only reach for a bridge when the join object genuinely exists as an imported type. If the join is just an array of ids on one side, a plain condition rule matching an array property is simpler (arrays match if *any* element matches). + +--- + +## Group rules — synthesising a grouping object + +A group rule buckets source objects by a property value and creates a **synthetic group object per distinct value** — useful when the API exposes a grouping dimension (region, environment, team, tag) that has no object of its own to import. The `target` is an `objectGroup` instead of types: + +```json +{ + "displayName": "Device by Region", + "source": { "types": "Device" }, + "target": { + "objectGroup": { + "property": "region", + "sourceType": "Region" + } + }, + "conditions": [ + { "sourceProperty": "region", "operator": "equals", "targetProperty": "*" } + ], + "labels": { "forward": "in", "reverse": "contains" } +} +``` + +- `objectGroup.property` — the source property to bucket on (≤128 chars, cannot be `id`). +- `objectGroup.sourceType` — the type name given to the synthesised group objects. They're named after the property value and owned by the correlation engine, not by an import step. +- Group rules carry exactly **one** condition, whose `targetProperty` is the wildcard `"*"` (the only place the wildcard is legal). A `sourceExpression` on that condition still applies, so you can normalise the bucket key. + +Prefer importing a real object type when the API has a list endpoint for it — a real object carries properties and can be a dashboard scope. Reach for a group rule only when there's nothing to import. + +--- + +## Merge (`"is"`) — rarely right for a plugin + +A forward label of exactly `"is"` is **canonical**: instead of drawing a relationship, it merges the two objects into a single object in the graph. It exists to reconcile the same real-world thing seen by two different data sources — which a plugin rule can't do, because both legs are scoped to this plugin. Don't ship `"is"` unless you're deliberately deduplicating two object types within your own plugin, and never on a bridge rule (rejected at validation). + +--- + +## Which properties can be matched — the #1 authoring trap + +A condition can only reference a property that actually exists on the **graph vertex**. That means: + +| Available on every object | Notes | +| --------------------------------------------- | -------------------------------------------------------------- | +| `name` | from `objectMapping.name` | +| `rawId` | the raw value of `objectMapping.id`, unprefixed | +| `sourceType` | the object type | +| anything in `objectMapping.properties` | **only** what you explicitly mapped | + +A column your data stream returns but the import step doesn't map into `properties` **does not exist on the object** and will never match. So the join keys a correlation rule needs must be planned as mapped properties in Phase 2 and mapped in Phase 5 — that's why the Kubernetes plugin maps `ownerUid`, `nodeName`, and `namespace` even though no tile shows them. + +Matching semantics: + +- **Exact, case-sensitive string equality** after stringification — numbers and booleans are compared as their string form. Use `{{ value.toLowerCase() }}` when the two sides differ in case, and `{{ value.trim() }}` if one side may carry stray whitespace. +- **Array-valued properties match if any element matches** — a `tags` or `memberIds` array on either side works. +- A missing/empty value on either side never matches (it does not match another missing value). + +--- + +## Limits + +| Thing | Limit | +| ----------------------------------------- | ---------------------------- | +| `types` per leg | 25 (each ≤128 chars) | +| conditions per array | 25 | +| `displayName` | 256 chars | +| labels | 128 chars each | +| property names | 128 chars | +| format expressions | 512 chars | +| `conditionLogic` | 256 chars, nesting depth 5 | + +--- + +## Validation + +`squaredup validate --json` from the plugin dir validates every rule against the same schema the platform uses, and the summary reports `Correlation Rules: N`. **Check that count matches the number of files you wrote** — and treat any correlation error as blocking, since an invalid rule fails the whole plugin validation. Errors are reported per file as `: `, e.g. `conditions[0].operator: Condition operator must be "equals"`. + +--- + +## Lifecycle — when edges actually appear + +1. **Deploy** — rules ship with the plugin and are stored against the plugin id. +2. **Data source** — each authenticated instance of the plugin gets a read-only copy, visible in Settings → Correlation Rules under the data source rules tab. +3. **Evaluation** — correlation runs automatically, fire-and-forget, **after each successful import** for that data source. It is not part of the import's success status, so edges appear shortly *after* `index-status` reports done. + +So the sequence to see edges is always: deploy the rules → run an import → wait a moment. A rule added after the last import produces nothing until the next import completes. + +**Verifying:** the CLI has no edge-query command. Confirm rules deployed via the `Correlation Rules: N` count from `squaredup validate`, and confirm edges via the tenant UI — open an object and check its relationships/graph view — or with the SquaredUp MCP server's `graph_query` tool if one is connected. + +--- + +## Common mistakes + +| Mistake | Symptom / fix | +| ------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Join key not in `objectMapping.properties` | Rule validates, zero edges forever. Map the property, then **re-index** — existing objects are stale. | +| Rule written but no import run since | Zero edges. Trigger an import (Checkpoint B) after deploying rules. | +| `types` doesn't match `objectTypes` exactly | Zero edges, no error. Copy the string from `metadata.json`. | +| `operator` omitted | Validation failure — `equals` is required on every condition. | +| `reverse` label omitted | The relationship reads identically in both directions. Always write both. | +| Source/target swapped | Edges exist but read backwards. Source is the side holding the foreign key. | +| Renaming a rule file to "tidy up" | Old rule and all its edges deleted, new rule recreated. Keep filenames stable. | +| `pluginId` / `configs` written into the file | Redundant at best; both on one leg is a validation error. Leave scoping to deploy. | +| Expecting to correlate to another plugin's objects | Not possible from a plugin rule — both legs are scoped to this plugin. | +| One rule per object-type *pair* when a `types` array would do | Use an array: one Namespace→(Pod, Deployment, Service…) rule beats eight near-identical files. | diff --git a/.claude/skills/build-plugin/references/data-streams.md b/.claude/skills/build-plugin/references/data-streams.md index 450eb269..1fa2db0e 100644 --- a/.claude/skills/build-plugin/references/data-streams.md +++ b/.claude/skills/build-plugin/references/data-streams.md @@ -517,7 +517,7 @@ Works on primitives too — a string, number, or boolean at the path is returned "timeframes": ["last24hours", "last7days"] // limit to specific options ``` -**Canonical enum values** — these are the only valid entries in a `timeframes` array (and the only values `defaultTimeframe` accepts). Anything else fails validation with an opaque `✖ Invalid input`. +**Canonical enum values** — these are the only valid entries in a `timeframes` array. Anything else fails validation with an opaque `✖ Invalid input`. **They are _not_ the values `defaultTimeframe` takes** — that field accepts only `"none"` or `"dashboard"` (see below). ```text last1hour last12hours last24hours last7days last30days @@ -536,6 +536,13 @@ JSON-only timeframe properties (not settable via the Save as data stream modal): "requiresParameterTimeframe": true // timeframe params always injected even without user selection ``` +⚠️ **`defaultTimeframe` accepts exactly two values: `"none"` or `"dashboard"`.** It is not a timeframe enum — `"last7days"` and every other value from the list above fail validation with `Invalid value — expected one of: none, dashboard (path: defaultTimeframe)`. It chooses where a new tile's timeframe comes from, not which range it opens on: + +- `"dashboard"` (the default when omitted) — the tile inherits the dashboard's timeframe. +- `"none"` — the tile opens with timeframe "None", the same effect as `timeframes: false`. + +**There is no way to make a new tile default to a specific range.** If a range is wrong for the stream, remove it from `timeframes` so it can't be selected at all — restricting the array is the only lever you have. + ### Response size limit (~6MB) The Lambda that runs a stream caps its response at ~6MB. Exceeding it surfaces as a `Function.ResponseSizeTooLarge` 500 — not a validation error. Long timeframes are the usual trigger: a wide range × a fine interval returns far more rows than a short one. @@ -544,7 +551,7 @@ Remediation: - **If the endpoint can group, filter, or bucket server-side, push that down** (see [Push grouping, filtering and sorting down to the cheapest layer](#push-grouping-filtering-and-sorting-down-to-the-cheapest-layer)) — pre-aggregated rows are far smaller than raw ones, and this is often the only fix when even a short timeframe overflows. - Reduce the `pageSize` so each page (and the accumulated result) stays smaller. -- Restrict `timeframes` to the ranges the endpoint can actually return within the cap, and set a conservative `defaultTimeframe` so new tiles don't open on the largest range. +- Restrict `timeframes` to the ranges the endpoint can actually return within the cap. This is the whole fix — a range left in the array can be selected, and `defaultTimeframe` **cannot** be used to steer new tiles away from it (it only takes `"none"`/`"dashboard"`, see [Timeframes](#timeframes)). - **Apply the same restriction to every sibling stream on the same endpoint family.** If one stream on an endpoint overflows at `last30days`, its siblings hitting the same (or a heavier) endpoint will too — fixing only the one you happened to test leaves the rest broken. --- diff --git a/.claude/skills/build-plugin/references/index-defs.md b/.claude/skills/build-plugin/references/index-defs.md index cc7bc710..6693153e 100644 --- a/.claude/skills/build-plugin/references/index-defs.md +++ b/.claude/skills/build-plugin/references/index-defs.md @@ -59,9 +59,17 @@ Defines what gets imported into the SquaredUp graph. - `objectMapping.type` maps to the `sourceType` column. It can also be a fixed string: `{ "value": "My Device" }` — use when all rows are the same type, rather than a computed column. - `objectMapping.properties` are extra fields stored on the graph node, accessible in scripts as `object.propName`. - Use `{ "targetProp": "sourceProp" }` syntax when the column name differs from the desired property name. +- Use `{ "targetProp": { "value": } }` to stamp a **fixed value** onto every object the step imports, with no backing column — the same `{ "value": ... }` form `objectMapping.type` takes. Strings, numbers and booleans all work: + + ```json + "properties": ["namespace", { "region": { "value": "eu-west-1" } }, { "managed": { "value": true } }] + ``` + + Useful when one step's objects need a constant the API never returns — most often a discriminator that lets a correlation rule or a scope tell them apart from objects of the same type imported by a sibling step. - **Never map a column into `properties` that is already mapped as `id`, `name`, or `type`.** The id's raw value is always available on every object as `rawId` (`{{object.rawId}}` in templates, `context.objects[N].rawId` in scripts — and as a scalar, unlike user-defined properties, which arrive as arrays), and the name as `name`. Adding e.g. `{ "projectId": "id" }` to `properties` when `"id": "id"` already exists creates a duplicate that has to be re-indexed to take effect and otherwise sits as dead config — use `rawId`/`name` instead. - The `objectMapping.sourceType` column value **must** match an entry in `objectTypes` in `metadata.json`. For dynamic ones add these based on API response data later. - `frequencyMinutes` — controls re-import interval. Defaults to `720` (12 hours). +- **Index definitions create objects, never relationships.** There is no edge/relationship syntax here — objects are linked by correlation rules in `correlationRules/`, which match one object's property against another's. Those rules can only reference `name`, `rawId`, `sourceType`, and whatever is in `objectMapping.properties`, so **any join key a relationship needs must be mapped here** — that's the one legitimate reason to map a property no tile ever displays. See [correlation-rules.md](correlation-rules.md). The stream called by an import step must return one flat row per object with at least `sourceId`, `name` that are unique. diff --git a/.claude/skills/deploy-plugin/SKILL.md b/.claude/skills/deploy-plugin/SKILL.md index 7fe4ffe8..fb62f725 100644 --- a/.claude/skills/deploy-plugin/SKILL.md +++ b/.claude/skills/deploy-plugin/SKILL.md @@ -81,11 +81,14 @@ New plugins start at `1.0.0`. Use semver: | Change type | Bump | |---|---| | Bug fix, docs, icon, metadata tweak | PATCH (`1.0.x`) | -| New stream, new optional config field, new default content | MINOR (`1.x.0`) | +| New stream, new optional config field, new default content, new correlation rule | MINOR (`1.x.0`) | +| Deleted/renamed correlation rule | MINOR (`1.x.0`) — see warning below | | Deleted/renamed stream, breaking config change | MAJOR (`x.0.0`) | Every PR that modifies plugin files must include a version bump in `metadata.json`. +**Deleting or renaming a `correlationRules/*.json` file is quietly destructive.** Rules are matched by filename across upgrades, so a removed or renamed file deletes that rule **and every edge it created** on the next deploy — relationships vanish from the graph, and anything built on them (perspectives, drilldowns, relationship-scoped tiles) goes quiet. It breaks no config or data stream contract, so it stays MINOR, but call it out in the PR description, and rename a rule file only when you intend to drop its edges. + **Breaking (MAJOR) changes — do not create a new major version without asking the user first.** It is often possible to avoid the break entirely. If a major version is genuinely needed: - Create a new versioned folder (e.g. `v2/`) rather than modifying `v1/` - Mark the removed/changed stream `deprecated` in one release, then remove it in a follow-up major bump diff --git a/REVIEW.md b/REVIEW.md index 15fa9c9e..5b9dba05 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -96,6 +96,17 @@ When suggesting changes: - Name source types after how they are referred to in the upstream product or API (e.g. `agent`, `device`). Do not prefix them with the plugin name (e.g. avoid `NinjaOne Device`). A separate friendly display name can be configured if needed (via custom_types.json). +### Correlation rules - (correlationRules/\*.json) + +- Filenames are the stable rule identity across plugin upgrades, so a rename deletes the old rule and every edge it created. Question any renamed file in a PR, and prefer kebab-case names describing the relationship (e.g. `relate-device-to-site.json`). +- displayName - Name the relationship source-first, e.g. "Node runs Pod", "Device reports to Site". Mandatory in practice: it is what identifies the rule in the product. +- labels - Both `forward` and `reverse` should be present. `reverse` defaults to the forward label when omitted, which reads wrongly in one direction (e.g. "Node scheduled on Pod"). +- Join keys - Every `sourceProperty`/`targetProperty` must be `name`, `rawId`, `sourceType`, or a property the relevant import step maps in `objectMapping.properties`. A property the import doesn't map will validate cleanly and produce zero edges, so check the pairing against `indexDefinitions/` rather than the data stream columns. +- types - Must match `objectTypes` in metadata.json exactly. Prefer one rule with a `types` array over several near-identical files for the same relationship. +- Multiple conditions are AND-ed by default. Only expect `conditionLogic` when the author genuinely needs OR/parentheses, and check every condition index it references exists. +- Format expressions - `sourceExpression`/`targetExpression` run per candidate object on every correlation run. Where the upstream data allows, prefer mapping a clean join key in the import over transforming one here. +- Scope - Never accept `pluginId`, `configs`, `ruleType`, `schemaVersion`, `origin`, or condition `id`s in the file; all are stamped at deploy time. A plugin rule can only relate its own object types. + ### Documentation - (docs/README.md) - Should typically start headings from level 1. When embedded in SquaredUp, the headings will be sized appropriately. From c6ec9dfab6c123645d8d99384d420f6ea56876bd Mon Sep 17 00:00:00 2001 From: Dave Clarke Date: Mon, 17 Aug 2026 15:13:30 +0100 Subject: [PATCH 2/2] Address review: rule shapes, timeframe wording, markdown lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - correlation-rules.md: reframe around the four authorable shapes (Relate, Bridge, Group, Merge) instead of "condition vs bridge rules", per @adamkinniburgh — Relate/Group/Merge are all shapes of a condition rule and Bridge is a separate type, but type is resolved at install and isn't the author's concern. Adds a shapes table naming what each does and how each is recognised, which also resolves the inconsistency between the shippable-types list and the documented group rules. - data-streams.md: defaultTimeframe "none" is no longer equated with timeframes: false. Availability comes from `timeframes`; defaultTimeframe only picks the starting selection, so the other entries stay selectable. - SKILL.md: simplify the re-indexing rule heading to an ASCII anchor (MD051) and repoint its three references; the warning it carried moves into the body. - correlation-rules.md: add the `text` language to the file-layout fence (MD040). - REVIEW.md: name the four shapes so a reviewer can recognise a Merge or Group rule, and flag an unexplained "is" label. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01T3pxj55BWSS4FpuGwVrYkm --- .claude/skills/build-plugin/SKILL.md | 10 ++--- .../references/correlation-rules.md | 44 ++++++++++++------- .../build-plugin/references/data-streams.md | 2 +- REVIEW.md | 1 + 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/.claude/skills/build-plugin/SKILL.md b/.claude/skills/build-plugin/SKILL.md index 5a1ef4aa..d9e34a22 100644 --- a/.claude/skills/build-plugin/SKILL.md +++ b/.claude/skills/build-plugin/SKILL.md @@ -284,7 +284,7 @@ Skip only if the Phase 2 plan has no relationships. Write one `correlationRules/ Author them inline in the main agent (they're small, and they depend on the import mapping you're holding in context). For each planned relationship: 1. **Confirm both join keys exist on the object.** A condition can only match `name`, `rawId`, `sourceType`, or something in that step's `objectMapping.properties` — a column the stream returns but the step doesn't map does not exist on the object and will never match. -2. **If a join key is missing, add it to `objectMapping.properties` now.** Adding it here is free if that step's objects haven't been imported yet; if they have (a dependent-step build where Checkpoint B already ran for a lower level), every existing object is now stale — apply the [re-indexing rule](#re-indexing-rule--a-definition-change-leaves-imported-objects-stale). +2. **If a join key is missing, add it to `objectMapping.properties` now.** Adding it here is free if that step's objects haven't been imported yet; if they have (a dependent-step build where Checkpoint B already ran for a lower level), every existing object is now stale — apply the [re-indexing rule](#re-indexing-rule). 3. **Write the rule** — source is the side holding the foreign key; always write both `forward` and `reverse` labels; `"operator": "equals"` on every condition. Never write `pluginId`, `ruleType`, `schemaVersion`, or condition `id`s — those are stamped for you. 4. **Validate** — run `squaredup validate --json` from the plugin dir and confirm the summary's `Correlation Rules` count equals the number of files you wrote. An invalid rule fails the whole plugin validation, so fix any error before Checkpoint B. @@ -302,9 +302,9 @@ Scoped data streams can't be tested until objects exist, which means the import 4. **Confirm** — check objects landed with an **inline scope**: `squaredup objects --matches '{"sourceType":{"type":"equals","value":""}}' --plugin-id --datasource-id --json` should return a non-empty list. `` is a `sourceType` from the `objectTypes` you defined in `metadata.json` / `indexDefinitions/default.json`. Use `--matches` here, **not** `objects `: that form resolves a data stream file's `matches`, but no scoped data stream exists yet (those come in Phase 6) and the import streams written so far have no `matches` to resolve. For the same reason, pass **inline** JSON — `--matches @.json` won't work, as an import stream's `matches` is `none`/absent. 5. **Confirm relationships** — only if Phase 5b shipped correlation rules. Correlation is triggered automatically once the import succeeds, but fire-and-forget: it is _not_ part of the import status, so edges appear shortly **after** `index-status` reports done. The CLI has no edge-query command, so confirm in the tenant — ask the user to open an object of the source type and check its relationships — or use the SquaredUp MCP server's `graph_query` if one is connected. Zero edges from a rule that validated almost always means an unmapped join key or a `types` string that doesn't match `objectTypes`; see the common-mistakes table in [correlation-rules.md](references/correlation-rules.md). -### ⚠️ Re-indexing rule — a definition change leaves imported objects stale +### Re-indexing rule -The objects now in the graph are **frozen at import time**: any later edit to `indexDefinitions/*.json` or an import stream leaves every existing object **stale** until the datasource is re-imported — a property you map now is absent on every already-imported object. +⚠️ **A definition change leaves imported objects stale.** The objects now in the graph are **frozen at import time**: any later edit to `indexDefinitions/*.json` or an import stream leaves every existing object **stale** until the datasource is re-imported — a property you map now is absent on every already-imported object. So before you **rely** on such a change — spawning Phase 6 sub-agents that reference a new property, building dashboards on it, or shipping a stream that scopes on it — **re-run the full Checkpoint B cycle and confirm the change itself landed**, not merely that the import succeeded. Only then may you tell Phase 6 sub-agents the property exists. The procedure — including checking the change is even needed before re-indexing — lives in [checkpoints.md](references/checkpoints.md). Skipping it is the root cause of the shipped `undefined === undefined` scope bug — see [testing.md](references/testing.md), "The two-object rule". @@ -318,7 +318,7 @@ For each data stream in the Phase 2 plan, **spawn one build-mode sub-agent (all Collect the reports, then **run [the reconciliation pass](#the-reconciliation-pass)** before Phase 7 — now across the data-stream reports. This is where a constraint one stream hit — a daily-granularity endpoint that 404s on `last1hour`, the ~6MB response cap that 500s on long timeframes — gets its `timeframes` fix propagated to **all** sibling streams on that endpoint, not just the one that found it. -If resolving a contradiction means **adding or renaming a mapped property** in `indexDefinitions/*.json` (not just fixing a stream to use one that already exists), the imported objects are now stale — apply the [re-indexing rule](#re-indexing-rule--a-definition-change-leaves-imported-objects-stale) before re-spawning sub-agents that rely on the change. +If resolving a contradiction means **adding or renaming a mapped property** in `indexDefinitions/*.json` (not just fixing a stream to use one that already exists), the imported objects are now stale — apply the [re-indexing rule](#re-indexing-rule) before re-spawning sub-agents that rely on the change. `test` sends the **local** stream config against the deployed plugin, so **no redeploy** is needed to test a new or edited stream (including the re-tests above) — only Checkpoints A and B and the final deploy redeploy. @@ -349,4 +349,4 @@ Write `custom_types.json` — for this and other reusable patterns (built-in pro Then invoke the `deploy-plugin` skill for the final validate, version bump, and deploy. -**Conditional final re-index.** If `indexDefinitions/*.json` or any import stream changed since the last successful import (the Checkpoint B run, or any re-index triggered by the [re-indexing rule](#re-indexing-rule--a-definition-change-leaves-imported-objects-stale)), the deployed tenant's objects are **stale** — they still match the **old** definition and won't pick up the new shape until the next scheduled import, up to `frequencyMinutes` away (default `720` = 12 hours). So after the final deploy lands, trigger + poll one more import (the [Checkpoint B](#checkpoint-b-redeploy--run-the-first-import) trigger/wait steps) so the deployed objects match the shipped definition. Skip only if no import definition or import stream has changed since the last import. +**Conditional final re-index.** If `indexDefinitions/*.json` or any import stream changed since the last successful import (the Checkpoint B run, or any re-index triggered by the [re-indexing rule](#re-indexing-rule)), the deployed tenant's objects are **stale** — they still match the **old** definition and won't pick up the new shape until the next scheduled import, up to `frequencyMinutes` away (default `720` = 12 hours). So after the final deploy lands, trigger + poll one more import (the [Checkpoint B](#checkpoint-b-redeploy--run-the-first-import) trigger/wait steps) so the deployed objects match the shipped definition. Skip only if no import definition or import stream has changed since the last import. diff --git a/.claude/skills/build-plugin/references/correlation-rules.md b/.claude/skills/build-plugin/references/correlation-rules.md index 23f84a27..e0f83f28 100644 --- a/.claude/skills/build-plugin/references/correlation-rules.md +++ b/.claude/skills/build-plugin/references/correlation-rules.md @@ -8,7 +8,7 @@ Edges are what make a Pod appear under its Node, a device under its site, an ale ## File layout -``` +```text my-plugin/ v1/ correlationRules/ @@ -29,20 +29,32 @@ Write only the rule's *shape*. These fields are filled in for you and **must not | Field | Filled in by | | ------------------------------------ | ------------------------------------------------------------------ | | `ruleName` | the filename | -| `ruleType` | inferred — `bridge` if the rule has a `bridge` leg, else `condition` | +| `ruleType` | inferred from the file's shape at install — never write it | | `schemaVersion` | always `2` | | `id`, condition `id`s | positional (`"1"`, `"2"`, … in array order) — referenced by `conditionLogic` | | `pluginId` (rule, source, target, bridge) | deploy time — scoped to this plugin | | `origin`, `edgeSource`, `configs` | deploy time | -Two consequences of this: +One consequence worth knowing: **a plugin rule can only relate object types from its own plugin.** Cross-plugin correlation exists, but only as a user-authored rule in Settings → Correlation Rules — a plugin can't ship one. + +--- + +## The four shapes + +Write the shape you want; the platform works out the rule type on install. Each shape is recognised purely from the fields present in the file, so there is nothing to declare: + +| Shape | What it does | Recognised by | +| ---------- | ------------------------------------------------------------------------- | ------------------------------------------ | +| **Relate** | Draws a labelled edge between two object types | the default — no `bridge`, no `objectGroup` | +| **Bridge** | Relates source to target *through* a third, joining object type | a `bridge` leg is present | +| **Group** | Buckets source objects under a synthesised grouping object | `target` is an `objectGroup` | +| **Merge** | Collapses the two objects into one, instead of relating them | `labels.forward` is exactly `"is"` | -- **A plugin rule can only relate object types from its own plugin.** Cross-plugin correlation exists, but only as a user-authored rule in Settings → Correlation Rules — a plugin can't ship one. -- **Only condition and bridge rules are shippable.** `ruleType` is inferred from the file's shape, so the platform's other rule types — semantic (fuzzy name matching) and direct (one specific object to another) — can't be authored in a plugin. Writing `semanticThreshold` into a file doesn't produce a semantic rule; it's read as a condition rule with no conditions and fails validation. +The platform has two further rule types — **semantic** (fuzzy name matching) and **direct** (one specific object to one other) — that a plugin **cannot** ship; they only exist as user-authored rules. Writing `semanticThreshold` into a file doesn't produce a semantic rule, it produces a Relate rule with no conditions, which fails validation. --- -## The condition rule (what you'll write 95% of the time) +## Relate — the default shape (95% of what you'll write) ```json { @@ -117,9 +129,9 @@ Prefer mapping a clean join key in the import over an expression — an expressi --- -## Bridge rules — matching through a join object +## Bridge — matching through a join object -When source and target share no property, but a third imported object joins them (a membership, an assignment, a mapping row), add a `bridge` leg. Its presence is what makes it a bridge rule: +When source and target share no property, but a third imported object joins them (a membership, an assignment, a mapping row), add a `bridge` leg. Its presence is what makes it a Bridge rule: ```json { @@ -140,17 +152,17 @@ When source and target share no property, but a third imported object joins them - **`sourceConditions`** (required, 1–25) compare **source → bridge**: `sourceProperty` reads the source object, `targetProperty` reads the bridge object. - **`targetConditions`** (required, 1–25) compare **bridge → target**: `sourceProperty` reads the **bridge**, `targetProperty` reads the target. - **`conditions`** (optional) compare source → target directly, as an extra filter. -- All three arrays are AND-ed. `conditionLogic` does not apply to bridge rules. +- All three arrays are AND-ed. `conditionLogic` does not apply to Bridge rules. - The edge is written **source → target**; the bridge object isn't part of it. -- Bridge rules cannot use the canonical `"is"` label (see Merge below). +- A Bridge rule cannot use the `"is"` label — see the Merge section below. -Only reach for a bridge when the join object genuinely exists as an imported type. If the join is just an array of ids on one side, a plain condition rule matching an array property is simpler (arrays match if *any* element matches). +Only reach for a bridge when the join object genuinely exists as an imported type. If the join is just an array of ids on one side, a plain Relate rule matching an array property is simpler (arrays match if *any* element matches). --- -## Group rules — synthesising a grouping object +## Group — synthesising a grouping object -A group rule buckets source objects by a property value and creates a **synthetic group object per distinct value** — useful when the API exposes a grouping dimension (region, environment, team, tag) that has no object of its own to import. The `target` is an `objectGroup` instead of types: +A Group rule buckets source objects by a property value and creates a **synthetic group object per distinct value** — useful when the API exposes a grouping dimension (region, environment, team, tag) that has no object of its own to import. The `target` is an `objectGroup` instead of types: ```json { @@ -173,13 +185,13 @@ A group rule buckets source objects by a property value and creates a **syntheti - `objectGroup.sourceType` — the type name given to the synthesised group objects. They're named after the property value and owned by the correlation engine, not by an import step. - Group rules carry exactly **one** condition, whose `targetProperty` is the wildcard `"*"` (the only place the wildcard is legal). A `sourceExpression` on that condition still applies, so you can normalise the bucket key. -Prefer importing a real object type when the API has a list endpoint for it — a real object carries properties and can be a dashboard scope. Reach for a group rule only when there's nothing to import. +Prefer importing a real object type when the API has a list endpoint for it — a real object carries properties and can be a dashboard scope. Reach for a Group rule only when there's nothing to import. --- -## Merge (`"is"`) — rarely right for a plugin +## Merge — rarely right for a plugin -A forward label of exactly `"is"` is **canonical**: instead of drawing a relationship, it merges the two objects into a single object in the graph. It exists to reconcile the same real-world thing seen by two different data sources — which a plugin rule can't do, because both legs are scoped to this plugin. Don't ship `"is"` unless you're deliberately deduplicating two object types within your own plugin, and never on a bridge rule (rejected at validation). +A forward label of exactly `"is"` is **canonical**: instead of drawing a relationship, it merges the two objects into a single object in the graph. It exists to reconcile the same real-world thing seen by two different data sources — which a plugin rule can't do, because both legs are scoped to this plugin. Don't ship `"is"` unless you're deliberately deduplicating two object types within your own plugin, and never on a Bridge rule (rejected at validation). --- diff --git a/.claude/skills/build-plugin/references/data-streams.md b/.claude/skills/build-plugin/references/data-streams.md index 1fa2db0e..0aebfa78 100644 --- a/.claude/skills/build-plugin/references/data-streams.md +++ b/.claude/skills/build-plugin/references/data-streams.md @@ -539,7 +539,7 @@ JSON-only timeframe properties (not settable via the Save as data stream modal): ⚠️ **`defaultTimeframe` accepts exactly two values: `"none"` or `"dashboard"`.** It is not a timeframe enum — `"last7days"` and every other value from the list above fail validation with `Invalid value — expected one of: none, dashboard (path: defaultTimeframe)`. It chooses where a new tile's timeframe comes from, not which range it opens on: - `"dashboard"` (the default when omitted) — the tile inherits the dashboard's timeframe. -- `"none"` — the tile opens with timeframe "None", the same effect as `timeframes: false`. +- `"none"` — the tile **opens** on "None"; every other entry in `timeframes` stays selectable. This is not the same as `timeframes: false`, which removes timeframe support altogether — availability comes from `timeframes`, `defaultTimeframe` only picks the starting selection. Pair it with `supportsNoneTimeframe: true` so "None" is a valid option in the first place. **There is no way to make a new tile default to a specific range.** If a range is wrong for the stream, remove it from `timeframes` so it can't be selected at all — restricting the array is the only lever you have. diff --git a/REVIEW.md b/REVIEW.md index 5b9dba05..4780b8dd 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -99,6 +99,7 @@ When suggesting changes: ### Correlation rules - (correlationRules/\*.json) - Filenames are the stable rule identity across plugin upgrades, so a rename deletes the old rule and every edge it created. Question any renamed file in a PR, and prefer kebab-case names describing the relationship (e.g. `relate-device-to-site.json`). +- Four shapes are authorable, recognised from the fields present rather than declared: **Relate** (the default), **Bridge** (has a `bridge` leg), **Group** (`target` is an `objectGroup`), and **Merge** (`labels.forward` is exactly `"is"`). Merge collapses two objects into one rather than relating them, so treat an unexplained `"is"` label as a mistake unless the author is deliberately deduplicating. - displayName - Name the relationship source-first, e.g. "Node runs Pod", "Device reports to Site". Mandatory in practice: it is what identifies the rule in the product. - labels - Both `forward` and `reverse` should be present. `reverse` defaults to the forward label when omitted, which reads wrongly in one direction (e.g. "Node scheduled on Pod"). - Join keys - Every `sourceProperty`/`targetProperty` must be `name`, `rawId`, `sourceType`, or a property the relevant import step maps in `objectMapping.properties`. A property the import doesn't map will validate cleanly and produce zero edges, so check the pairing against `indexDefinitions/` rather than the data stream columns.