analytics_ops 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/CODE_OF_CONDUCT.md +10 -0
  4. data/CONTRIBUTING.md +67 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +188 -0
  7. data/SECURITY.md +56 -0
  8. data/docs/api-support-matrix.md +50 -0
  9. data/docs/architecture.md +89 -0
  10. data/docs/authentication.md +86 -0
  11. data/docs/commands.md +112 -0
  12. data/docs/configuration-schema-v1.json +129 -0
  13. data/docs/configuration.md +167 -0
  14. data/docs/google-client-compatibility.md +59 -0
  15. data/docs/plan-format.md +89 -0
  16. data/docs/plan-schema-v1.json +224 -0
  17. data/docs/rails.md +73 -0
  18. data/docs/reports.md +124 -0
  19. data/docs/safety.md +93 -0
  20. data/docs/troubleshooting.md +103 -0
  21. data/exe/analytics-ops +6 -0
  22. data/lib/analytics_ops/applier.rb +52 -0
  23. data/lib/analytics_ops/canonical.rb +65 -0
  24. data/lib/analytics_ops/cli.rb +427 -0
  25. data/lib/analytics_ops/clients/admin.rb +446 -0
  26. data/lib/analytics_ops/clients/data.rb +314 -0
  27. data/lib/analytics_ops/configuration/document.rb +25 -0
  28. data/lib/analytics_ops/configuration/loader.rb +76 -0
  29. data/lib/analytics_ops/configuration/schema.rb +107 -0
  30. data/lib/analytics_ops/configuration/validator.rb +344 -0
  31. data/lib/analytics_ops/configuration.rb +19 -0
  32. data/lib/analytics_ops/desired_state.rb +40 -0
  33. data/lib/analytics_ops/errors.rb +31 -0
  34. data/lib/analytics_ops/plan.rb +551 -0
  35. data/lib/analytics_ops/planner.rb +233 -0
  36. data/lib/analytics_ops/rails/railtie.rb +65 -0
  37. data/lib/analytics_ops/rails.rb +5 -0
  38. data/lib/analytics_ops/redaction.rb +46 -0
  39. data/lib/analytics_ops/reports/catalog.rb +100 -0
  40. data/lib/analytics_ops/reports/definition.rb +226 -0
  41. data/lib/analytics_ops/reports/result.rb +90 -0
  42. data/lib/analytics_ops/reports.rb +13 -0
  43. data/lib/analytics_ops/resources.rb +79 -0
  44. data/lib/analytics_ops/snapshot.rb +45 -0
  45. data/lib/analytics_ops/version.rb +5 -0
  46. data/lib/analytics_ops/workspace.rb +212 -0
  47. data/lib/analytics_ops.rb +21 -0
  48. data/lib/generators/analytics_ops/install_generator.rb +23 -0
  49. data/lib/generators/analytics_ops/templates/analytics-ops +9 -0
  50. data/lib/generators/analytics_ops/templates/analytics_ops.yml +23 -0
  51. data/sig/analytics_ops.rbs +395 -0
  52. metadata +131 -0
