diff --git a/docs/log.md b/docs/log.md index 2fdc3b59..50869f09 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,8 @@ ## 2026-08-21 +* **Update**: `rules/rule-id-namespace.md` — the export→paste round-trip is no longer lossy: the JSON editors now map rule-shaped exports into the form (`gatherRuleToForm` unflattens the nested gather `parameters` object, analyze `jsonToForm` maps `evaluateOn` onto the eval checkboxes; `tags` and the effective `markSessionAsFailed` carry into the created rule). Both editors still accept the serialized-form shape; edit-mode pastes merge, with an explicit `parameters`/`evaluateOn` winning over stale flat fields. + * **New**: `rules/rule-id-namespace.md` — rule identity model: human-readable `ruleId` strings (no GUIDs), tenant isolation via Table Storage partitioning (`global` = built-in/community, tenantId = custom), the reserved built-in namespace from `RuleIdPolicy`, and the four collision guards: catalog-only global `RuleStats` aggregate (same-ID custom rules of unrelated tenants no longer sum into one `global_{ruleId}` row), combine.js duplicate/namespace fail, GitHub-reseed rejection of non-reserved IDs into the global partition, and the namespace gate on the cross-tenant RuleState GC. * **New**: `backend/app-install-duration-attempt.md` — attempt-scoped app install durations: `DurationSeconds` = status-defining (last) attempt anchored on the new max-folded `LastAttemptStartedAt` (StartedAt stays the earliest-wins window/version-ordering key; legacy rows without the anchor keep the span fallback); `InstallPassCount` with replay-guarded additive fold; weaker-terminal guard (completed-with-Skipped never overrides Installed/Error, in-batch and cross-batch); attempt-scoped `DownloadDurationSeconds` pairing; the version-regression radar's both-sides cutover gate; time attribution counts the union of per-pass active segments (occupancy merges segments, hulls stay for display/what-if); agent payload timing re-arms both endpoints on terminal→active. No backfill — pre-cutover rows keep span semantics. diff --git a/docs/rules/rule-id-namespace.md b/docs/rules/rule-id-namespace.md index f9c17d72..6196331a 100644 --- a/docs/rules/rule-id-namespace.md +++ b/docs/rules/rule-id-namespace.md @@ -27,7 +27,7 @@ Namespace split (`RuleIdPolicy.cs`, shared model): - **Custom vs built-in**: impossible via API — `CreateRuleAsync`/`UpdateRuleAsync` reject reserved IDs (409) and check existence against `global` ∪ own tenant partition. Legacy debris is neutralized at merge time: global wins, tenant copy dropped with a warning (pinned by `RuleMergeCollisionGuardTests`, motivated by the 5ca2b350 duplicate-key incident). - **Custom vs custom across tenants**: allowed and harmless — separate partitions, separate evaluation, separate RuleResults/tenant stats. Two tenants both using `ANALYZE-CUSTOM-101` never interact. -- **JSON copy between tenants** (export → paste into create editor): goes through the same create path, so an ID already taken in the receiving tenant surfaces as 409 — the user renames. There is no bulk import endpoint; note the export→paste round-trip is lossy (gather `parameters`, analyze `evaluateOn` are form-derived and silently dropped by the create editor). +- **JSON copy between tenants** (export → paste into create editor): goes through the same create path, so an ID already taken in the receiving tenant surfaces as 409 — the user renames. There is no bulk import endpoint. The JSON editors accept both shapes — a rule export and the serialized form — via `gatherRuleToForm` / `jsonToForm` (web `types.ts` of each rules page): nested gather `parameters` are unflattened, analyze `evaluateOn` maps onto the eval checkboxes, and `tags` plus the effective `markSessionAsFailed` survive the import. # Guards (all enforced, 2026-08-21) diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts new file mode 100644 index 00000000..630200f7 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/__tests__/analyzeRuleFormLogic.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from "vitest"; +import { EMPTY_FORM, formToEvaluateOn, jsonToForm } from "../types"; + +/** + * The JSON editor serializes the FORM object (eval* checkbox fields), but what users + * paste is usually a rule EXPORT (the "Export" button's shape, e.g. received from + * another tenant) which carries `evaluateOn` instead. Before the mapper, a pasted + * export silently lost evaluateOn — the imported rule quietly reverted to the + * enrollment-end default. + */ +describe("jsonToForm", () => { + const baseExport = { + ruleId: "ANALYZE-CUSTOM-101", + title: "VPN client install failed", + severity: "high", + category: "network", + conditions: [ + { + signal: "vpn_failed", + source: "event_data", + eventType: "app_install_failed", + dataField: "appName", + operator: "contains", + value: "VPN", + required: true, + }, + ], + baseConfidence: 70, + confidenceThreshold: 50, + tags: ["vpn", "network"], + }; + + it("maps a rule-shaped export's evaluateOn onto the form flags", () => { + const form = jsonToForm({ + ...baseExport, + evaluateOn: ["whiteglove_sealed", "on_event:vpn_error"], + }); + + expect(form.evalAtEnrollmentEnd).toBe(false); + expect(form.evalAtWhitegloveSealed).toBe(true); + expect(form.evalOnEventTypes).toBe("vpn_error"); + expect(form.conditions).toHaveLength(1); + expect(form.tags).toEqual(["vpn", "network"]); + }); + + it("defaults a terminal-only export (no evaluateOn key) to enrollment end", () => { + const form = jsonToForm(baseExport); + + expect(form.evalAtEnrollmentEnd).toBe(true); + expect(form.evalAtWhitegloveSealed).toBe(false); + expect(form.evalOnEventTypes).toBe(""); + }); + + it("passes form-shaped JSON (eval* fields, no evaluateOn) through unchanged", () => { + const form = jsonToForm({ + ...EMPTY_FORM, + ruleId: "ANALYZE-CUSTOM-102", + title: "Pasted form", + evalAtEnrollmentEnd: false, + evalAtWhitegloveSealed: true, + }); + + expect(form.evalAtEnrollmentEnd).toBe(false); + expect(form.evalAtWhitegloveSealed).toBe(true); + }); + + it("prefers an explicit evaluateOn over stale form flags on an edit merge", () => { + // Edit-mode paste merges into the current form, so both shapes are present: + // the export's evaluateOn must win over the edited rule's stale checkboxes. + const form = jsonToForm({ + ...EMPTY_FORM, + ...baseExport, + evalAtEnrollmentEnd: true, + evaluateOn: ["whiteglove_sealed"], + }); + + expect(form.evalAtEnrollmentEnd).toBe(false); + expect(form.evalAtWhitegloveSealed).toBe(true); + }); + + it("folds the tenant markSessionAsFailed override into the imported default", () => { + // An exported rule that (effectively) marks sessions failed must keep doing so + // when re-created in another tenant from its JSON. + expect(jsonToForm({ ...baseExport, markSessionAsFailed: true }).markSessionAsFailedDefault).toBe(true); + expect(jsonToForm({ ...baseExport, markSessionAsFailedDefault: true }).markSessionAsFailedDefault).toBe(true); + expect(jsonToForm({ ...baseExport, markSessionAsFailedDefault: true, markSessionAsFailed: false }).markSessionAsFailedDefault).toBe(false); + expect(jsonToForm(baseExport).markSessionAsFailedDefault).toBe(false); + }); + + it("round-trips evaluateOn through the form and back", () => { + const evaluateOn = ["enrollment_end", "whiteglove_sealed", "on_event:vpn_error"]; + + expect(formToEvaluateOn(jsonToForm({ ...baseExport, evaluateOn }))).toEqual(evaluateOn); + // Terminal-only stays clean of the field (undefined = backend default). + expect(formToEvaluateOn(jsonToForm(baseExport))).toBeUndefined(); + }); +}); diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx index 3473af44..7c60e51a 100644 --- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx +++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { AnalyzeRule, RuleForm, getSeverityColor, getCategoryColor, interimTriggerLabels } from "../types"; +import { AnalyzeRule, RuleForm, PastedAnalyzeJson, getSeverityColor, getCategoryColor, interimTriggerLabels, jsonToForm } from "../types"; import { FormJsonToggle, JsonModeToggleButtons, ReadOnlyJsonView } from "@/components/rules/FormJsonToggle"; import AnalyzeRuleFormFields from "./AnalyzeRuleFormFields"; import { stripInternalFields } from "@/lib/rulePageHelpers"; @@ -635,9 +635,11 @@ export default function AnalyzeRuleCard({ jsonError={jsonError} onApplyJson={() => { try { - const parsed = JSON.parse(jsonText) as RuleForm; + const parsed = JSON.parse(jsonText) as PastedAnalyzeJson; if (!parsed.title) throw new Error("JSON must include title"); - setEditForm({ ...editForm, ...parsed }); + // Merge keeps unmentioned fields; a rule-shaped paste brings an explicit + // evaluateOn, which the mapper prefers over the stale eval* form fields. + setEditForm(jsonToForm({ ...editForm, ...parsed })); onSetJsonModeEdit(false); onSetJsonError(null); } catch (e) { @@ -655,9 +657,9 @@ export default function AnalyzeRuleCard({