dataspring-cli 0.3.0__py3-none-any.whl

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.
cli/contract.py ADDED
@@ -0,0 +1,1138 @@
1
+ """The dispatch contract: every ``params_model`` and action union, and nothing else.
2
+
3
+ This is the ``dataspring_contract`` of decision D19
4
+ (``docs/2026-09-17-one-registry-three-surfaces.md``): the pydantic models
5
+ that describe what each registry operation takes. ``services/dispatch.py``
6
+ imports them and pairs each with its handler; ``GET /api/dispatch``
7
+ publishes their JSON schemas; the MCP tools are generated from them; and
8
+ ``cli/generated.py`` is generated from them, so the CLI validates a body
9
+ against the same class the server will.
10
+
11
+ It lives in ``cli/`` for the reason ``cli/skilltree.py`` does: the
12
+ ``dataspring-cli`` wheel ships exactly this package, and the server imports
13
+ from it, so both ends read one definition. It imports pydantic and the
14
+ standard library only. ``tests/test_cli_thin_client.py`` and
15
+ ``tests/test_cli_wheel_imports.py`` keep it that way.
16
+
17
+ ``extra="forbid"`` on the base is the contract every surface relies on: a
18
+ field the server does not know is refused at the boundary instead of
19
+ silently dropped. Field descriptions are the parameter docs every surface
20
+ shows, verbatim; a model's docstring is its description.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Annotated, Any, Literal
26
+
27
+ from pydantic import BaseModel, ConfigDict, Field
28
+
29
+
30
+ class DispatchParams(BaseModel):
31
+ """Base of every model on the dispatch boundary: each registry entry's
32
+ ``params_model`` and every member of the ``<domain>_edit`` action unions
33
+ (decision D19).
34
+
35
+ ``extra="forbid"`` is the contract every surface relies on: a field the
36
+ server does not know is refused at the boundary (HTTP 400, an MCP
37
+ validation error) instead of silently dropped, so a client can never
38
+ believe it sent something the handler never saw.
39
+ """
40
+
41
+ model_config = ConfigDict(extra="forbid")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Dashboard action types
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class CreateDashboardAction(DispatchParams):
50
+ """Create a new dashboard."""
51
+
52
+ action: Literal["create"]
53
+ title: str = Field(description="Dashboard title")
54
+ description: str | None = Field(default=None, description="Optional description")
55
+ visibility: Literal["private", "org"] = Field(
56
+ default="private",
57
+ description='"private" (only you) or "org" (shared with organization)',
58
+ )
59
+
60
+
61
+ class UpdateDashboardAction(DispatchParams):
62
+ """Update an existing dashboard's metadata (title/description/visibility)."""
63
+
64
+ action: Literal["update"]
65
+ id: str = Field(description="Dashboard ID")
66
+ title: str | None = None
67
+ description: str | None = None
68
+ visibility: Literal["private", "org"] | None = None
69
+ expected_version: int | None = Field(
70
+ default=None,
71
+ description="Optimistic lock — if provided, update fails on mismatch. "
72
+ "Use the version from get_dashboard to prevent overwriting concurrent changes.",
73
+ )
74
+
75
+
76
+ class DeleteDashboardAction(DispatchParams):
77
+ """Delete a dashboard. Cannot be undone."""
78
+
79
+ action: Literal["delete"]
80
+ id: str = Field(description="Dashboard ID")
81
+
82
+
83
+ class DuplicateDashboardAction(DispatchParams):
84
+ """Duplicate a dashboard with all pages, sections, and widgets (new IDs)."""
85
+
86
+ action: Literal["duplicate"]
87
+ id: str = Field(description="Source dashboard ID")
88
+ new_title: str | None = Field(
89
+ default=None, description='Title for the copy (default: "Copy of {original}")'
90
+ )
91
+
92
+
93
+ class ShareDashboardAction(DispatchParams):
94
+ """Toggle dashboard visibility between private and shared (org)."""
95
+
96
+ action: Literal["share"]
97
+ id: str = Field(description="Dashboard ID")
98
+ visibility: Literal["private", "org"]
99
+
100
+
101
+ class SetDashboardControlsAction(DispatchParams):
102
+ """Update a dashboard's date-range, grain, and comparison controls."""
103
+
104
+ action: Literal["set_controls"]
105
+ id: str = Field(description="Dashboard ID")
106
+ date_range: dict | None = Field(
107
+ default=None,
108
+ description=(
109
+ 'Date range config. Relative: {"mode": "relative", "preset": "last_30_days"}. '
110
+ 'Absolute: {"mode": "absolute", "start_date": "YYYY-MM-DD", "end_date": "YYYY-MM-DD"}.'
111
+ ),
112
+ )
113
+ grain: Literal["day", "week", "month", "quarter", "year"] | None = None
114
+ comparison: Literal["none", "previous_period", "same_period_last_year"] | None = None
115
+
116
+
117
+ DashboardAction = Annotated[
118
+ CreateDashboardAction
119
+ | UpdateDashboardAction
120
+ | DeleteDashboardAction
121
+ | DuplicateDashboardAction
122
+ | ShareDashboardAction
123
+ | SetDashboardControlsAction,
124
+ Field(discriminator="action"),
125
+ ]
126
+
127
+
128
+ class DashboardEditParams(DispatchParams):
129
+ """The ``dashboard_edit`` call: one ``action`` from the family's union."""
130
+
131
+ action: DashboardAction
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Widget action types
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class AddWidgetAction(DispatchParams):
140
+ """Add a widget to a dashboard section."""
141
+
142
+ action: Literal["add"]
143
+ dashboard_id: str
144
+ widget: dict = Field(
145
+ description=(
146
+ "Widget definition. Required: type (kpi|area_chart|bar_chart|line_chart|"
147
+ "table|donut|heatmap), title, query. Optional: format, width (1-10), "
148
+ "time_scope (range|latest|latest_complete), pivot (for table widgets)."
149
+ )
150
+ )
151
+ page_index: int = 0
152
+ section_index: int = 0
153
+ expected_version: int | None = None
154
+
155
+
156
+ class UpdateWidgetAction(DispatchParams):
157
+ """Update a widget's fields (title, type, query, format, width, time_scope, pivot)."""
158
+
159
+ action: Literal["update"]
160
+ dashboard_id: str
161
+ page_index: int
162
+ section_index: int
163
+ widget_index: int
164
+ updates: dict = Field(description="Fields to patch — only provided keys are changed")
165
+ expected_version: int | None = None
166
+
167
+
168
+ class RemoveWidgetAction(DispatchParams):
169
+ """Remove a widget from a dashboard section (irreversible)."""
170
+
171
+ action: Literal["remove"]
172
+ dashboard_id: str
173
+ page_index: int
174
+ section_index: int
175
+ widget_index: int
176
+ expected_version: int | None = None
177
+
178
+
179
+ class ReorderWidgetsAction(DispatchParams):
180
+ """Reorder widgets within a section. All widget IDs in the section must be listed."""
181
+
182
+ action: Literal["reorder"]
183
+ dashboard_id: str
184
+ page_index: int
185
+ section_index: int
186
+ widget_ids: list[str] = Field(description="Full list of widget IDs in desired order")
187
+ expected_version: int | None = None
188
+
189
+
190
+ class MoveWidgetAction(DispatchParams):
191
+ """Move a widget to a different page/section (keeps the same ID)."""
192
+
193
+ action: Literal["move"]
194
+ dashboard_id: str
195
+ widget_id: str
196
+ target_page_id: str
197
+ target_section_index: int
198
+ position: int | None = Field(
199
+ default=None, description="Position within target section (None = append)"
200
+ )
201
+
202
+
203
+ class CopyWidgetAction(DispatchParams):
204
+ """Copy a widget with a new ID."""
205
+
206
+ action: Literal["copy"]
207
+ dashboard_id: str
208
+ widget_id: str
209
+ target_page_id: str
210
+ target_section_index: int
211
+ new_title: str | None = None
212
+
213
+
214
+ class SwapWidgetsAction(DispatchParams):
215
+ """Swap the positions of two widgets (may be in different sections)."""
216
+
217
+ action: Literal["swap"]
218
+ dashboard_id: str
219
+ widget_id_1: str
220
+ widget_id_2: str
221
+
222
+
223
+ WidgetAction = Annotated[
224
+ AddWidgetAction
225
+ | UpdateWidgetAction
226
+ | RemoveWidgetAction
227
+ | ReorderWidgetsAction
228
+ | MoveWidgetAction
229
+ | CopyWidgetAction
230
+ | SwapWidgetsAction,
231
+ Field(discriminator="action"),
232
+ ]
233
+
234
+
235
+ class WidgetEditParams(DispatchParams):
236
+ """The ``widget_edit`` call: one ``action`` from the family's union."""
237
+
238
+ action: WidgetAction
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Page action types
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ class CreatePageAction(DispatchParams):
247
+ action: Literal["create"]
248
+ dashboard_id: str
249
+ title: str
250
+ position: int | None = Field(default=None, description="Insert position (None = append)")
251
+
252
+
253
+ class DeletePageAction(DispatchParams):
254
+ action: Literal["delete"]
255
+ dashboard_id: str
256
+ page_id: str
257
+ cascade: bool = Field(default=False, description="Delete even if page has widgets")
258
+
259
+
260
+ class RenamePageAction(DispatchParams):
261
+ action: Literal["rename"]
262
+ dashboard_id: str
263
+ page_id: str
264
+ title: str
265
+
266
+
267
+ class ReorderPagesAction(DispatchParams):
268
+ action: Literal["reorder"]
269
+ dashboard_id: str
270
+ page_ids: list[str] = Field(description="Full list of page IDs in desired order")
271
+
272
+
273
+ PageAction = Annotated[
274
+ CreatePageAction | DeletePageAction | RenamePageAction | ReorderPagesAction,
275
+ Field(discriminator="action"),
276
+ ]
277
+
278
+
279
+ class PageEditParams(DispatchParams):
280
+ """The ``page_edit`` call: one ``action`` from the family's union."""
281
+
282
+ action: PageAction
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Section action types
287
+ # ---------------------------------------------------------------------------
288
+
289
+
290
+ class CreateSectionAction(DispatchParams):
291
+ action: Literal["create"]
292
+ dashboard_id: str
293
+ page_id: str
294
+ title: str | None = None
295
+ position: int | None = None
296
+
297
+
298
+ class DeleteSectionAction(DispatchParams):
299
+ action: Literal["delete"]
300
+ dashboard_id: str
301
+ page_id: str
302
+ section_index: int
303
+ cascade: bool = False
304
+
305
+
306
+ class RenameSectionAction(DispatchParams):
307
+ action: Literal["rename"]
308
+ dashboard_id: str
309
+ page_id: str
310
+ section_index: int
311
+ title: str | None = Field(description="New title (None to clear)")
312
+
313
+
314
+ class MoveSectionAction(DispatchParams):
315
+ action: Literal["move"]
316
+ dashboard_id: str
317
+ source_page_id: str
318
+ section_index: int
319
+ target_page_id: str
320
+ target_position: int | None = None
321
+
322
+
323
+ SectionAction = Annotated[
324
+ CreateSectionAction | DeleteSectionAction | RenameSectionAction | MoveSectionAction,
325
+ Field(discriminator="action"),
326
+ ]
327
+
328
+
329
+ class SectionEditParams(DispatchParams):
330
+ """The ``section_edit`` call: one ``action`` from the family's union."""
331
+
332
+ action: SectionAction
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Semantic-model action types (admin/owner only)
337
+ # ---------------------------------------------------------------------------
338
+
339
+
340
+ _EXPECT_VERSION_DOC = (
341
+ "Managed tenants only: the datacore version (draft snapshot id) this edit "
342
+ "was written against, from the previous edit's `version` or "
343
+ "dataspring://datacore/files; refused with the current one when stale"
344
+ )
345
+
346
+
347
+ class CreateSemanticModelAction(DispatchParams):
348
+ action: Literal["create"]
349
+ model_data: dict = Field(
350
+ description="Full semantic-model definition (name, measures, dimensions, entities)"
351
+ )
352
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
353
+
354
+
355
+ class UpdateSemanticModelAction(DispatchParams):
356
+ action: Literal["update"]
357
+ name: str
358
+ updates: dict
359
+ confirmed: bool = Field(
360
+ default=False,
361
+ description=(
362
+ "set true only after the user confirmed a redefinition of a "
363
+ "NATIVE entity whose impact you showed them; has no effect on "
364
+ "imported entities"
365
+ ),
366
+ )
367
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
368
+
369
+
370
+ class DeleteSemanticModelAction(DispatchParams):
371
+ action: Literal["delete"]
372
+ name: str
373
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
374
+
375
+
376
+ class PreviewSemanticModelAction(DispatchParams):
377
+ """Validate a semantic model without writing it or touching the warehouse."""
378
+
379
+ action: Literal["preview"]
380
+ model_data: dict = Field(
381
+ description="Full semantic-model definition to validate (name, "
382
+ "measures, dimensions, entities)"
383
+ )
384
+
385
+
386
+ SemanticModelAction = Annotated[
387
+ CreateSemanticModelAction
388
+ | UpdateSemanticModelAction
389
+ | DeleteSemanticModelAction
390
+ | PreviewSemanticModelAction,
391
+ Field(discriminator="action"),
392
+ ]
393
+
394
+
395
+ class SemanticModelEditParams(DispatchParams):
396
+ """The ``semantic_model_edit`` call: one ``action`` from the family's union."""
397
+
398
+ action: SemanticModelAction
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # Metric action types (admin/owner only for writes, preview is open)
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ class CreateMetricAction(DispatchParams):
407
+ action: Literal["create"]
408
+ metric_data: dict = Field(description="Metric definition (name, type, type_params)")
409
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
410
+
411
+
412
+ class UpdateMetricAction(DispatchParams):
413
+ action: Literal["update"]
414
+ name: str
415
+ updates: dict
416
+ confirmed: bool = Field(
417
+ default=False,
418
+ description=(
419
+ "set true only after the user confirmed a redefinition of a "
420
+ "NATIVE entity whose impact you showed them; has no effect on "
421
+ "imported entities"
422
+ ),
423
+ )
424
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
425
+
426
+
427
+ class DeleteMetricAction(DispatchParams):
428
+ action: Literal["delete"]
429
+ name: str
430
+ expect_version: str | None = Field(default=None, description=_EXPECT_VERSION_DOC)
431
+
432
+
433
+ class PreviewMetricAction(DispatchParams):
434
+ action: Literal["preview"]
435
+ metric_data: dict = Field(description="Metric definition to preview")
436
+ sample_query: dict | None = Field(
437
+ default=None,
438
+ description="Optional query params (start_date, end_date, grain, limit)",
439
+ )
440
+
441
+
442
+ class ImpactMetricAction(DispatchParams):
443
+ """What applying ``updates`` to this metric would touch, without writing."""
444
+
445
+ action: Literal["impact"]
446
+ name: str
447
+ updates: dict = Field(description="The updates whose impact to assess")
448
+
449
+
450
+ MetricAction = Annotated[
451
+ CreateMetricAction
452
+ | UpdateMetricAction
453
+ | DeleteMetricAction
454
+ | PreviewMetricAction
455
+ | ImpactMetricAction,
456
+ Field(discriminator="action"),
457
+ ]
458
+
459
+
460
+ class MetricEditParams(DispatchParams):
461
+ """The ``metric_edit`` call: one ``action`` from the family's union."""
462
+
463
+ action: MetricAction
464
+
465
+
466
+ # ---------------------------------------------------------------------------
467
+ # Quick-metric action types
468
+ # ---------------------------------------------------------------------------
469
+
470
+
471
+ class CreateQuickMetricAction(DispatchParams):
472
+ action: Literal["create"]
473
+ name: str = Field(description='Metric name (e.g., "revenue_per_order")')
474
+ expression: str = Field(
475
+ description='Arithmetic expression over existing metrics (e.g., "total_revenue / order_count")'
476
+ )
477
+ description: str | None = None
478
+
479
+
480
+ class UpdateQuickMetricAction(DispatchParams):
481
+ action: Literal["update"]
482
+ metric_id: str
483
+ name: str | None = None
484
+ expression: str | None = None
485
+ description: str | None = None
486
+
487
+
488
+ class DeleteQuickMetricAction(DispatchParams):
489
+ action: Literal["delete"]
490
+ metric_id: str
491
+
492
+
493
+ QuickMetricAction = Annotated[
494
+ CreateQuickMetricAction | UpdateQuickMetricAction | DeleteQuickMetricAction,
495
+ Field(discriminator="action"),
496
+ ]
497
+
498
+
499
+ class QuickMetricEditParams(DispatchParams):
500
+ """The ``quick_metric_edit`` call: one ``action`` from the family's union."""
501
+
502
+ action: QuickMetricAction
503
+
504
+
505
+ # ---------------------------------------------------------------------------
506
+ # Scheduled-report action types
507
+ # ---------------------------------------------------------------------------
508
+
509
+
510
+ _Frequency = Literal["daily", "weekly", "monthly"]
511
+
512
+
513
+ class CreateReportAction(DispatchParams):
514
+ action: Literal["create"]
515
+ name: str
516
+ frequency_type: _Frequency
517
+ time: str = Field(description="HH:MM in UTC, 24-hour")
518
+ recipients: list[str]
519
+ dashboard_id: str | None = Field(
520
+ default=None, description="For dashboard reports (PDF/PNG)"
521
+ )
522
+ metrics: list[str] | None = Field(
523
+ default=None, description="For query reports (CSV/JSON)"
524
+ )
525
+ dimensions: list[str] | None = None
526
+ format: str = Field(
527
+ default="pdf",
528
+ description='"pdf"|"png" for dashboards, "csv"|"json" for queries',
529
+ )
530
+ day_of_week: int | None = Field(default=None, description="0=Mon … 6=Sun (weekly)")
531
+ day_of_month: int | None = Field(default=None, description="1-28 (monthly)")
532
+
533
+
534
+ class UpdateReportAction(DispatchParams):
535
+ action: Literal["update"]
536
+ schedule_id: str
537
+ name: str | None = None
538
+ enabled: bool | None = Field(
539
+ default=None, description="Set False to pause without deleting"
540
+ )
541
+ frequency_type: _Frequency | None = None
542
+ time: str | None = None
543
+ day_of_week: int | None = None
544
+ day_of_month: int | None = None
545
+ recipients: list[str] | None = None
546
+
547
+
548
+ class DeleteReportAction(DispatchParams):
549
+ action: Literal["delete"]
550
+ schedule_id: str
551
+
552
+
553
+ ReportAction = Annotated[
554
+ CreateReportAction | UpdateReportAction | DeleteReportAction,
555
+ Field(discriminator="action"),
556
+ ]
557
+
558
+
559
+ class ReportEditParams(DispatchParams):
560
+ """The ``report_edit`` call: one ``action`` from the family's union."""
561
+
562
+ action: ReportAction
563
+
564
+
565
+ # ---------------------------------------------------------------------------
566
+ # business_context action types
567
+ # ---------------------------------------------------------------------------
568
+
569
+
570
+ class GetBusinessContextAction(DispatchParams):
571
+ """Read the current org business_context document."""
572
+
573
+ action: Literal["get"]
574
+
575
+
576
+ class ShowSizeBusinessContextAction(DispatchParams):
577
+ """Report current size and remaining room within the 2000-byte UTF-8 cap."""
578
+
579
+ action: Literal["show_size"]
580
+
581
+
582
+ class SetBusinessContextAction(DispatchParams):
583
+ """Replace the org's business_context with ``content`` (admin/owner)."""
584
+
585
+ action: Literal["set"]
586
+ content: str = Field(description="Full document content to save")
587
+
588
+
589
+ class AppendBusinessContextAction(DispatchParams):
590
+ """Append ``content`` to the existing business_context (admin/owner).
591
+
592
+ Reads the current value, concatenates, and writes the combined text.
593
+ Useful for incremental edits where the agent doesn't already have
594
+ the prior content in scope.
595
+ """
596
+
597
+ action: Literal["append"]
598
+ content: str = Field(description="Text to append to the current document")
599
+
600
+
601
+ BusinessContextAction = Annotated[
602
+ GetBusinessContextAction
603
+ | ShowSizeBusinessContextAction
604
+ | SetBusinessContextAction
605
+ | AppendBusinessContextAction,
606
+ Field(discriminator="action"),
607
+ ]
608
+
609
+
610
+ class BusinessContextEditParams(DispatchParams):
611
+ """The ``business_context_edit`` call: one ``action`` from the family's union."""
612
+
613
+ action: BusinessContextAction
614
+
615
+
616
+ # ---------------------------------------------------------------------------
617
+ # Learned-trail action types
618
+ # ---------------------------------------------------------------------------
619
+
620
+
621
+ class ListLearnedAction(DispatchParams):
622
+ """List what DataSpring learned for this org (plus your personal entries)."""
623
+
624
+ action: Literal["list"]
625
+ limit: int = 20
626
+ include_reverted: bool = False
627
+
628
+
629
+ class UndoLearnedAction(DispatchParams):
630
+ """Revert one learning (or, with no id, your most recent reversible one)."""
631
+
632
+ action: Literal["undo"]
633
+ learning_id: str | None = Field(
634
+ default=None,
635
+ description="Learning to revert; omit for your most recent reversible entry",
636
+ )
637
+
638
+
639
+ LearnedAction = Annotated[
640
+ ListLearnedAction | UndoLearnedAction,
641
+ Field(discriminator="action"),
642
+ ]
643
+
644
+
645
+ class LearnedEditParams(DispatchParams):
646
+ """The ``learned_edit`` call: one ``action`` from the family's union."""
647
+
648
+ action: LearnedAction
649
+
650
+
651
+ # ---------------------------------------------------------------------------
652
+ # Verified-query action types
653
+ # ---------------------------------------------------------------------------
654
+
655
+
656
+ class ListVerifiedQueriesAction(DispatchParams):
657
+ """The org's verified queries plus your own personal ones."""
658
+
659
+ action: Literal["list"]
660
+ limit: int = 20
661
+
662
+
663
+ class RecordVerifiedQueryAction(DispatchParams):
664
+ """Record one confirmed (question, query) pair."""
665
+
666
+ action: Literal["record"]
667
+ question: str = Field(description="The user's question, verbatim")
668
+ params: dict = Field(
669
+ description=(
670
+ "The query that answers it, in the query_metrics wire shape: "
671
+ "metrics, dimensions, grain, start_date, end_date, where, "
672
+ "order_by, limit"
673
+ )
674
+ )
675
+ note: str | None = Field(
676
+ default=None,
677
+ description="One line on WHY this is the right query for that question",
678
+ )
679
+ scope: Literal["org", "user"] = Field(
680
+ default="user",
681
+ description=(
682
+ "'user' (the default) records it in this user's personal set; "
683
+ "'org' curates it for everyone and requires admin/owner"
684
+ ),
685
+ )
686
+
687
+
688
+ class DeleteVerifiedQueryAction(DispatchParams):
689
+ """Remove one verified query by id."""
690
+
691
+ action: Literal["delete"]
692
+ id: str
693
+
694
+
695
+ VerifiedQueryAction = Annotated[
696
+ ListVerifiedQueriesAction | RecordVerifiedQueryAction | DeleteVerifiedQueryAction,
697
+ Field(discriminator="action"),
698
+ ]
699
+
700
+
701
+ class VerifiedQueryEditParams(DispatchParams):
702
+ """The ``verified_query_edit`` call: one ``action`` from the family's union."""
703
+
704
+ action: VerifiedQueryAction
705
+
706
+
707
+ # ---------------------------------------------------------------------------
708
+ # Warehouse action types (docs/2026-09-17-datacore-chunk2-plan.md)
709
+ # ---------------------------------------------------------------------------
710
+
711
+
712
+ class ActivateWarehouseAction(DispatchParams):
713
+ """Make one warehouse the org's default: every query that names no
714
+ ``warehouse`` runs against it."""
715
+
716
+ action: Literal["activate"]
717
+ id: str = Field(description="Warehouse id, e.g. 'managed' or 'external'")
718
+
719
+
720
+ class AddExternalWarehouseAction(DispatchParams):
721
+ """Register a customer-owned BigQuery project as an external warehouse.
722
+
723
+ The customer's key is NOT an argument: it lives in Secret Manager in the
724
+ data project and is only named here (``key_secret``)."""
725
+
726
+ action: Literal["add_external"]
727
+ label: str = Field(description="Display name, e.g. \"Noon's BigQuery\"")
728
+ project: str = Field(description="The customer's GCP project id")
729
+ dataset: str = Field(description="The dataset the semantic manifest points at")
730
+ location: str = Field(description="BigQuery location of that dataset, e.g. 'europe-north2'")
731
+ key_secret: str = Field(
732
+ description=(
733
+ "NAME of the Secret Manager secret holding the customer's "
734
+ "service-account key (t-<org>-external-bigquery-key). Never the key itself."
735
+ )
736
+ )
737
+ id: str = Field(default="external", description="Warehouse id to create (default 'external')")
738
+
739
+
740
+ class RemoveWarehouseAction(DispatchParams):
741
+ """Delete a warehouse and its manifest. Refused for the active warehouse
742
+ and for the managed one."""
743
+
744
+ action: Literal["remove"]
745
+ id: str = Field(description="Warehouse id to remove")
746
+
747
+
748
+ WarehouseAction = Annotated[
749
+ ActivateWarehouseAction | AddExternalWarehouseAction | RemoveWarehouseAction,
750
+ Field(discriminator="action"),
751
+ ]
752
+
753
+
754
+ class WarehouseEditParams(DispatchParams):
755
+ """The ``warehouse_edit`` call: one ``action`` from the family's union."""
756
+
757
+ action: WarehouseAction
758
+
759
+
760
+ # ---------------------------------------------------------------------------
761
+ # Datacore action types (chunk 3): the workspace files, and the loop
762
+ # ---------------------------------------------------------------------------
763
+
764
+ _DATACORE_VERSION_DOC = (
765
+ "The datacore version this edit was written against: the `version` the "
766
+ "previous edit returned, or dataspring://datacore/files. An edit against "
767
+ "a stale version is refused with the current one. Omit to skip the check."
768
+ )
769
+
770
+
771
+ _DATACORE_NAME_DOC = (
772
+ "For kind connection or pipeline: the name, i.e. the file name without .yaml "
773
+ "(connections/<name>.yaml, pipelines/<name>.yaml). For kind model: the path "
774
+ "under models/, the dbt project (models/models/marts/orders.sql, "
775
+ "models/models/staging/schema.yml, models/seeds/x.csv, models/dbt_project.yml); "
776
+ "no traversal, .sql .yml .yaml .csv .md .txt .json, never target/ or dbt_packages/"
777
+ )
778
+
779
+
780
+ DatacoreKind = Literal["connection", "pipeline", "model"]
781
+
782
+
783
+ class ApplyDatacoreFileAction(DispatchParams):
784
+ """Write one workspace file whole (create or replace): a connection or
785
+ pipeline as a ``document`` (validated against its schema and the
786
+ catalog), a dbt project file as ``content``."""
787
+
788
+ action: Literal["apply"]
789
+ kind: DatacoreKind = Field(description="connection (admin), pipeline (admin) or model (member)")
790
+ name: str = Field(description=_DATACORE_NAME_DOC)
791
+ document: dict | None = Field(
792
+ default=None,
793
+ description=(
794
+ "kind connection: the whole document (connection.schema.json: kind, base_url, "
795
+ "auth with secret NAMES). kind pipeline: the whole document (pipeline.schema.json: "
796
+ "a catalog `connector` with config, secrets, overrides; or a `connection` reference, "
797
+ "an inline `source` or named `sources`, with `resources`; schedule, checks, then). "
798
+ "Secrets are named, never valued; every name must exist"
799
+ ),
800
+ )
801
+ content: str | None = Field(default=None, description="kind model: the whole file content")
802
+ expect_version: str | None = Field(default=None, description=_DATACORE_VERSION_DOC)
803
+
804
+
805
+ class PatchDatacoreFileAction(DispatchParams):
806
+ """Apply a JSON merge patch (RFC 7386: a key set to null is removed) to an
807
+ existing YAML file: a connection, a pipeline (a schedule, a window, a
808
+ check), or a YAML file under models/ (not SQL)."""
809
+
810
+ action: Literal["patch"]
811
+ kind: DatacoreKind = Field(description="connection (admin), pipeline (admin) or model (member)")
812
+ name: str = Field(description=_DATACORE_NAME_DOC)
813
+ patch: dict = Field(description="JSON merge patch over the current document")
814
+ expect_version: str | None = Field(default=None, description=_DATACORE_VERSION_DOC)
815
+
816
+
817
+ class DeleteDatacoreFileAction(DispatchParams):
818
+ """Remove one workspace file."""
819
+
820
+ action: Literal["delete"]
821
+ kind: DatacoreKind = Field(description="connection (admin), pipeline (admin) or model (member)")
822
+ name: str = Field(description=_DATACORE_NAME_DOC)
823
+ expect_version: str | None = Field(default=None, description=_DATACORE_VERSION_DOC)
824
+
825
+
826
+ DatacoreAction = Annotated[
827
+ ApplyDatacoreFileAction | PatchDatacoreFileAction | DeleteDatacoreFileAction,
828
+ Field(discriminator="action"),
829
+ ]
830
+
831
+
832
+ class DatacoreEditParams(DispatchParams):
833
+ """The ``datacore_edit`` call: one ``action`` from the family's union."""
834
+
835
+ action: DatacoreAction
836
+
837
+
838
+ class CheckDatacoreAction(DispatchParams):
839
+ """Check the current draft; no arguments beyond an optional note."""
840
+
841
+ action: Literal["check"]
842
+ note: str | None = Field(default=None, description="Optional note, recorded on the check run")
843
+
844
+
845
+ class DeployDatacoreAction(DispatchParams):
846
+ action: Literal["deploy"]
847
+ snapshot: str | None = Field(
848
+ default=None,
849
+ description=(
850
+ "Omit to promote the current draft (needs a green check bound to it). "
851
+ "A previously deployed snapshot id rolls back to it"
852
+ ),
853
+ )
854
+ note: str | None = Field(default=None, description="Why, recorded on the learned trail")
855
+
856
+
857
+ class RunWindowParams(DispatchParams):
858
+ start: str = Field(description="First day, YYYY-MM-DD, inclusive")
859
+ end: str = Field(description="Last day, YYYY-MM-DD, inclusive")
860
+
861
+
862
+ class RunCursorParams(DispatchParams):
863
+ start: Any = Field(
864
+ alias="from",
865
+ description="For a cursor-driven pipeline (Uniconta): re-pull from this cursor value",
866
+ )
867
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
868
+
869
+
870
+ class RunPipelineAction(DispatchParams):
871
+ action: Literal["run"]
872
+ pipeline: str = Field(description="Pipeline name (a deployed pipelines/<name>.yaml)")
873
+ window: RunWindowParams | None = Field(
874
+ default=None,
875
+ description="A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill",
876
+ )
877
+ cursor: RunCursorParams | None = Field(default=None, description="{from: <value>}: a cursor to re-pull from")
878
+ backfill_chunk: str | None = Field(
879
+ default=None,
880
+ description="Chunk size for a windowed run: 7d, 2w or 1M (calendar months, the default)",
881
+ )
882
+
883
+
884
+ class ResetPipelineAction(DispatchParams):
885
+ action: Literal["reset"]
886
+ pipeline: str = Field(description="The pipeline whose dlt state and raw tables to remove")
887
+ approve: bool = Field(
888
+ default=False,
889
+ description="Must be true: the reset is irreversible. Ask the user first",
890
+ )
891
+
892
+
893
+ DatacoreRunAction = Annotated[
894
+ CheckDatacoreAction | DeployDatacoreAction | RunPipelineAction | ResetPipelineAction,
895
+ Field(discriminator="action"),
896
+ ]
897
+
898
+
899
+ class DatacoreRunParams(DispatchParams):
900
+ """The ``datacore_run`` call: one ``action`` from the family's union."""
901
+
902
+ action: DatacoreRunAction
903
+
904
+
905
+ class MCPQueryParams(DispatchParams):
906
+ """One metric query as every surface sends it: string dates, MetricFlow
907
+ ``where`` constraints, an optional warehouse. ``query_metrics`` and
908
+ ``explain_query`` both take it; ``services.queries.QueryParams`` is the
909
+ parsed form the query service runs.
910
+ """
911
+
912
+ metrics: list[str] = Field(description="List of metric names to query (e.g., ['total_revenue', 'order_count'])")
913
+ dimensions: list[str] | None = Field(default=None, description="Dimensions to group by. Use qualified names from dataspring://dimensions (e.g., ['customer__segment', 'order__region'])")
914
+ grain: str | None = Field(default=None, description="Time granularity: 'day', 'week', 'month', 'quarter', or 'year'")
915
+ start_date: str | None = Field(default=None, description="Start date in YYYY-MM-DD format")
916
+ end_date: str | None = Field(default=None, description="End date in YYYY-MM-DD format")
917
+ limit: int | None = Field(default=None, description="Maximum number of rows to return")
918
+ order_by: str | None = Field(default=None, description="Column to sort by, append ' desc' for descending (e.g., 'total_revenue desc')")
919
+ where: list[str] | None = Field(
920
+ default=None,
921
+ description=(
922
+ "Row filters in MetricFlow's constraint syntax, one string each, "
923
+ "e.g. \"{{ Dimension('customer__segment') }} NOT IN ('Direct')\". "
924
+ "Use qualified names from dataspring://dimensions - an unresolvable "
925
+ "name comes back as an error whose suggestion names the qualified "
926
+ "form. `where` decides WHICH ROWS the numbers cover; `dimensions` "
927
+ "breaks the numbers OUT by a column; send both for a breakdown of "
928
+ "a filtered slice. A `where` on a dimension also overrides the "
929
+ "user's standing filter on that same dimension for this call."
930
+ ),
931
+ )
932
+ warehouse: str | None = Field(
933
+ default=None,
934
+ description=(
935
+ "Which of the org's warehouses to run against - an id from "
936
+ "dataspring://warehouses ('managed', 'external', ...). Omit for the "
937
+ "active one. During a migration ask both and compare."
938
+ ),
939
+ )
940
+
941
+
942
+ class QueryMetricsParams(MCPQueryParams):
943
+ """``query_metrics``: the query, plus whether to suggest a chart for it
944
+ and the shape of the answer (rows, or a CSV document)."""
945
+
946
+ suggest_visualization: bool = Field(
947
+ default=False,
948
+ description="Also return a suggested visualization type for the result.",
949
+ )
950
+ format: Literal["json", "csv"] = Field(
951
+ default="json",
952
+ description=(
953
+ "'json' (default) answers rows in `data`; 'csv' answers the same "
954
+ "result as one CSV document in `content` (header row first) for a "
955
+ "file or a spreadsheet, with `columns` and `row_count` alongside."
956
+ ),
957
+ )
958
+
959
+
960
+ class ExplainQueryParams(MCPQueryParams):
961
+ """``explain_query``: the same query ``query_metrics`` takes, compiled and
962
+ described instead of run."""
963
+
964
+
965
+ class ExportDataParams(DispatchParams):
966
+ """``export_data``: a query to export, or a dashboard whose data to export."""
967
+
968
+ format: Literal["csv", "json"] = Field(default="csv", description="Output format: 'csv' or 'json'.")
969
+ metrics: list[str] | None = Field(default=None, description="Metric names to query (query export).")
970
+ dimensions: list[str] | None = Field(default=None, description="Dimensions to group by (query export).")
971
+ grain: str | None = Field(default=None, description="Time granularity: 'day', 'week', 'month', 'quarter' or 'year' (query export).")
972
+ start_date: str | None = Field(default=None, description="Start date, YYYY-MM-DD (query export).")
973
+ end_date: str | None = Field(default=None, description="End date, YYYY-MM-DD (query export).")
974
+ dashboard_id: str | None = Field(default=None, description="Dashboard id for a dashboard export; takes precedence over the query fields.")
975
+ output_path: str | None = Field(default=None, description="If given, save to this file path instead of returning the content.")
976
+ warehouse: str | None = Field(default=None, description="One of the org's warehouses (see dataspring://warehouses); omit for the active one (query export only).")
977
+
978
+
979
+ class UpdateContextParams(DispatchParams):
980
+ """``update_context``: the preference fields to merge in."""
981
+
982
+ updates: dict[str, Any] = Field(
983
+ description=(
984
+ "Fields to update; only these change. Presentation: default_currency, "
985
+ "default_grain, decimal_places, preferred_chart_type. Lists and maps "
986
+ "REPLACE what is stored: favorite_metrics, standing_filters "
987
+ "([{dimension, operator: in|not_in|eq|neq, values}]), "
988
+ "metric_substitutions ({asked: preferred}), default_segment "
989
+ "({dimension, value} or null)."
990
+ ),
991
+ )
992
+
993
+
994
+ class ImportManifestParams(DispatchParams):
995
+ """``import_manifest``: a semantic manifest and the warehouse it belongs to."""
996
+
997
+ manifest_data: dict[str, Any] = Field(
998
+ description="The semantic manifest to import (semantic models, metrics), as a JSON object."
999
+ )
1000
+ warehouse: str | None = Field(
1001
+ default=None,
1002
+ description="Which of the org's warehouses the manifest belongs to (see dataspring://warehouses); omit for the active one.",
1003
+ )
1004
+
1005
+
1006
+ class RenderDashboardParams(DispatchParams):
1007
+ """``render_dashboard``: which dashboard, in which mode."""
1008
+
1009
+ dashboard_id: str = Field(description="ID of the dashboard to render.")
1010
+ format: Literal["pdf", "png", "app"] = Field(
1011
+ default="app",
1012
+ description="'app' for an interactive iframe payload (default), 'pdf' or 'png' for a static export.",
1013
+ )
1014
+ page_id: str | None = Field(default=None, description="(pdf/png only) One page to render; default: all pages.")
1015
+ width: int = Field(default=1200, description="(pdf/png only) Viewport width in pixels.")
1016
+ height: int = Field(default=800, description="(pdf/png only) Viewport height in pixels.")
1017
+ output_path: str | None = Field(default=None, description="(pdf/png only) Save to this file path instead of returning base64.")
1018
+ warehouse: str | None = Field(
1019
+ default=None,
1020
+ description="One of the org's warehouses (see dataspring://warehouses) the widgets query; omit for the active one.",
1021
+ )
1022
+
1023
+
1024
+ class RenderWidgetParams(DispatchParams):
1025
+ """``render_widget``: which widget of which dashboard, in which mode."""
1026
+
1027
+ dashboard_id: str = Field(description="ID of the dashboard containing the widget.")
1028
+ widget_id: str = Field(description="ID of the widget to render.")
1029
+ format: Literal["png", "app"] = Field(
1030
+ default="app",
1031
+ description="'app' for an interactive iframe payload (default), 'png' for a static snapshot.",
1032
+ )
1033
+ width: int = Field(default=600, description="(png only) Viewport width in pixels.")
1034
+ height: int = Field(default=400, description="(png only) Viewport height in pixels.")
1035
+ output_path: str | None = Field(default=None, description="(png only) Save to this file path instead of returning base64.")
1036
+ warehouse: str | None = Field(
1037
+ default=None,
1038
+ description="One of the org's warehouses (see dataspring://warehouses) the widget queries; omit for the active one.",
1039
+ )
1040
+
1041
+
1042
+ class SwitchOrganizationParams(DispatchParams):
1043
+ """``switch_organization``: the org to switch to."""
1044
+
1045
+ org_id: str = Field(description="The organization ID to switch to (see read(kind=\"organizations\")).")
1046
+
1047
+
1048
+ class SubmitErrorReportParams(DispatchParams):
1049
+ """``submit_error_report``: what failed, and what was expected."""
1050
+
1051
+ command: str = Field(description="The tool, resource, or action that failed (e.g. 'query_metrics', 'dataspring://dashboards', 'dashboard_edit').")
1052
+ error_message: str = Field(description="The exact error message received.")
1053
+ expected: str | None = Field(default=None, description="What you expected to happen.")
1054
+ context_info: str | None = Field(default=None, description="What you were trying to achieve.")
1055
+ source: str = Field(default="mcp", description="Which surface sent the report: 'mcp', 'cli' or 'api'.")
1056
+
1057
+
1058
+ # ---------------------------------------------------------------------------
1059
+ # secret_edit: secrets in both directions (D17 and its 2026-09-18 amendment)
1060
+ # ---------------------------------------------------------------------------
1061
+
1062
+
1063
+ class SetSecretAction(DispatchParams):
1064
+ """Get a one-time link where a PERSON pastes a credential. The value
1065
+ never comes through here; the same name rotates a credential."""
1066
+
1067
+ action: Literal["set"]
1068
+ name: str = Field(
1069
+ description=(
1070
+ "The secret to set or rotate: t-<org>-<connection>-<field> for your "
1071
+ "organization, lower-case (t-acme-kanpla-api_key, "
1072
+ "t-acme-external-bigquery-key). A connection file names it."
1073
+ )
1074
+ )
1075
+
1076
+
1077
+ class RevealSecretAction(DispatchParams):
1078
+ """Get a one-time link that shows a credential DataSpring ISSUED to this
1079
+ organization (the Airbyte destination key today), once."""
1080
+
1081
+ action: Literal["reveal"]
1082
+ name: str | None = Field(
1083
+ default=None,
1084
+ description=(
1085
+ "The issued credential, by secret name. Omit for the Airbyte "
1086
+ "destination key (t-<org>-airbyte-key). Only issued credentials can "
1087
+ "be revealed, never ones you gave DataSpring."
1088
+ ),
1089
+ )
1090
+
1091
+
1092
+ SecretAction = Annotated[
1093
+ SetSecretAction | RevealSecretAction,
1094
+ Field(discriminator="action"),
1095
+ ]
1096
+
1097
+
1098
+ class SecretEditParams(DispatchParams):
1099
+ """The ``secret_edit`` call: one ``action`` from the family's union."""
1100
+
1101
+ action: SecretAction
1102
+
1103
+
1104
+ #: Rows one ``run_sql`` call may return, and the ceiling ``max_rows`` may ask for.
1105
+ RUN_SQL_DEFAULT_ROWS = 1000
1106
+ RUN_SQL_MAX_ROWS = 10_000
1107
+
1108
+
1109
+ class RunSqlParams(DispatchParams):
1110
+ """``run_sql``: one statement and how much of its result to return."""
1111
+
1112
+ sql: str = Field(
1113
+ description=(
1114
+ "One SQL statement (BigQuery Standard SQL on a managed warehouse). "
1115
+ "Name tables as <org>_marts.<table>, <org>_core.<table> or "
1116
+ "<org>_staging.<table>; the identity it runs as can read those and "
1117
+ "nothing else, so a write or a reach outside them is refused by "
1118
+ "the warehouse."
1119
+ )
1120
+ )
1121
+ warehouse: str | None = Field(
1122
+ default=None,
1123
+ description="One of the org's warehouses (see dataspring://warehouses); omit for the active one.",
1124
+ )
1125
+ max_rows: int = Field(
1126
+ default=RUN_SQL_DEFAULT_ROWS,
1127
+ ge=1,
1128
+ le=RUN_SQL_MAX_ROWS,
1129
+ description=f"Rows to return at most (default {RUN_SQL_DEFAULT_ROWS}, cap {RUN_SQL_MAX_ROWS}). Aggregate in SQL rather than paging.",
1130
+ )
1131
+ format: Literal["json", "csv", "markdown"] = Field(
1132
+ default="json",
1133
+ description="'json' answers rows in `data`; 'csv' or 'markdown' answer one document in `content`.",
1134
+ )
1135
+ dry_run: bool = Field(
1136
+ default=False,
1137
+ description="Only estimate the bytes the statement would process (and check it parses); run nothing.",
1138
+ )