data/docs/commands.md ADDED
@@ -0,0 +1,112 @@
1
+ # Commands
2
+
3
+ The executable is `analytics-ops`. All commands use
4
+ `config/analytics_ops.yml` and the `production` profile unless overridden.
5
+
6
+ ```text
7
+ analytics-ops COMMAND [options]
8
+ ```
9
+
10
+ ## Fast examples
11
+
12
+ ```bash
13
+ analytics-ops doctor
14
+ analytics-ops audit --format json
15
+ analytics-ops plan --output tmp/ga4-plan.json
16
+ analytics-ops apply tmp/ga4-plan.json
17
+ analytics-ops report landing_pages --format csv
18
+ analytics-ops realtime --format json
19
+ ```
20
+
21
+ Only `apply` can write to Google.
22
+
23
+ ## Read-only commands
24
+
25
+ | Command | Result |
26
+ | --- | --- |
27
+ | `doctor` | Checks the local file, credentials, Admin API, Data API, property access, client versions, edit visibility, and clock |
28
+ | `discover` | Lists accessible account IDs, property IDs, and stream IDs |
29
+ | `snapshot` | Prints normalized managed remote state and its fingerprint |
30
+ | `audit` | Compares desired state with a fresh snapshot; exits 2 for drift |
31
+ | `plan` | Generates the same comparison; `--output FILE` saves its exact JSON |
32
+ | `verify` | Replans and exits 0 only when managed state converges |
33
+ | `report NAME` | Runs one built-in standard Data API report |
34
+ | `realtime [NAME]` | Runs `realtime_events` by default |
35
+ | `schema` | Prints the version-1 configuration schema |
36
+
37
+ `doctor` cannot reliably inspect scopes inside an issued Google token. It
38
+ reports that check as unknown and proves effective access with real read-only
39
+ calls instead.
40
+
41
+ ## Apply
42
+
43
+ ```bash
44
+ analytics-ops apply PLAN_FILE
45
+ ```
46
+
47
+ Interactive apply:
48
+
49
+ 1. Loads and strictly validates the saved plan.
50
+ 2. Prints every saved before value, after value, and rollback instruction.
51
+ 3. Requires the exact response `yes`.
52
+ 4. Refreshes the remote snapshot.
53
+ 5. Rejects the plan if its fingerprint is stale.
54
+ 6. Executes only its saved create/update operations, stopping on first failure.
55
+
56
+ Automation must be explicit:
57
+
58
+ ```bash
59
+ analytics-ops apply tmp/ga4-plan.json --non-interactive --yes --format json
60
+ ```
61
+
62
+ `--non-interactive` without `--yes` is an error. There is no command that
63
+ plans and applies in one step.
64
+
65
+ ## Options
66
+
67
+ | Option | Meaning |
68
+ | --- | --- |
69
+ | `-c, --config PATH` | Configuration file |
70
+ | `-p, --profile NAME` | Profile inside the file |
71
+ | `-f, --format FORMAT` | `human`, `json`, or report-only `csv` |
72
+ | `-o, --output PATH` | Save generated JSON; valid only with `plan` |
73
+ | `--transport grpc|rest` | Official Google-client transport |
74
+ | `--timeout SECONDS` | Positive API call timeout |
75
+ | `--log-level LEVEL` | Structured request metadata: `debug`, `info`, `warn`, or `error`; default `warn` |
76
+ | `--yes` | Approve every operation in a saved plan; apply only |
77
+ | `--non-interactive` | Never prompt; apply only and requires `--yes` |
78
+
79
+ Unknown arguments and options fail instead of being ignored. CSV is rejected
80
+ for anything except report results. CSV cells beginning with spreadsheet
81
+ formula characters are prefixed safely.
82
+
83
+ ## Output
84
+
85
+ - Human output is readable in a terminal.
86
+ - JSON output uses stable gem-owned fields and structured errors.
87
+ - CSV contains report headers and rows only; it never applies to plans,
88
+ snapshots, or errors.
89
+
90
+ No command prints credentials or generated Google protobuf objects. Report
91
+ rows are emitted only because the user requested a report; they are never
92
+ logged automatically.
93
+
94
+ ## Exit statuses
95
+
96
+ | Status | Meaning |
97
+ | ---: | --- |
98
+ | 0 | Success |
99
+ | 2 | Drift found or verification not converged |
100
+ | 64 | Invalid command, option, argument, or missing confirmation |
101
+ | 65 | Invalid configuration, plan, or ambiguous identity |
102
+ | 66 | Authentication failure |
103
+ | 69 | Invalid Google request or other remote API failure |
104
+ | 74 | Timeout |
105
+ | 75 | Quota or rate limit |
106
+ | 77 | Google Analytics permission failure |
107
+ | 78 | Unsupported installed-client capability |
108
+ | 79 | Stale saved plan |
109
+ | 80 | Partial apply; inspect reconciliation output |
110
+
111
+ When `--format json` is selected, errors are JSON on standard error and are
112
+ suitable for automation.
@@ -0,0 +1,129 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/nelsonchaves/analytics_ops/blob/master/docs/configuration-schema-v1.json",
4
+ "title": "Analytics Ops configuration version 1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": ["version", "profiles"],
8
+ "properties": {
9
+ "version": { "const": 1 },
10
+ "profiles": {
11
+ "type": "object",
12
+ "minProperties": 1,
13
+ "propertyNames": { "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
14
+ "additionalProperties": { "$ref": "#/$defs/profile" }
15
+ }
16
+ },
17
+ "$defs": {
18
+ "id": {
19
+ "type": "string",
20
+ "pattern": "^(?:[0-9]{1,50}|\\$\\{[A-Z][A-Z0-9_]*\\})$"
21
+ },
22
+ "profile": {
23
+ "type": "object",
24
+ "additionalProperties": false,
25
+ "required": ["property_id"],
26
+ "properties": {
27
+ "property_id": { "$ref": "#/$defs/id" },
28
+ "streams": {
29
+ "type": "object",
30
+ "propertyNames": { "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" },
31
+ "additionalProperties": { "$ref": "#/$defs/stream" }
32
+ },
33
+ "retention": { "$ref": "#/$defs/retention" },
34
+ "google_signals": { "$ref": "#/$defs/googleSignals" },
35
+ "key_events": {
36
+ "type": "array",
37
+ "uniqueItems": true,
38
+ "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" }
39
+ },
40
+ "custom_dimensions": {
41
+ "type": "array",
42
+ "items": { "$ref": "#/$defs/customDimension" }
43
+ },
44
+ "custom_metrics": {
45
+ "type": "array",
46
+ "items": { "$ref": "#/$defs/customMetric" }
47
+ },
48
+ "manual_requirements": {
49
+ "type": "array",
50
+ "uniqueItems": true,
51
+ "items": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,63}$" }
52
+ }
53
+ }
54
+ },
55
+ "stream": {
56
+ "type": "object",
57
+ "additionalProperties": false,
58
+ "required": ["stream_id"],
59
+ "properties": {
60
+ "stream_id": { "$ref": "#/$defs/id" },
61
+ "default_uri": {
62
+ "type": "string",
63
+ "maxLength": 2048,
64
+ "pattern": "^(?:https?://|\\$\\{)"
65
+ },
66
+ "enhanced_measurement": {
67
+ "type": "object",
68
+ "additionalProperties": false,
69
+ "required": ["enabled", "experimental"],
70
+ "properties": {
71
+ "enabled": { "type": "boolean" },
72
+ "experimental": { "const": true }
73
+ }
74
+ }
75
+ }
76
+ },
77
+ "retention": {
78
+ "type": "object",
79
+ "additionalProperties": false,
80
+ "required": ["event_data", "user_data", "reset_on_new_activity"],
81
+ "properties": {
82
+ "event_data": { "$ref": "#/$defs/retentionValue" },
83
+ "user_data": { "$ref": "#/$defs/retentionValue" },
84
+ "reset_on_new_activity": { "type": "boolean" }
85
+ }
86
+ },
87
+ "retentionValue": {
88
+ "enum": ["2_months", "14_months", "26_months", "38_months", "50_months"]
89
+ },
90
+ "googleSignals": {
91
+ "type": "object",
92
+ "additionalProperties": false,
93
+ "required": ["state", "experimental"],
94
+ "properties": {
95
+ "state": { "enum": ["enabled", "disabled"] },
96
+ "experimental": { "const": true }
97
+ }
98
+ },
99
+ "customDimension": {
100
+ "type": "object",
101
+ "additionalProperties": false,
102
+ "required": ["parameter_name", "display_name", "scope"],
103
+ "properties": {
104
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
105
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
106
+ "description": { "type": "string", "maxLength": 150 },
107
+ "scope": { "enum": ["event", "user", "item"] },
108
+ "disallow_ads_personalization": { "type": "boolean" }
109
+ }
110
+ },
111
+ "customMetric": {
112
+ "type": "object",
113
+ "additionalProperties": false,
114
+ "required": ["parameter_name", "display_name", "scope"],
115
+ "properties": {
116
+ "parameter_name": { "type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_]{0,39}$" },
117
+ "display_name": { "type": "string", "minLength": 1, "maxLength": 82 },
118
+ "description": { "type": "string", "maxLength": 150 },
119
+ "scope": { "const": "event" },
120
+ "measurement_unit": {
121
+ "enum": [
122
+ "standard", "currency", "feet", "meters", "kilometers", "miles",
123
+ "milliseconds", "seconds", "minutes", "hours"
124
+ ]
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
@@ -0,0 +1,167 @@
1
+ # Configuration
2
+
3
+ Analytics Ops reads strict, versioned YAML. The file describes desired GA4
4
+ state; it never contains credentials.
5
+
6
+ ## Smallest valid file
7
+
8
+ ```yaml
9
+ version: 1
10
+
11
+ profiles:
12
+ production:
13
+ property_id: "123456789"
14
+ ```
15
+
16
+ Numeric identifiers must stay quoted. `property_id` is a GA4 property ID,
17
+ not account ID `100000001`, stream ID `987654321`, or measurement ID
18
+ `G-EXAMPLE1`.
19
+
20
+ Use it with:
21
+
22
+ ```bash
23
+ analytics-ops doctor
24
+ analytics-ops audit
25
+ ```
26
+
27
+ The default path is `config/analytics_ops.yml`; the default profile is
28
+ `production`. Change them with `--config` and `--profile`.
29
+
30
+ ## Complete example
31
+
32
+ All identifiers below are fake:
33
+
34
+ ```yaml
35
+ version: 1
36
+
37
+ profiles:
38
+ production:
39
+ property_id: "${GA4_PROPERTY_ID}"
40
+
41
+ streams:
42
+ web:
43
+ stream_id: "${GA4_STREAM_ID}"
44
+ default_uri: "https://www.example.test"
45
+
46
+ # Accepted only as an explicit experimental declaration.
47
+ # Version 0.1.0 reports a finding and does not apply this setting.
48
+ enhanced_measurement:
49
+ enabled: true
50
+ experimental: true
51
+
52
+ retention:
53
+ event_data: 14_months
54
+ user_data: 14_months
55
+ reset_on_new_activity: false
56
+
57
+ key_events:
58
+ - calculation_completed
59
+
60
+ custom_dimensions:
61
+ - parameter_name: calculator_slug
62
+ display_name: Calculator slug
63
+ description: Published calculator identifier
64
+ scope: event
65
+
66
+ - parameter_name: customer_segment
67
+ display_name: Customer segment
68
+ description: Broad non-sensitive segment
69
+ scope: user
70
+ disallow_ads_personalization: true
71
+
72
+ custom_metrics:
73
+ - parameter_name: estimate_total
74
+ display_name: Estimate total
75
+ description: Non-sensitive calculated estimate
76
+ scope: event
77
+ measurement_unit: currency
78
+
79
+ # Checklist findings only; Analytics Ops does not claim to manage these.
80
+ manual_requirements:
81
+ - email_redaction_enabled
82
+ - consent_mode_reviewed
83
+
84
+ # Explicit declaration only in 0.1.0; never silently applied.
85
+ google_signals:
86
+ state: disabled
87
+ experimental: true
88
+ ```
89
+
90
+ ## Managed fields
91
+
92
+ ### Streams
93
+
94
+ `streams` is keyed by a local readable name. Each entry requires a numeric
95
+ `stream_id`. For a web stream, `default_uri` may be an absolute HTTP or
96
+ HTTPS URI without embedded credentials. Analytics Ops can update an existing
97
+ web stream URI; it does not automatically create or delete streams.
98
+
99
+ ### Retention
100
+
101
+ All three fields are required when `retention` is present:
102
+
103
+ - `event_data`: `2_months`, `14_months`, `26_months`, `38_months`, or
104
+ `50_months`
105
+ - `user_data`: the same values
106
+ - `reset_on_new_activity`: `true` or `false`
107
+
108
+ Google may restrict longer periods by property type or account capabilities.
109
+ An API rejection is returned as a typed error; Analytics Ops does not silently
110
+ substitute a different period.
111
+
112
+ ### Key events
113
+
114
+ `key_events` is a unique list of event names. Missing events are created with
115
+ `once_per_event` counting. Existing key events are not deleted or renamed.
116
+
117
+ ### Custom dimensions
118
+
119
+ Required fields are `parameter_name`, `display_name`, and `scope`.
120
+ Supported scopes are `event`, `user`, and `item`.
121
+ `disallow_ads_personalization` is valid only for user-scoped dimensions.
122
+
123
+ Identity is `scope + parameter_name`; display name and description are
124
+ mutable. Immutable conflicts are findings, not automatic replacement.
125
+
126
+ ### Custom metrics
127
+
128
+ Required fields are `parameter_name`, `display_name`, and
129
+ `scope: event`. Units are:
130
+
131
+ `standard`, `currency`, `feet`, `meters`, `kilometers`, `miles`,
132
+ `milliseconds`, `seconds`, `minutes`, and `hours`.
133
+
134
+ Identity is `parameter_name`. Scope and unit are immutable; Analytics Ops
135
+ will not archive and recreate a conflicting metric.
136
+
137
+ ## Environment variables
138
+
139
+ Only the literal form `${UPPER_CASE_NAME}` is expanded, and interpolation
140
+ happens after safe YAML parsing:
141
+
142
+ ```bash
143
+ export GA4_PROPERTY_ID=123456789
144
+ export GA4_STREAM_ID=987654321
145
+ analytics-ops doctor
146
+ ```
147
+
148
+ Missing or malformed variables fail validation. There is no ERB, command
149
+ execution, YAML alias support, or Ruby-object deserialization.
150
+
151
+ ## Strict safety rules
152
+
153
+ - The file is limited to 1 MiB.
154
+ - Unknown fields fail closed.
155
+ - Secret-shaped fields fail closed at any depth.
156
+ - Numeric identifiers must be YAML strings.
157
+ - User-visible strings reject control characters.
158
+ - Duplicate resource identities fail validation.
159
+ - Configuration loading never contacts Google.
160
+
161
+ The machine-readable contract is
162
+ [configuration-schema-v1.json](configuration-schema-v1.json). Print the same
163
+ schema from the installed gem with:
164
+
165
+ ```bash
166
+ analytics-ops schema --format json
167
+ ```
@@ -0,0 +1,59 @@
1
+ # Google client compatibility
2
+
3
+ Analytics Ops uses only Google's official modern Ruby clients. It adds no
4
+ second HTTP stack and does not expose generated Google objects publicly.
5
+
6
+ ## Supported versions
7
+
8
+ | Direct dependency | Constraint | Contract-tested release |
9
+ | --- | --- | --- |
10
+ | `google-analytics-admin` | `~> 0.8.0` | 0.8.0 |
11
+ | `google-analytics-data` | `~> 0.9.0` | 0.9.0 |
12
+
13
+ The reviewed lockfile resolves these generated transports:
14
+
15
+ | Versioned transport | Reviewed release |
16
+ | --- | --- |
17
+ | `google-analytics-admin-v1alpha` | 0.43.0 |
18
+ | `google-analytics-data-v1beta` | 0.22.0 |
19
+
20
+ The main Admin wrapper currently selects a V1alpha generated transport. That
21
+ transport name does not make every feature Alpha: Google publishes feature
22
+ maturity separately. Analytics Ops treats capabilities Google identifies as
23
+ Alpha as experimental or unsupported.
24
+
25
+ The pessimistic direct bounds allow reviewed patch updates inside each minor
26
+ line and reject a new minor contract automatically. `doctor` reports the
27
+ installed direct versions, expected bounds, and selected `grpc` or `rest`
28
+ transport.
29
+
30
+ ## Executable contract coverage
31
+
32
+ Tests coerce deterministic fake requests and responses through official
33
+ protobuf classes for:
34
+
35
+ - `list_account_summaries`
36
+ - `get_property`
37
+ - `list_data_streams`
38
+ - `get_data_retention_settings`
39
+ - `update_data_retention_settings`
40
+ - `list_key_events` and `create_key_event`
41
+ - list/create/update custom dimensions
42
+ - list/create/update custom metrics
43
+ - update web data-stream default URI
44
+ - `run_report` and `run_realtime_report`
45
+
46
+ The tests also prove enum mappings and normalization into immutable gem-owned
47
+ values without making network requests.
48
+
49
+ ## Updating a Google client
50
+
51
+ 1. Review the official release and Admin API changelog.
52
+ 2. Update one direct bound intentionally.
53
+ 3. Resolve the lockfile on every supported Ruby.
54
+ 4. Run focused Admin/Data contract specs.
55
+ 5. Run the full Ruby and Rails CI matrix.
56
+ 6. Update this file and the changelog.
57
+
58
+ Do not merge an automated client update based only on dependency resolution.
59
+ Generated method, field, enum, and transport changes require contract review.
@@ -0,0 +1,89 @@
1
+ # Saved plan format
2
+
3
+ `analytics-ops plan --output FILE` writes deterministic version-1 JSON. This
4
+ file is the only mutation input accepted by `apply`.
5
+
6
+ Example with fake identifiers:
7
+
8
+ ```json
9
+ {
10
+ "changes": [
11
+ {
12
+ "after": {
13
+ "description": "Published calculator identifier",
14
+ "disallow_ads_personalization": false,
15
+ "display_name": "Calculator slug",
16
+ "parameter_name": "calculator_slug",
17
+ "scope": "event"
18
+ },
19
+ "api_maturity": "beta",
20
+ "before": null,
21
+ "operation": "create",
22
+ "resource_identity": "event:calculator_slug",
23
+ "resource_type": "custom_dimension",
24
+ "reversible": true,
25
+ "rollback": "Archive the newly created custom dimension"
26
+ }
27
+ ],
28
+ "findings": [],
29
+ "format_version": 1,
30
+ "profile": "production",
31
+ "property_id": "123456789",
32
+ "snapshot_fingerprint": "sha256:0000000000000000000000000000000000000000000000000000000000000000"
33
+ }
34
+ ```
35
+
36
+ Plans contain no timestamp. The same desired state and normalized snapshot
37
+ produce the same bytes.
38
+
39
+ ## Top-level fields
40
+
41
+ | Field | Meaning |
42
+ | --- | --- |
43
+ | `format_version` | Exact plan contract version; currently 1 |
44
+ | `profile` | Configuration profile that produced the plan |
45
+ | `property_id` | Numeric GA4 property ID encoded as a string |
46
+ | `snapshot_fingerprint` | SHA-256 of relevant normalized remote state |
47
+ | `changes` | Sorted supported create/update operations |
48
+ | `findings` | Sorted drift, manual, warning, or experimental observations |
49
+
50
+ Every change records resource type, stable identity, operation, API maturity,
51
+ before/after payloads, reversibility, and manual rollback guidance.
52
+
53
+ ## Supported version-1 changes
54
+
55
+ | Resource | Create | Update | Delete/archive |
56
+ | --- | :---: | :---: | :---: |
57
+ | Existing web data-stream default URI | No | Yes | No |
58
+ | Data retention | No | Yes | No |
59
+ | Key event | Yes | No | No |
60
+ | Custom dimension | Yes | Mutable metadata only | No |
61
+ | Custom metric | Yes | Mutable metadata only | No |
62
+
63
+ Resource names in update payloads must belong to the plan's property. Immutable
64
+ identity fields cannot change between `before` and `after`.
65
+
66
+ ## Findings are not operations
67
+
68
+ A finding may identify inaccessible resources, immutable conflicts, manual UI
69
+ checks, or explicitly declared experimental capabilities. Apply ignores
70
+ findings; it never converts them into mutations.
71
+
72
+ ## Storage and review
73
+
74
+ Plan writes are atomic and mode 0600. Treat the file as operationally
75
+ sensitive even though credentials and report rows are forbidden:
76
+
77
+ ```bash
78
+ analytics-ops plan --output tmp/ga4-plan.json
79
+ less tmp/ga4-plan.json
80
+ analytics-ops apply tmp/ga4-plan.json
81
+ ```
82
+
83
+ Do not hand-edit a fingerprint or payload. Generate a new plan after any remote
84
+ or desired-state change. Partial apply also requires reconciliation and a new
85
+ plan.
86
+
87
+ The complete machine-readable contract is
88
+ [plan-schema-v1.json](plan-schema-v1.json). Runtime validation additionally
89
+ enforces cross-field identity, immutable-field, and cross-property rules.