ts-dependency-manager

star 1

Safely audit, remove, or repoint columns and objects across a ThoughtSpot environment — generates a risk-rated impact report, backs up TML before any change, and supports full rollback.

thoughtspot By thoughtspot schedule Updated 6/9/2026

name: ts-dependency-manager description: Safely audit, remove, or repoint columns and objects across a ThoughtSpot environment — generates a risk-rated impact report, backs up TML before any change, and supports full rollback.

ThoughtSpot: Dependency Manager

Safely make changes that affect dependent objects in ThoughtSpot. Before any modification, this skill generates a full impact report with hyperlinks and risk ratings, lets you choose exactly what to change, takes TML backups, and provides rollback capability.

Supported operations:

  • Remove columns — remove one or more columns from a connection table or Model, then clean up all dependent Answers, Liveboards, and Models that reference them
  • Repoint objects — redirect Answers or Liveboards to a different table or Model, with column-gap detection and mapping

Note: RENAME mode is intentionally not supported by this skill. Smoke testing on champ-staging (2026-04-27) demonstrated that ThoughtSpot's TML import API sometimes applies a column rename despite returning status_code: ERROR — leaving source and dependents out of sync with no atomicity guarantee. Until TS resolves the misleading-error issue (open-item #15) and the skill has a reliable post-import verification path (added v0.2.0), RENAME is excluded. Use the ThoughtSpot UI to rename columns; this skill stays focused on the safe-to-automate operations.

When to use this skill:

  • A database column has been deprecated and needs to be removed from ThoughtSpot cleanly
  • A column has been renamed in the data warehouse and ThoughtSpot objects need updating
  • Answers or Liveboards need to be moved to a new or restructured Model
  • You want to audit what would be affected before making a structural change
  • You want a dependency report on a column/table/Model without committing to a change — Audit mode, or run ts metadata report directly for a non-interactive shell version.

Ask one question at a time. Wait for each answer before proceeding.


References

File Purpose
references/dependency-types.md Status of every dependency type (Implementable / Partial / Manual / GUID-stable / Informational), the dependency hierarchy the skill walks in Step 4, and a worked sample of the Step 5 impact report — read before adding new dep handling or changing how Step 4 walks the graph
references/open-items.md Dependency API, search_query and join constraints, Alert scan, RLS/security/aliasing open items — read before implementing Steps 4 and 9
../ts-profile-thoughtspot/SKILL.md ThoughtSpot auth, profile config, token persistence
../../shared/schemas/thoughtspot-model-tml.md Model TML structure — column and formula placement rules, self-validation checklist
../../shared/schemas/thoughtspot-answer-tml.md Answer TML structure — answer_columns, cohorts (sets), chart, table, search_query layout
../../shared/schemas/thoughtspot-sets-tml.md Set (cohort) TML structure — reusable vs answer-level, anchor_column_id, bin/group/query types
../../shared/schemas/thoughtspot-liveboard-tml.md Liveboard TML structure — visualizations, filters, layout
../../shared/schemas/thoughtspot-view-tml.md View TML structure — view_columns, joins, table_paths, search_query
../../shared/schemas/thoughtspot-alert-tml.md Alert TML structure — metric_id references, personalised_view_info filters
../../shared/schemas/thoughtspot-feedback-tml.md Feedback/coaching TML structure — search_tokens and formula_info column references
../../shared/schemas/thoughtspot-table-tml.md Connection table TML structure — column definitions

Prerequisites

  • ThoughtSpot profile configured — run /ts-profile-thoughtspot if not
  • ts CLI installed: pip install -e tools/ts-cli
  • Python package: pyyaml (pip install pyyaml)
  • ThoughtSpot user must have MODIFY or FULL access on all objects being changed

Step 0 — Overview

On skill invocation, display this plan before doing any work:


ts-dependency-manager — safely audit, remove, or repoint columns and objects across a ThoughtSpot environment, with a full impact report and TML backup before any change is made.

A. Steps

  1. Authenticate ......................................... auto
  2. Choose mode (Audit | Remove | Repoint) ............... you choose Audit produces a dependency report only — no changes applied. Useful before any change to plan blast radius.
  3. Identify source object and column scope .............. you provide
  4. Walk dependents via v2 dependents API and classify impact (alias-aware) ................................. auto (~3 s — single API call + per-dep TML check)
  5. Impact report — review, confirm, re-scan, or exit .... you confirm
  6. Per-object action — fix (update) or delete entirely .. you choose; chart decisions apply only to "fix"
  7. Back up all TML (source + every selected dependent) to /tmp/ts_dep_backup_/ ... auto
  8. Pre-change confirmation; deletes require typed "DELETE" confirmation ... you confirm
  9. Apply changes (deletes first, then updates, then source) ... auto
  10. Rollback on any failure ............................... auto

Confirmation required: Steps 5, 6, 8 (extra typed confirm if any deletes are queued) Auto-executed: Steps 1, 3 (model lookup), 4, 7, 9, 10

B. Dependency hierarchy

What the skill walks during Step 4. Solid arrows = standard dependencies via v2 dependents API; dotted arrows = TML-attached objects that come through --associated exports; dashed arrows = TMLs that exist but are not retrievable on this build.

                  [CONNECTION]
                       │
                       ▼
                   [TABLE]──────────────────┐
                  /  │  \                   │
                 /   │   \              (inline)
                /    │    \                 │
               /     │     \                ▼
              /      │      ╲       [table.rls_rules]
             /       │       ╲              (#7 RLS — verified)
            /        │        ╲
           ▼         ▼         ╲- - -→ [<TABLE>_CSR.column_security_rules]
       [MODEL]    [VIEW]                   (#9 — retrieval unverified)
        / │ \      │ │
       /  │  \     │ │  ............→ [<MODEL>.column_alias]
      /   │   ╲    │ │       (#10 — retrieval unverified)
     /    │    ╲   │ │
    ▼     ▼     ▼  ▼ ▼
 [ANSWER] │  [SET]   ┊
     │    │     │    ┊  (model-attached)
     │    │     ▼    ┊
     │    │  [ANSWER consumers via Set]
     │    │
     ▼    ▼
 [LIVEBOARD]
     │   │
     │   └ ............→ [nls_feedback]   (#18 partial via --associated)
     │
     ├ ............→ [monitor_alert]   (#6 verified via --associated)
     ▼
[SCHEDULE]   (informational only — column-agnostic)

C. Coverage at a glance

Before showing the prompt, run the coverage helper and display its output verbatim so the user can see what the skill auto-detects vs. what needs manual review:

python3 references/build_coverage.py --summary

Sample output (regenerates from references/dependency-types.md):

Coverage:  auto-detected (8): Model / Worksheet, View, Answer, Liveboard, Set / Cohort, Monitor alert, RLS rule, Inline alias
           partial (3): Spotter feedback, Column security rule (CSR), Column alias TML
           informational (2): Schedule, Connection | no skill action (1): Column-level ACLs
           Full breakdown in references/dependency-types.md

If a status changes (e.g. open-item #9 CSR retrieval verified), update references/dependency-types.md only — this block re-renders. Do not hardcode the list anywhere in SKILL.md.

Ready to start? [Y / N]

Do not begin Step 1 until the user confirms.


Step 1 — Authenticate

Read ~/.claude/thoughtspot-profiles.json. If the file is missing or empty, prompt the user to run /ts-profile-thoughtspot first.

If multiple profiles exist, ask:

Which ThoughtSpot profile would you like to use?

  1. {name}  —  {base_url}
  2. {name}  —  {base_url}

Enter number:

If exactly one profile exists, show it and ask the user to confirm.

Authenticate:

source ~/.zshenv && ts auth whoami --profile "{profile_name}"

If the command fails, the token may be expired. Refer to ../ts-profile-thoughtspot/SKILL.md for the token refresh procedure.

Save {base_url} (strip trailing slash) and {profile_name} for all subsequent steps.


Step 2 — Choose Mode

Ask:

What would you like to do?

  Plan:
    A  Audit — generate a dependency report; no changes applied.
       Use this to understand blast radius BEFORE deciding on a column change.

  Apply changes:
    R  Remove column(s)  — remove one or more columns and clean up all dependents
    P  Repoint objects   — redirect Answers / Liveboards to a different
                           Model, View, or connection Table

Enter A / R / P:

Save {operation} (AUDIT / REMOVE / REPOINT) and branch to the appropriate Step 3 sub-section.

RENAME mode is not supported — see the rationale at the top of this file. If the user types N, respond with the warning explaining why and prompt them to pick A/R/P or use the ThoughtSpot UI for renames.

Audit (A) is read-only. No backups taken, no objects modified. The audit produces the same report files as R/N/P (impact_plan.json, impact_report.csv, dependency_tree.txt, dependency.mmd) and exits cleanly after Step 5. The user can re-run with R/N/P later once they've planned their change.

For a quick non-interactive audit, you can also run ts metadata report <source> directly — same coverage, no skill conversation. The CLI emits the same impact report data in JSON, text, or markdown.


Step 3-A — Audit scope (operation = AUDIT)

Search for the source object exactly like Step 3-R (search both WORKSHEET and ONE_TO_ONE_LOGICAL and AGGR_WORKSHEET subtypes). Save {source_guid}, {source_name}, {source_type}.

Then ask which scope to report on:

What scope?

  1  One column          — pick a single column from the source
  2  Several columns     — pick multiple columns by number

  Deferred to a future commit (currently routed to scope 1 with a warning):
  3  Whole object        — section-per-column breakdown of every column on the source
  4  Repoint pre-flight  — pick a target object; show column-gap from source → target

Enter 1 / 2 / 3 / 4:

Then pick the column(s) using the same numbered-list flow as Step 3-R. Save:

  • {audit_columns} — list of column names to scan against
  • {audit_scope}"ONE" / "MANY" / "WHOLE" / "REPOINT_PREFLIGHT"

Branch directly to Step 4 — Dependency Discovery with these inputs. Step 4 runs the same recursive walk regardless of operation. After Step 5 renders the report, audit mode exits cleanly at Step 5.5 (defined below) — it does NOT proceed to Step 6 / 7 / 8 / 9.

Scope 3 and 4 are deferred — see open-items.md #19 (whole-object section-per-column report) and #20 (repoint pre-flight column-gap analysis). For now, if the user picks 3 or 4, print a notice and route them to scope 1 ("treat this as one-column for now").

if audit_scope in ("WHOLE", "REPOINT_PREFLIGHT"):
    print("\n⚠  Scope 3 (Whole object) and Scope 4 (Repoint pre-flight) are not yet")
    print("   implemented. Falling back to scope 1 — pick a single column.\n")
    audit_scope = "ONE"
    # Re-prompt for a single column

Step 3-R — Identify Columns to Remove

Ask:

Which object contains the column(s) you want to remove?

  Enter a name or partial name to search (searches Models and connection tables):

Search for the object — run both queries and combine results:

# Models and Worksheets
source ~/.zshenv && ts metadata search \
  --subtype WORKSHEET \
  --name "%{search_term}%" \
  --profile "{profile_name}" \
  --all

# Connection tables
source ~/.zshenv && ts metadata search \
  --subtype ONE_TO_ONE_LOGICAL \
  --name "%{search_term}%" \
  --profile "{profile_name}" \
  --all

Show results as a numbered list, labelled [MODEL], [WORKSHEET], or [TABLE]. Let the user pick one.

Save {source_guid}, {source_name}, {source_type} (MODEL / WORKSHEET / TABLE).

Export the source TML to get the column list:

source ~/.zshenv && ts tml export {source_guid} \
  --profile "{profile_name}" \
  --fqn \
  --parse
import json, subprocess

result = subprocess.run(
    ["bash", "-c",
     f"source ~/.zshenv && ts tml export {source_guid} "
     f"--profile '{profile_name}' --fqn --parse"],
    capture_output=True, text=True,
)
export_json = json.loads(result.stdout)
source_export_item = export_json[0]  # save for Step 9
tml = source_export_item["tml"]

if source_type in ("MODEL", "WORKSHEET"):
    section = tml.get("model") or tml.get("worksheet", {})
    columns = [
        {"name": c["name"], "type": c.get("properties", {}).get("column_type", "ATTRIBUTE")}
        for c in section.get("columns", [])
    ]
else:  # TABLE
    table_section = tml.get("table", {})
    columns = [{"name": c["name"], "type": "ATTRIBUTE"} for c in table_section.get("columns", [])]

Show columns as a numbered list:

Columns in "{source_name}":

  1  Customer ID     ATTRIBUTE
  2  Revenue         MEASURE
  3  Order Date      ATTRIBUTE
  4  Legacy Region   ATTRIBUTE
  5  Old Segment     ATTRIBUTE
  ...

Enter numbers to remove (comma-separated), or a search term to filter:

Save {columns_to_remove} (list of column names).

Confirm the selection:

Columns selected for removal from "{source_name}":

  - Legacy Region
  - Old Segment

Correct? (Y / N):

Step 3-P — Identify Objects and Target for Repoint

Ask:

What would you like to repoint?

  A  Specific Answers or Liveboards — search by name
  M  All objects pointing at a specific Model — search for the source Model

Enter A or M:

M — All objects pointing at a source Model:

Search for the source Model, let the user pick. Save {source_guid}, {source_name}.

Then ask:

What is the new target Model or table?

  Enter a name or partial name to search:

Search and let user pick. Save {target_guid}, {target_name}, {target_type}.

Export both source and target TMLs to identify the column gap:

def export_columns(guid, profile_name):
    result = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts tml export {guid} "
         f"--profile '{profile_name}' --fqn --parse"],
        capture_output=True, text=True,
    )
    item = json.loads(result.stdout)[0]
    tml = item["tml"]
    section = tml.get("model") or tml.get("worksheet") or tml.get("table", {})
    return {c["name"] for c in section.get("columns", [])}

source_cols = export_columns(source_guid, profile_name)
target_cols = export_columns(target_guid, profile_name)
column_gap  = source_cols - target_cols   # columns not available in target

If column_gap is non-empty, show it to the user:

Column gap — these columns exist in "{source_name}" but NOT in "{target_name}":

  - Legacy Region
  - Old Segment Code

  Any objects that use these columns will have them removed during the repoint.

Continue? (Y / N):

A — Specific Answers or Liveboards: Search by name, let the user select multiple.

For each selected object, export its TML and check which of its data source references will be repointed. Then ask for the target using the same search as above.

Save {source_guid}, {target_guid}, {target_name}, {column_gap}.


Step 4 — Walk dependents

Call the ts metadata report command to do the walk. It returns the same data the skill previously assembled inline, plus richer coverage (RLS, alerts, joins, Spotter AI surface area, column aliases).

ts metadata report <source-guid> --profile {profile_name} --format json --depth 3 > /tmp/{slug}_report.json

Then parse the JSON. Key fields:

  • source{ "input", "guid", "type", "name", "parent" }
  • dependents[] — flat list, each with guid / name / type / hops / owner / modified_at / risk{tag,reason}
  • coverage[][{ "type", "checked", "found", "reason?" }, ...]
  • classification{ "per_dependent", "aggregate{tag,reason}", "recommendation" }

Where the skill needs to filter by audit-scope (specific columns vs whole-object), it does so over the dependents list after parsing — the CLI returns everything the source touches.

Filtering by scope

Scope Filter applied after parse
Specific column(s) Keep dependents whose risk.reason references the column name; drop others.
Column set As above, but check membership against the set's columns.
Whole object Keep all dependents.

Step 5 — Render the impact report

The CLI already does the rendering. Run:

ts metadata report <source-guid> --profile {profile_name} --format md --out /tmp/{slug}_impact_report.md

Present the markdown content to the user. Apply scope filtering (Step 4) to the dependents table before display when the audit scope is column-specific.

The CLI's coverage matrix is the canonical Scan Coverage block — build_coverage.py is now retired in favor of the live API-driven coverage list.

Stop conditions

When classification.aggregate.tag == "STOP", surface to the user:

⛔ STOP CONDITION — {recommendation}

{reason}

Resolve via the ThoughtSpot UI (remove or rewrite the RLS rule) before re-running this skill.

For Audit mode, stop after this step. For Remove / Repoint, proceed to Step 6 only if the user explicitly accepts the STOP impact.


Step 6 — Object Selection and Per-Object Action

This step has three parts run in order:

  • 6a — Selection: which dependents to act on (existing A / N / S menu)
  • 6b — Action per object: for each selected dependent, choose to fix it (strip column references — the historical default) or delete it entirely. Delete is opt-in and gated by a typed-confirmation prompt at Step 8
  • 6c — Chart-removal decisions: only asked for objects marked fix that have action == "REMOVE_CHART". Objects marked for delete skip these decisions — the entire Liveboard or Answer is going away, so per-viz choices are moot

6a — Select objects

If any dependent has action == "REMOVE_CHART", present per-chart decisions later in 6c (only for objects the user marks fix in 6b). Liveboards with REMOVE_CHART vizzes can have those specific charts removed from the liveboard; standalone Answers with REMOVE_CHART cannot be auto-fixed and are excluded from the fix list (you can still mark them delete).

CHART REMOVAL DECISIONS REQUIRED

The following chart visualizations use the removed column as a primary axis (X or Y).
Each requires a decision before the main operation proceeds:

─── LIVEBOARD: Q4 Sales Overview ──────────────────────────────────
  Viz: Revenue Trend  (Y axis: Legacy Region)
    A  Remove this chart from the liveboard
    S  Skip — leave this visualization unchanged (may appear broken after column removal)

─── LIVEBOARD: Monthly Report ──────────────────────────────────────
  Viz: Monthly Revenue by Region  (X axis: Legacy Region)
    A  Remove this chart from the liveboard
    S  Skip — leave this visualization unchanged

─── ANSWERS (skip only — cannot partially fix a standalone Answer) ─
  → Sales by Region Q4  (Y axis: Legacy Region)
     Automatically excluded from update list. Will appear in Change Report as
     "Requires manual intervention".

Enter decisions (e.g. "1A 2S"):

Ask:

Which of the {N} dependent objects would you like to act on?

  A  All {N} objects
  N  None — make the source change only; dependent objects will need manual updates
  S  Select specific objects

Enter A / N / S:

If S, show the numbered list from the impact report and let the user enter numbers (comma-separated). Confirm the selection before proceeding.

The source object itself is always updated — the column must be removed, renamed, or the source must be updated regardless of which dependents are selected. The source cannot be marked for delete from this step (deleting the source would be a different operation entirely; refuse and re-prompt if the user attempts it).

Save {selected_objects} (list of dependent object dicts including guid, type, name, tml).

6b — Choose action per selected object: fix or delete

Default action for every selected object is fix (= strip the column references — the behaviour applied prior to this skill version). The user can opt to delete specific objects instead. Deletes are destructive and not eligible for clean rollback (see the warning below) — keep this option opt-in.

Ask:

For the {M} selected dependent(s), choose an action:

  F  Fix all       — strip the affected column references from every selected object
                     (default; preserves objects)

  D  Delete all    — DELETE every selected dependent object entirely
                     ⚠  IRREVERSIBLE without manual TML re-import; new GUIDs on restore;
                     references from any object NOT in the selected list will break.

  M  Mixed         — choose F or D for each selected object individually

Enter F / D / M:

If D or M, show this warning block before continuing:

⚠  DELETE WARNING

  Deleting a ThoughtSpot object is permanent. The skill will:
    • Take a TML backup before any delete (Step 7 — non-negotiable, always runs)
    • Call POST /api/rest/2.0/metadata/delete (with explicit type) for each object marked
      for delete in Step 9. Note: the ts CLI's `metadata delete` command is currently broken
      — it reports success without actually deleting — so the skill calls v2 directly
    • Offer rollback by re-importing the backup at Step 11

  Important rollback caveats:
    • Re-imported objects receive a NEW GUID. Any object that referenced the deleted
      object by its original GUID — including objects NOT in the selected list, or
      objects you cannot see (cross-org, RLS-restricted, Spotter feedback) — will
      remain broken even after rollback.
    • Sets / Cohorts / Spotter feedback / Alerts that depend on the deleted object
      cannot be auto-restored.
    • Re-imported Liveboards lose their saved layout if the backup TML doesn't include it.

  Do not proceed unless you accept these caveats.

If M, present a numbered list and ask for one F/D per row:

Set action for each object (e.g. "1F 2D 3D 4F"):

  1  Q4 Sales Overview        (LIVEBOARD)  default: F
  2  Sales by Region Q4       (ANSWER)     default: F
  3  Customer 360 Model       (MODEL)      default: F
  4  Historical Comparison    (ANSWER)     default: F

Validate input — every selected object must have an explicit F or D. Reject ambiguous input and re-prompt rather than guessing.

Save the action on each dict: dep["user_action"] = "FIX" | "DELETE".

Split the list:

  • objects_to_fix — dependents with user_action == "FIX"
  • objects_to_delete — dependents with user_action == "DELETE"

6b.1 — Set-cascade enforcement

If any Set is marked DELETE in objects_to_delete, every consumer of that Set (Answers and Liveboards listed in the Set's consumers[] from Step 4) MUST also be in either objects_to_fix or objects_to_delete. Deleting a Set out from under its consumers silently breaks them — the consumers continue to reference the (now-missing) Set GUID.

Walk the selection and check; if any consumer is missing, block the run with a stop- condition prompt and require the user to either select the consumer for action or revert the Set to FIX.

deletes_blocked_by = []   # list of (set_dep, missing_consumer_guid)

for set_dep in [d for d in objects_to_delete if d["type"] == "SET"]:
    selected_guids = {d["guid"] for d in objects_to_fix} | {d["guid"] for d in objects_to_delete}
    for consumer_guid in set_dep.get("in_use_by", []):
        if consumer_guid not in selected_guids:
            deletes_blocked_by.append((set_dep, consumer_guid))

if deletes_blocked_by:
    print("\n⛔  SET-CASCADE BLOCK")
    print("  The following Sets are marked DELETE but have consumers that aren't in the action list.")
    print("  Deleting the Set will silently break these consumers. Resolve before continuing.\n")
    for set_dep, consumer_guid in deletes_blocked_by:
        consumer = next((d for d in dependents if d["guid"] == consumer_guid), None)
        consumer_name = (consumer or {}).get("name", "(unknown)")
        print(f"    Set: {set_dep['name']} ({set_dep['guid']})")
        print(f"      missing consumer: {consumer_name} ({consumer_guid})")
    print("\n  Options:")
    print("    A  Add the missing consumers to the action list (you'll be prompted F/D each)")
    print("    R  Revert the Set(s) to FIX action")
    print("    X  Exit")
    # Re-prompt — do not proceed to Step 6c until resolved

6c — Chart-on-axis decisions (only for objects to fix)

Run this flow ONLY for entries in objects_to_fix with action == "REMOVE_CHART". Skip entirely for objects in objects_to_delete — the whole object is being removed, so per-viz decisions are irrelevant.

Two valid choices per viz/answer — SKIP is not offered. Skipping would leave the column referenced in that object, which causes ThoughtSpot to reject the source change at Step 9b with error 14544 ("Deleted columns have dependents") naming the skipped object. The user must pick a real fix or delete the whole object via Step 6b.

CHART AXIS DECISIONS REQUIRED

The following visualizations use the removed column as a primary X or Y axis.
For each, choose how to fix it:

  T  Convert to table view  (default — preserves the viz, switches display_mode to TABLE_MODE,
                             strips the column from search_query/answer_columns/chart bindings)
  A  Remove the chart       (Liveboard only — drops the entire viz from the liveboard;
                             for standalone Answers this is unavailable — delete via Step 6b
                             if you want the Answer gone)

─── LIVEBOARD: Q4 Sales Overview ──────────────────────────────────
  1  Viz: Revenue Trend            (Y axis: Legacy Region)   [T / A]
  2  Viz: Monthly Revenue by Region (X axis: Legacy Region)  [T / A]

─── ANSWERS (auto-fixed to TABLE_MODE — no decision needed) ────────
   - Sales by Region Q4  (Y axis: Legacy Region)  → CONVERT_TO_TABLE

Enter decisions (e.g. "1T 2A"), or press Enter to accept defaults (all T):

Save per-viz decisions as dep["viz_remove_decisions"] = {viz_id: "CONVERT_TO_TABLE" | "REMOVE"} for each liveboard. Standalone Answers with action == "REMOVE_CHART" are auto-marked CONVERT_TO_TABLE (they remain in objects_to_fix).

Final state passed to Step 7:

  • objects_to_fix — dependents to update (column references stripped)
  • objects_to_delete — dependents to delete entirely

Inform the user about backup location before continuing:

Next: Step 7 will back up the source object PLUS every selected dependent.

  Backup directory: /tmp/ts_dep_backup_<YYYYMMDD_HHMMSS>/
  This includes the source — even if you selected "None" above, the source TML is still
  backed up because the column change itself is a change. Backup runs automatically
  before any modification and is required for rollback.

Step 7 — TML Backup

Non-negotiable: this step always runs and must complete successfully before Step 9. A successful TML export is taken for every object that will change — including the source itself, even if no dependents were selected. The source change (column removed, renamed, or repointed) is itself a change and is always backed up.

Scope of the backup:

  • Source object (always — the column-modifying change happens here at Step 9b)
  • Every dependent in objects_to_fix (column references stripped at Step 9c)
  • Every dependent in objects_to_delete (object removed at Step 9a)
  • A manifest.json index linking each backup file to its GUID, name, type, and intent

If any backup export fails, abort the run and ask the user how to proceed — never skip, never proceed with partial backup.

Choose backup location

Before generating the backup path, ask the user:

Where should the TML backup be saved?

  1  /tmp/   (default — ephemeral, cleared on reboot)
  2  Custom path

Enter 1 or a path [default: 1]:

If the user enters a custom path, use it as the base directory. The skill appends ts_dep_backup_<YYYYMMDD_HHMMSS>/ for uniqueness in both cases. If the directory doesn't exist, create it with os.makedirs(path, exist_ok=True).

Announce backup location BEFORE running

Before exporting anything, tell the user where the backup will be written and what it will contain. The path is generated once and reused for the rest of this run.

TML BACKUP — about to run

  Location:  {backup_dir}/
  Contents:  manifest.json
             {source_type}_{source_guid}_{source_name}.json   ← source
             {type}_{guid}_{name}.json   × {F + D} dependent(s)

  This backup is required for rollback (Step 11). Keep the directory until you are
  confident the change is correct.

Replace the placeholders with the actual path (computed below) and counts before printing. Then begin the export loop.

Export and save the current TML for every object that will be modified or deleted. Do this even if the user selected "None" in Step 6 (the source still changes).

import os, json, datetime

timestamp   = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_base = custom_backup_path or "/tmp"
backup_dir  = os.path.join(backup_base, f"ts_dep_backup_{timestamp}")
os.makedirs(backup_dir, exist_ok=True)

# Source + everything we touch (fix + delete) — non-negotiable: always back up before Step 9
all_to_backup = (
    [{"guid": source_guid, "name": source_name, "type": source_type, "intent": "FIX_SOURCE"}]
    + [{**d, "intent": "FIX"}    for d in objects_to_fix]
    + [{**d, "intent": "DELETE"} for d in objects_to_delete]
)

# Announce the backup destination BEFORE exporting so the user can see where files will land.
print(f"TML backup will be written to: {backup_dir}/")
print(f"  {len(all_to_backup)} object(s) will be exported "
      f"(1 source + {len(objects_to_fix)} fix + {len(objects_to_delete)} delete)")

manifest = {
    "created":       timestamp,
    "profile":       profile_name,
    "base_url":      base_url,
    "operation":     operation,
    "source_object": {"guid": source_guid, "name": source_name, "type": source_type},
    "fix_count":     len(objects_to_fix),
    "delete_count":  len(objects_to_delete),
    "objects":       [],
}

print(f"Backing up {len(all_to_backup)} object(s) to {backup_dir}...")

for obj in all_to_backup:
    result = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts tml export {obj['guid']} "
         f"--profile '{profile_name}' --fqn --parse"],
        capture_output=True, text=True,
    )
    if result.returncode != 0:
        # Backup failure on a delete-target is fatal — TML is the only restore source.
        # Backup failure on a fix-target is also treated as fatal: the skill guarantees
        # rollback, and rollback requires the backup. Abort and surface to the user.
        print(f"  ✗ Backup FAILED for '{obj['name']}' ({obj['guid']}) — intent={obj.get('intent')}")
        raise SystemExit(
            "Backup failed for at least one object. No changes have been applied. "
            "Investigate the export error (token, permissions, object validity) and re-run."
        )

    for item in json.loads(result.stdout):
        safe_name   = obj["name"].replace("/", "_").replace("\\", "_")[:60]
        backup_file = os.path.join(backup_dir,
                                   f"{item['type']}_{item['guid']}_{safe_name}.json")
        with open(backup_file, "w") as f:
            json.dump(item, f, indent=2)
        manifest["objects"].append({
            "guid":        item["guid"],
            "name":        obj["name"],
            "type":        item["type"],
            "intent":      obj.get("intent", "FIX"),
            "backup_file": backup_file,
        })

with open(os.path.join(backup_dir, "manifest.json"), "w") as f:
    json.dump(manifest, f, indent=2)

print(f"  Backed up {len(manifest['objects'])} file(s) — manifest at {backup_dir}/manifest.json")

Save {backup_dir} for use in Steps 10 and 11.

Tell the user:

TML backup saved to: {backup_dir}
  {N} object(s) backed up. Keep this path handy — it is required for rollback.

Step 8 — Pre-change Confirmation

Show a concise summary of all changes that will be made. No changes have been made yet. Sections only appear when non-empty: skip "Dependent objects to delete" if objects_to_delete is empty, etc.

REMOVE example with mixed fix + delete:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 PENDING CHANGES — ready to apply
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Source:
  {source_name} ({source_type}):
    Remove columns: Legacy Region, Old Segment

Dependent objects to FIX ({F}):
  Q4 Sales Overview     (LIVEBOARD) — remove column references
  Customer 360 Model    (MODEL)     — remove formula referencing Legacy Region

Dependent objects to DELETE ({D}):  ⚠  IRREVERSIBLE without manual intervention
  Sales by Region Q4    (ANSWER)    — entire object will be deleted
  Historical Comparison (ANSWER)    — entire object will be deleted

Skipped ({K} not selected):
  Old Trend Liveboard   (LIVEBOARD)

Backup: {backup_dir}
Order:  deletes first → updates → source
Policy: ALL_OR_NONE per object update; v2 /metadata/delete (with explicit type) for deletions

IMPORTANT: This will modify {F + 1} and DELETE {D} live ThoughtSpot objects.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Proceed? (Y / N):

If objects_to_delete is non-empty AND user answers Y, require a typed confirmation before proceeding to Step 9:

You have {D} object(s) marked for DELETE. This is irreversible without manual TML re-import.

Type DELETE (in capitals, exactly) to confirm:

If the input is anything other than the exact string DELETE, treat it as a no and ask:

  A  Adjust selection (go back to Step 6)
  X  Exit without making any changes

Enter A / X:

If N at the first prompt (no deletes pending), ask the same A / X choice without the typed confirmation step.


Step 9 — Apply Changes

Apply changes in this order:

  1. 9a — Deletes (objects_to_delete) — runs first; leaf-most types first (Liveboards, Answers), then Models/Views in reverse-dependency order. Removes the objects the user explicitly asked to delete before any updates so the dependency graph reflects the final intent.
  2. 9b — Fixes on dependents (objects_to_fix) — strip column references from each remaining dependent. Order: Liveboards/Answers first (no recursion); Sets next; Views; Models last (so a Model's dependent Views/Answers/Sets reflect the fix before the Model itself is rewritten).
  3. 9c — Source — modify the source object (column removed / renamed / repointed). Runs LAST because TS error 14544 will reject the source change while ANY visible dependent still references the column. Source must be the final step.
  4. 9d — Reusable Sets — only used for sets where action is DELETE and 9b didn't already handle them; for FIX-mode sets, the body update happens in 9b alongside other dependents.

Each import is atomic (ALL_OR_NONE per object). A failure on one item does not roll back previously successful operations — track all results in a single results dict for the Step 10 Change Report.

Drift check helper (Fix #3, 2026-04-26)

Before applying any change to a specific object, re-query its metadata_header.modified and compare against the value snapshotted in Step 4 (modified_at_scan[guid]). If the timestamp moved, someone else edited the object between scan and apply — the dep walk's analysis (chart roles, alias chain, removed-column references) is now stale. Skip the import and report DRIFT_DETECTED.

def check_drift(guid, type_str, profile_name, snapshot):
    """Returns (has_drift: bool, current_modified: int, error: Optional[str]).

    `snapshot` is the int from `modified_at_scan[guid]`. If we cannot re-query
    (network failure, deleted object), return has_drift=True with the error so
    the caller skips the import — better to fail safe than to clobber.
    """
    r = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts metadata get {guid} --type {type_str} --profile '{profile_name}'"],
        capture_output=True, text=True,
    )
    if r.returncode != 0 or not r.stdout.strip():
        return True, 0, (r.stderr.strip() or "metadata get returned no output")
    try:
        meta = json.loads(r.stdout)
    except json.JSONDecodeError as e:
        return True, 0, f"JSON decode error: {e}"
    current = int((meta.get("metadata_header") or {}).get("modified", 0))
    if not snapshot:
        # No snapshot to compare against — be conservative and allow the import.
        # Source-not-found cases will surface naturally as the import proceeds.
        return False, current, None
    return (current != snapshot), current, None


def v2_type_for(skill_type):
    """Map skill type label to v2 metadata type."""
    return {
        "ANSWER": "ANSWER", "LIVEBOARD": "LIVEBOARD",
        "SET": "LOGICAL_COLUMN", "COHORT": "LOGICAL_COLUMN",
    }.get(skill_type.upper(), "LOGICAL_TABLE")


def assert_no_drift_or_skip(guid, skill_type, profile_name, snapshot, results,
                            phase, name=None):
    """Returns True if safe to proceed; False if drift detected (caller must skip).
    Logs the skip into `results['skipped']` with reason DRIFT_DETECTED."""
    has_drift, current, err = check_drift(guid, v2_type_for(skill_type), profile_name, snapshot)
    if has_drift:
        reason = (f"DRIFT_DETECTED — modified at scan was {snapshot}, "
                  f"now {current}" + (f"; query error: {err}" if err else ""))
        results.setdefault("skipped", []).append({
            "guid": guid, "name": name or guid, "type": skill_type,
            "phase": phase, "reason": reason,
        })
        print(f"  ⚠ Skip {skill_type} {name or guid} — {reason}")
        return False
    return True

Detect obj_id support (REPOINT only). Before any modifications, probe whether the instance supports obj_id references by exporting the source with the obj_id flags. When obj_id is available, the repoint helpers use it instead of fqn — avoiding VERSION_CONFLICT (error 14009) on builds that track content versions via obj_id.

source_obj_id = None
target_obj_id = None

if operation == "REPOINT":
    probe = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts tml export {source_guid} "
         f"--profile '{profile_name}' --include-obj-id --include-obj-id-ref "
         f"--no-guid --parse"],
        capture_output=True, text=True,
    )
    if probe.returncode == 0:
        probe_items = json.loads(probe.stdout)
        probe_tml = probe_items[0]["tml"] if probe_items else {}
        probe_section = probe_tml.get("model") or probe_tml.get("worksheet") or probe_tml.get("table", {})
        for tbl in probe_section.get("model_tables", probe_section.get("tables", [])):
            if tbl.get("obj_id"):
                source_obj_id = tbl.get("obj_id")
                break
        if not source_obj_id and probe_tml.get("obj_id"):
            source_obj_id = probe_tml["obj_id"]

    if source_obj_id:
        # Derive target obj_id: {target_name}-{first 8 chars of target_guid}
        target_obj_id = f"{target_name}-{target_guid[:8]}"
        print(f"  obj_id detected — using obj_id-based repoint (avoids VERSION_CONFLICT)")
        print(f"    source: {source_obj_id}")
        print(f"    target: {target_obj_id}")
    else:
        print(f"  obj_id not available — using fqn-based repoint")

Source drift is a hard stop. If the source object has drifted between Step 4 and Step 9, abort the entire run before touching any dependent — the Step 6 plan was computed against the old source, and applying it could remove columns that were re-purposed in the meantime.

# Run this BEFORE 9a/9b/9c
if not assert_no_drift_or_skip(source_guid, source_type, profile_name,
                               modified_at_scan.get(source_guid, 0),
                               results, phase="drift_pre_check",
                               name=source_name):
    raise SystemExit(
        "Source object has drifted since Step 4 — aborting the entire run. "
        "No changes applied. Re-run /ts-dependency-manager from Step 1 to "
        "rebuild the impact plan against the current source state."
    )

For dependent objects, drift detection skips that one object and continues with the rest. The user sees DRIFT_DETECTED entries in the Change Report and can re-run the skill on the affected dependents.

9a — Delete objects marked for deletion

Skip this section entirely if objects_to_delete is empty.

Verified 2026-05-11 on se-thoughtspot: ts metadata delete <guid> --type <type> genuinely deletes objects. The CLI passes type in the v2 request body and the API performs the deletion (confirmed by post-delete ts metadata get returning "No … object found"). See open-items.md #17 for the history of the earlier CLI bug (now fixed).

results = {"succeeded": [], "failed": [], "deleted": [], "skipped": []}

# Delete order: terminal types first, then non-terminal (Sets, Models/Views, source last)
# Sets are intentionally placed before Models/Views — a Set's parent Model still needs to
# exist so the Set's TML can be backed up earlier in Step 7. Sets delete cleanly even
# while their Model is still present, since the cascade is one-directional.
delete_order = {
    "LIVEBOARD": 0, "ANSWER": 1,
    "SET": 2, "COHORT": 2,
    "VIEW": 3,
    "MODEL": 4, "WORKSHEET": 4,
    "TABLE": 5,
}
sorted_deletes = sorted(objects_to_delete, key=lambda d: delete_order.get(d["type"].upper(), 9))

# v2 type values expected by --type flag
v2_type_map = {
    "ANSWER":    "ANSWER",
    "LIVEBOARD": "LIVEBOARD",
    "MODEL":     "LOGICAL_TABLE",
    "WORKSHEET": "LOGICAL_TABLE",
    "VIEW":      "LOGICAL_TABLE",
    "TABLE":     "LOGICAL_TABLE",
    "SET":       "LOGICAL_COLUMN",
    "COHORT":    "LOGICAL_COLUMN",
}

for dep in sorted_deletes:
    v2_type = v2_type_map.get(dep["type"].upper())
    if not v2_type:
        results["failed"].append({**dep, "error": f"no v2 type mapping for {dep['type']}",
                                  "phase": "delete"})
        continue

    # Drift check before deleting (Fix #3). If the object has been modified since
    # Step 4, the user's selection at Step 6 may have been based on stale state —
    # skip the delete and let the user re-scan.
    if not assert_no_drift_or_skip(dep["guid"], dep["type"], profile_name,
                                   modified_at_scan.get(dep["guid"], 0),
                                   results, phase="delete", name=dep["name"]):
        continue

    print(f"  Deleting {dep['type']}: {dep['name']} ({dep['guid']})...")
    r = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts metadata delete {dep['guid']} --type {v2_type} "
         f"--profile '{profile_name}'"],
        capture_output=True, text=True,
    )

    if r.returncode == 0:
        print(f"  ✓ Deleted: {dep['name']}")
        results["deleted"].append(dep)
    else:
        # Verify by re-querying — if the object is gone, treat as success
        check = subprocess.run(
            ["bash", "-c",
             f"source ~/.zshenv && ts metadata get {dep['guid']} --type {v2_type} "
             f"--profile '{profile_name}'"],
            capture_output=True, text=True,
        )
        if check.returncode != 0 or not check.stdout.strip():
            print(f"  ✓ Deleted: {dep['name']}  (verified by post-query)")
            results["deleted"].append(dep)
        else:
            err = f"CLI exit {r.returncode}: {r.stderr[:200]}"
            print(f"  ✗ Delete failed: {dep['name']} — {err}")
            results["failed"].append({**dep, "error": err, "phase": "delete"})

If a delete fails (permissions, dependent of an inaccessible object, etc.), continue with the remaining deletes — the user will see the failures in the Change Report and can decide whether to retry or roll back.

9b — Modify and import the source object

Read references/open-items.md #2 before modifying Answer or Liveboard TML — some fields use opaque column IDs that differ from display names.

Helper: serialize TML dict to YAML and import

import copy, yaml, re, json, subprocess

def _str_representer(dumper, data):
    if '\n' in data or ('{' in data and '}' in data):
        return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='>')
    return dumper.represent_scalar('tag:yaml.org,2002:str', data)

yaml.add_representer(str, _str_representer)

def import_tml(tml_dict, guid, profile_name, policy="ALL_OR_NONE", create_new=False):
    """
    Import a TML dict.

    create_new=False (default) — update an existing object at `guid`. Adds `--no-create-new`
        so an unknown GUID errors out instead of silently creating a duplicate.
    create_new=True  — used by Step 11 rollback after a delete: the original GUID no longer
        exists, so we want TS to assign a new one. Strips `guid:` from the YAML and drops
        the `--no-create-new` flag.
    """
    tml_yaml = yaml.dump(tml_dict, allow_unicode=True, default_flow_style=False)
    if create_new:
        tml_yaml = re.sub(r"^guid:\s*\S+\s*\n", "", tml_yaml, count=1, flags=re.MULTILINE)
    elif not tml_yaml.strip().startswith("guid:"):
        tml_yaml = f"guid: {guid}\n" + tml_yaml
    payload = json.dumps([tml_yaml])
    cmd = (f"source ~/.zshenv && ts tml import "
           f"--profile '{profile_name}' --policy {policy}")
    if not create_new:
        cmd += " --no-create-new"
    result = subprocess.run(["bash", "-c", cmd], input=payload, capture_output=True, text=True)
    return json.loads(result.stdout) if result.stdout.strip() else {}

def import_status(resp):
    """Extract (ok: bool, error_msg: str) from an import response.

    NOTE: do not trust this in isolation. TS sometimes returns status_code=ERROR
    while still applying the change (open-item #15 + 2026-04-27 smoke test on
    TEST_DEPENDENCY_MANAGEMENT). Always pair with `verify_change_applied()`
    before reporting a per-object outcome.
    """
    try:
        status = resp["object"][0]["response"]["status"]
        ok     = status.get("status_code") == "OK"
        return ok, status.get("error_message", "Unknown error")
    except (KeyError, IndexError):
        return False, str(resp)


def verify_change_applied(guid, skill_type, profile_name, *,
                          operation, columns_to_remove=None,
                          target_guid=None, column_gap=None,
                          target_obj_id=None):
    """Re-export the object's TML and confirm the expected change was applied.

    Returns (verified_applied: bool, detail: str). The skill calls this AFTER
    every Step 9 import (regardless of import_status) and decides what to do
    based on the matrix:

      api=OK + verified=True    → SUCCESS
      api=OK + verified=False   → FAIL (silent rejection — rare)
      api=ERROR + verified=True → SUCCESS_WITH_WARNING (open-item #15: TS
                                  applied the change despite returning ERROR;
                                  log the warning, treat as succeeded, continue)
      api=ERROR + verified=False → FAIL (genuine rejection — current behaviour)

    Why this matters: smoke-test 2026-04-27 had TS return error_message
    "Invalid YAML/JSON syntax in file" while applying the rename. Without
    post-import verification the skill thought the import failed and aborted —
    leaving source and dependents out of sync.

    Per-operation verification:
      REMOVE  — none of the columns_to_remove may appear in the exported TML
      REPOINT — target_guid or target_obj_id must appear in the exported TML;
                columns in column_gap (if any) must be absent
    """
    cmd = (f"source ~/.zshenv && ts tml export {guid} "
           f"--profile '{profile_name}' --fqn "
           f"--include-obj-id --include-obj-id-ref --parse")
    r = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
    if r.returncode != 0 or not r.stdout.strip():
        return False, f"verification re-export failed: {r.stderr[:200]}"
    try:
        items = json.loads(r.stdout)
        if not items:
            return False, "verification re-export returned no items"
        body = json.dumps(items[0]["tml"])
    except (json.JSONDecodeError, KeyError) as e:
        return False, f"verification parse error: {e}"

    if operation == "REMOVE":
        cols = list(columns_to_remove or [])
        # Check both the bracketed token form ("[col]") and TABLE::col form
        leftover = [
            c for c in cols
            if (f'"{c}"' in body) or (f"[{c}]" in body) or (f"::{c}" in body)
        ]
        if leftover:
            return False, f"REMOVE not applied — still references: {leftover}"
        return True, f"REMOVE verified — none of {cols} appear in TML"

    if operation == "REPOINT":
        # Verify target is referenced — check both fqn and obj_id
        target_found = False
        if target_guid and target_guid in body:
            target_found = True
        if target_obj_id and target_obj_id in body:
            target_found = True
        if not target_found and (target_guid or target_obj_id):
            ref = target_obj_id or target_guid[:8]
            return False, f"REPOINT not applied — target {ref} not in TML"
        if column_gap:
            still_present = [c for c in column_gap if (f"[{c}]" in body) or (f"::{c}" in body)]
            if still_present:
                return False, f"REPOINT partial — gap columns still present: {still_present}"
        return True, "REPOINT verified — target referenced; gap columns absent"

    # Unknown operation: treat as not-verifiable (return True so we don't block)
    return True, f"verification skipped for operation={operation}"


def import_and_verify(tml_dict, guid, skill_type, profile_name,
                      operation, results, phase, name=None,
                      columns_to_remove=None, target_guid=None, column_gap=None,
                      target_obj_id=None):
    """Single entry point that wraps import_tml + import_status + verify_change_applied
    and writes the right entry to `results`. Use this at every Step 9 call site.

    Returns one of: "SUCCESS", "SUCCESS_WITH_WARNING", "FAIL_VERIFIED",
    "FAIL_SILENT" (api=OK but change didn't apply).
    """
    resp = import_tml(tml_dict, guid, profile_name)
    api_ok, api_err = import_status(resp)
    verified, verify_detail = verify_change_applied(
        guid, skill_type, profile_name,
        operation=operation,
        columns_to_remove=columns_to_remove,
        target_guid=target_guid, column_gap=column_gap,
        target_obj_id=target_obj_id,
    )

    label = name or guid
    record = {"guid": guid, "name": label, "type": skill_type, "phase": phase,
              "api_status": "OK" if api_ok else "ERROR",
              "api_error": None if api_ok else api_err,
              "verified": verified, "verify_detail": verify_detail}

    if api_ok and verified:
        results["succeeded"].append(record)
        return "SUCCESS"
    if (not api_ok) and verified:
        # TS lied — change was applied despite the error. Log + treat as success.
        record["warning"] = ("api returned ERROR but verification confirms the "
                             "change applied (open-item #15)")
        results["succeeded"].append(record)
        print(f"  ⚠ {skill_type} {label} — api=ERROR, verified=True. "
              f"Treating as success per open-item #15. err={api_err[:120]}")
        return "SUCCESS_WITH_WARNING"
    if api_ok and (not verified):
        record["error"] = f"api=OK but change not applied — {verify_detail}"
        results["failed"].append(record)
        print(f"  ✗ {skill_type} {label} — silent rejection. {verify_detail}")
        return "FAIL_SILENT"
    # Both ERROR and not verified — true failure
    record["error"] = f"{api_err}  ({verify_detail})"
    results["failed"].append(record)
    return "FAIL_VERIFIED"

REMOVE — source is a Model or Worksheet:

Join conditions that reference removed columns MUST also be updated — ThoughtSpot will reject the import otherwise (see open-items.md #4). Use source_affected_joins identified in Step 4 to know which joins to remove.

updated_source = copy.deepcopy(source_export_item["tml"])
section = updated_source.get("model") or updated_source.get("worksheet")

# Collect formula IDs of any formula columns being removed
formula_ids_to_remove = {
    col.get("formula_id")
    for col in section.get("columns", [])
    if col.get("name") in columns_to_remove and col.get("formula_id")
}

section["columns"] = [
    c for c in section.get("columns", [])
    if c.get("name") not in columns_to_remove
]
section["formulas"] = [
    f for f in section.get("formulas", [])
    if f.get("id") not in formula_ids_to_remove
]

# Remove join conditions that reference the removed column(s)
# (must be done — cannot save a model with an orphaned join condition)
# model_tables uses `joins` (Scenario A referencing + Scenario B inline);
# `joins_with` only exists at the table/view level, never on model_tables.
for tbl in section.get("model_tables", []):
    tbl["joins"] = [
        j for j in tbl.get("joins", [])
        if not any(col in j.get("on", "") for col in columns_to_remove)
    ]

# Remove model-level filters that reference the removed column(s)
# Required by open-items.md #12 — TS rejects the import with error_code 14518
# ("Invalid filter column") if a filter still references a deleted column.
section["filters"] = [
    f for f in section.get("filters", [])
    if not any(c in columns_to_remove for c in f.get("column", []))
]

REMOVE — source is a connection Table:

updated_source = copy.deepcopy(source_export_item["tml"])
table_section  = updated_source.get("table", {})
table_section["columns"] = [
    c for c in table_section.get("columns", [])
    if c.get("name") not in columns_to_remove
]

Import the source object — but only after the source-drift hard-stop check above has passed. The pre-check uses assert_no_drift_or_skip and aborts the entire run on drift; if we get here, the source is still at the timestamp we scanned.

print(f"  Updating source: {source_name}...")
outcome = import_and_verify(
    updated_source, source_guid, source_type, profile_name,
    operation=operation, results=results, phase="source",
    name=source_name,
    columns_to_remove=columns_to_remove if operation == "REMOVE" else None,
    target_guid=target_guid if operation == "REPOINT" else None,
    column_gap=column_gap if operation == "REPOINT" else None,
    target_obj_id=target_obj_id if operation == "REPOINT" else None,
)
if outcome in ("FAIL_VERIFIED", "FAIL_SILENT"):
    print(f"  ✗ Source update failed ({outcome}). "
          f"No dependent objects will be updated. Backup is at {backup_dir}.")
    return
# SUCCESS or SUCCESS_WITH_WARNING — proceed to dependents
print(f"  ✓ {source_name} ({outcome})")

9c — Modify and import dependent objects

Search query helpers — mandatory for Answers and Views:

ThoughtSpot rejects the import of any object where search_query references a column that no longer exists. These sanitizers are not optional (see open-items.md #3).

import re

def sanitize_search_query(query_str, cols_to_remove):
    """Strip [col_name] tokens from a ThoughtSpot search_query string."""
    if not query_str:
        return query_str
    for col in cols_to_remove:
        query_str = re.sub(r'\s*\[' + re.escape(col) + r'\]\s*', ' ', query_str)
    return query_str.strip()

def rename_in_search_query(query_str, old_name, new_name):
    """Rename a [col_name] token in a ThoughtSpot search_query string."""
    if not query_str:
        return query_str
    return re.sub(r'\[' + re.escape(old_name) + r'\]', f'[{new_name}]', query_str)

For REMOVE — strip a column from an Answer or Liveboard viz:

def convert_answer_to_table(answer_dict):
    """
    Switch an answer to TABLE_MODE so it remains valid after a chart-axis column is stripped.
    Sets `display_mode = "TABLE_MODE"`. Used for REMOVE_CHART → CONVERT_TO_TABLE fixes.
    Skipping the conversion is not an option for column-removal cleanup: TS will reject the
    source change at error 14544 if the column is still referenced anywhere.
    """
    answer_dict["display_mode"] = "TABLE_MODE"
    return answer_dict

def remove_columns_from_answer(answer_dict, cols_to_remove):
    """Remove column references from an answer dict (the answer: section, not full TML)."""
    a = answer_dict

    # search_query — MUST be sanitized or ThoughtSpot will reject the import (open-items.md #3)
    if a.get("search_query"):
        a["search_query"] = sanitize_search_query(a["search_query"], cols_to_remove)

    # answer_columns[]
    a["answer_columns"] = [
        c for c in a.get("answer_columns", [])
        if c.get("name") not in cols_to_remove
    ]

    # table view: ordered_column_ids and table_columns
    tbl = a.get("table", {})
    if tbl.get("ordered_column_ids"):
        tbl["ordered_column_ids"] = [
            c for c in tbl["ordered_column_ids"] if c not in cols_to_remove
        ]
    tbl["table_columns"] = [
        c for c in tbl.get("table_columns", [])
        if c.get("column_id") not in cols_to_remove
    ]

    # chart view: chart_columns and axis_configs
    # Only strip color/size/shape bindings — x/y axis removal requires removing the
    # entire chart visualization. classify_chart_role() identifies those as REMOVE_CHART
    # and they are handled separately in Step 6 decisions and the liveboard loop below.
    chart = a.get("chart", {})
    chart["chart_columns"] = [
        c for c in chart.get("chart_columns", [])
        if c.get("column_id") not in cols_to_remove
    ]
    for axis in chart.get("axis_configs", []):
        for key in ("color", "size", "shape"):  # x/y excluded — see REMOVE_CHART path
            if key in axis and isinstance(axis[key], list):
                axis[key] = [v for v in axis[key] if v not in cols_to_remove]

    # formulas that reference the removed column
    formula_ids_to_remove = {
        f["id"] for f in a.get("formulas", [])
        if any(col in f.get("expr", "") for col in cols_to_remove)
    }
    if formula_ids_to_remove:
        a["formulas"]       = [f for f in a.get("formulas", [])
                                if f["id"] not in formula_ids_to_remove]
        a["answer_columns"] = [c for c in a.get("answer_columns", [])
                                if c.get("formula_id") not in formula_ids_to_remove
                                and c.get("name") not in
                                    {f["name"] for f in a.get("formulas", [])
                                     if f["id"] in formula_ids_to_remove}]

    # answer-level cohorts (sets) whose anchor_column_id is being removed
    # The set's display name also appears in answer_columns[] and may be in search_query
    set_names_to_remove = {
        c["name"] for c in a.get("cohorts", [])
        if c.get("config", {}).get("anchor_column_id") in cols_to_remove
    }
    if set_names_to_remove:
        a["cohorts"] = [
            c for c in a.get("cohorts", [])
            if c["name"] not in set_names_to_remove
        ]
        a["answer_columns"] = [
            c for c in a.get("answer_columns", [])
            if c.get("name") not in set_names_to_remove
        ]
        if a.get("search_query"):
            a["search_query"] = sanitize_search_query(
                a["search_query"], list(set_names_to_remove)
            )

    return a

For REPOINT — change the data source reference and remove gap columns:

The repoint helpers use obj_id-first matching with fqn fallback. When the TML was exported with --include-obj-id --include-obj-id-ref, obj_id fields are present and the helpers match on those. This avoids VERSION_CONFLICT (error 14009) on builds that track content versions via obj_id. When obj_id is absent (older builds), the helpers fall back to fqn matching.

def repoint_answer(answer_dict, source_guid, target_guid, target_name, column_gap,
                   *, source_obj_id=None, target_obj_id=None):
    a = answer_dict

    for tbl in a.get("tables", []):
        matched = False
        if source_obj_id and tbl.get("obj_id") == source_obj_id:
            matched = True
        elif tbl.get("fqn") == source_guid:
            matched = True

        if matched:
            if target_obj_id:
                tbl["obj_id"] = target_obj_id
                tbl.pop("fqn", None)
            else:
                tbl["fqn"] = target_guid
                tbl.pop("obj_id", None)
            tbl["name"] = target_name
            tbl["id"]   = target_name

    if column_gap:
        a = remove_columns_from_answer(a, column_gap)

    return a
def repoint_view(view_dict, source_guid, target_guid, target_name, column_gap,
                 *, source_obj_id=None, target_obj_id=None):
    """Update a View TML body to point at target_guid instead of source_guid.

    Prefers obj_id matching when available (avoids VERSION_CONFLICT / error 14009).
    Falls back to fqn when obj_id is absent.
    """
    v = view_dict
    old_name = None

    for tbl in v.get("tables", []):
        matched = False
        if source_obj_id and tbl.get("obj_id") == source_obj_id:
            matched = True
        elif tbl.get("fqn") == source_guid:
            matched = True

        if matched:
            old_name = tbl.get("name")
            if tbl.get("id") == old_name:
                tbl["id"] = target_name
            if target_obj_id:
                tbl["obj_id"] = target_obj_id
                tbl.pop("fqn", None)
            else:
                tbl["fqn"] = target_guid
                tbl.pop("obj_id", None)
            tbl["name"] = target_name

    if old_name and old_name != target_name:
        for tp in v.get("table_paths", []):
            if tp.get("table") == old_name:
                tp["table"] = target_name
        for j in v.get("joins", []):
            if j.get("source") == old_name:
                j["source"] = target_name
            if j.get("destination") == old_name:
                j["destination"] = target_name

    if column_gap:
        v = remove_columns_from_view(v, column_gap)

    return v
def repoint_model(model_dict, source_name, target_name, column_gap,
                  *, source_obj_id=None, target_obj_id=None,
                  source_guid=None, target_guid=None):
    """Repoint a Model's model_tables entry from source to target.

    Updates model_tables obj_id/fqn/name, joins with/on clauses,
    column_id prefixes, formula expressions, and description.
    Prefers obj_id when available; falls back to fqn.
    """
    m = model_dict

    for tbl in m.get("model_tables", []):
        matched = False
        if source_obj_id and tbl.get("obj_id") == source_obj_id:
            matched = True
        elif source_guid and tbl.get("fqn") == source_guid:
            matched = True
        elif tbl.get("name") == source_name:
            matched = True

        if matched:
            tbl["name"] = target_name
            if target_obj_id:
                tbl["obj_id"] = target_obj_id
                tbl.pop("fqn", None)
            elif target_guid:
                tbl["fqn"] = target_guid
                tbl.pop("obj_id", None)

        for join_key in ("joins", "joins_with"):
            for j in tbl.get(join_key, []):
                if j.get("with") == source_name:
                    j["with"] = target_name
                on_clause = j.get("on", "")
                if f"[{source_name}::" in on_clause:
                    j["on"] = on_clause.replace(
                        f"[{source_name}::", f"[{target_name}::")

    for col in m.get("columns", []):
        cid = col.get("column_id", "")
        if cid.startswith(f"{source_name}::"):
            col["column_id"] = cid.replace(
                f"{source_name}::", f"{target_name}::", 1)

    for formula in m.get("formulas", []):
        expr = formula.get("expr", "")
        if f"[{source_name}::" in expr:
            formula["expr"] = expr.replace(
                f"[{source_name}::", f"[{target_name}::")

    desc = m.get("description", "")
    if source_name in desc:
        m["description"] = desc.replace(source_name, target_name)

    if column_gap:
        m = fix_model(m, column_gap)

    return m

Apply to Answers:

for dep in [d for d in objects_to_fix if d["type"] == "ANSWER"]:
    # Drift check — skip this Answer if it's been edited since the Step-4 scan
    if not assert_no_drift_or_skip(dep["guid"], dep["type"], profile_name,
                                   modified_at_scan.get(dep["guid"], 0),
                                   results, phase="fix", name=dep["name"]):
        continue

    updated = copy.deepcopy(dep["tml"])
    answer  = updated.get("answer", {})

    if operation == "REMOVE":
        # Standalone Answers with action == "REMOVE_CHART" are auto-converted to TABLE_MODE
        # before stripping the column. This preserves the Answer (no longer "manual only").
        if dep.get("action") == "REMOVE_CHART":
            answer = convert_answer_to_table(answer)
        answer = remove_columns_from_answer(answer, columns_to_remove)
    elif operation == "REPOINT":
        answer = repoint_answer(answer, source_guid, target_guid, target_name, column_gap,
                                source_obj_id=source_obj_id, target_obj_id=target_obj_id)

    updated["answer"] = answer
    import_and_verify(
        updated, dep["guid"], dep["type"], profile_name,
        operation=operation, results=results, phase="fix", name=dep["name"],
        columns_to_remove=columns_to_remove if operation == "REMOVE" else None,
        target_guid=target_guid if operation == "REPOINT" else None,
        column_gap=column_gap if operation == "REPOINT" else None,
        target_obj_id=target_obj_id if operation == "REPOINT" else None,
    )

Apply to Liveboards:

Liveboards embed full answer definitions in visualizations[].answer. Apply the same helper functions to each visualization's answer section that references {source_guid}. Check against [../../shared/schemas/thoughtspot-liveboard-tml.md] for the exact field layout before modifying.

for dep in [d for d in objects_to_fix if d["type"] == "LIVEBOARD"]:
    # Drift check (Fix #3)
    if not assert_no_drift_or_skip(dep["guid"], dep["type"], profile_name,
                                   modified_at_scan.get(dep["guid"], 0),
                                   results, phase="fix", name=dep["name"]):
        continue

    updated   = copy.deepcopy(dep["tml"])
    liveboard = updated.get("liveboard", {})

    # Collect vizzes the user chose to remove entirely (REMOVE_CHART + "A" decision)
    vizes_to_remove = set()

    for viz in liveboard.get("visualizations", []):
        viz_id = viz.get("id", "")
        answer = viz.get("answer", {})
        tables = answer.get("tables", [])
        if not any(t.get("fqn") == source_guid for t in tables):
            continue  # this viz doesn't use the source — skip

        if operation == "REMOVE":
            viz_action = dep.get("viz_actions", {}).get(viz_id, "REMOVE_COLUMN")
            if viz_action == "REMOVE_CHART":
                # Two valid decisions: CONVERT_TO_TABLE (default) or REMOVE.
                # SKIP is not offered — leaving the column referenced causes TS to reject
                # the source change at error 14544.
                decision = dep.get("viz_remove_decisions", {}).get(viz_id, "CONVERT_TO_TABLE")
                if decision == "REMOVE":
                    vizes_to_remove.add(viz_id)
                    continue
                # CONVERT_TO_TABLE: switch this viz's display_mode and strip the column
                answer = convert_answer_to_table(answer)
            answer = remove_columns_from_answer(answer, columns_to_remove)
        elif operation == "REPOINT":
            answer = repoint_answer(answer, source_guid, target_guid, target_name, column_gap)

        viz["answer"] = answer

    # Remove entire chart visualizations the user chose to delete
    if vizes_to_remove:
        liveboard["visualizations"] = [
            v for v in liveboard.get("visualizations", [])
            if v.get("id") not in vizes_to_remove
        ]

    # Liveboard-level filter updates (REMOVE only)
    if operation == "REMOVE":
        # Remove filter entries for removed columns (Rule 1)
        # A filter whose column list is fully emptied is dropped entirely
        updated_filters = []
        for filt in liveboard.get("filters", []):
            new_cols = [c for c in filt.get("column", []) if c not in columns_to_remove]
            if new_cols:
                filt["column"] = new_cols
                updated_filters.append(filt)
        liveboard["filters"] = updated_filters

    import_and_verify(
        updated, dep["guid"], dep["type"], profile_name,
        operation=operation, results=results, phase="fix", name=dep["name"],
        columns_to_remove=columns_to_remove if operation == "REMOVE" else None,
        target_guid=target_guid if operation == "REPOINT" else None,
        column_gap=column_gap if operation == "REPOINT" else None,
        target_obj_id=target_obj_id if operation == "REPOINT" else None,
    )

Apply to Views:

Views have view_columns[], formulas[], joins[], and search_query — all need updating when a referenced column is removed or renamed. See ../../shared/schemas/thoughtspot-view-tml.md for the full field layout.

def remove_columns_from_view(view_dict, cols_to_remove):
    """Remove column references from a View TML dict (the view: section)."""
    v = view_dict

    # search_query — MUST be sanitized (same rule as Answers — open-items.md #3)
    if v.get("search_query"):
        v["search_query"] = sanitize_search_query(v["search_query"], cols_to_remove)

    # view_columns[] — remove entries whose column_id references the removed column
    v["view_columns"] = [
        c for c in v.get("view_columns", [])
        if not any(col in c.get("column_id", "") for col in cols_to_remove)
        and c.get("name") not in cols_to_remove
    ]

    # formulas[] — remove formulas whose expr references the removed column
    formula_ids_to_remove = {
        f["id"] for f in v.get("formulas", [])
        if any(col in f.get("expr", "") for col in cols_to_remove)
    }
    if formula_ids_to_remove:
        v["formulas"]     = [f for f in v.get("formulas", []) if f["id"] not in formula_ids_to_remove]
        v["view_columns"] = [c for c in v.get("view_columns", []) if c.get("column_id") not in formula_ids_to_remove]

    # joins[] — remove join conditions that reference the column in the ON expression
    v["joins"] = [
        j for j in v.get("joins", [])
        if not any(col in j.get("on", "") for col in cols_to_remove)
    ]

    return v

Apply to Views in the update loop:

for dep in [d for d in objects_to_fix if d["type"] == "VIEW"]:
    # Drift check (Fix #3)
    if not assert_no_drift_or_skip(dep["guid"], dep["type"], profile_name,
                                   modified_at_scan.get(dep["guid"], 0),
                                   results, phase="fix", name=dep["name"]):
        continue

    updated = copy.deepcopy(dep["tml"])
    view    = updated.get("view", {})

    if   operation == "REMOVE":  view = remove_columns_from_view(view, columns_to_remove)
    elif operation == "REPOINT": view = repoint_view(view, source_guid, target_guid, target_name, column_gap,
                                                       source_obj_id=source_obj_id, target_obj_id=target_obj_id)

    updated["view"] = view
    import_and_verify(
        updated, dep["guid"], dep["type"], profile_name,
        operation=operation, results=results, phase="fix", name=dep["name"],
        columns_to_remove=columns_to_remove if operation == "REMOVE" else None,
        target_guid=target_guid if operation == "REPOINT" else None,
        column_gap=column_gap if operation == "REPOINT" else None,
        target_obj_id=target_obj_id if operation == "REPOINT" else None,
    )

Apply to Feedback:

Feedback items share the Model's GUID. Export the updated Model's --associated TML to get the current feedback state, then strip stale entries:

for dep in [d for d in objects_to_fix if d["type"] == "FEEDBACK"]:
    # Drift check (Fix #3) — feedback shares the Model GUID, so we re-check
    # the Model's timestamp; a moved Model invalidates the feedback we exported
    if not assert_no_drift_or_skip(dep["guid"], "MODEL", profile_name,
                                   modified_at_scan.get(dep["guid"], 0),
                                   results, phase="fix", name=dep["name"]):
        continue

    updated   = copy.deepcopy(dep["tml"])
    feedback  = updated.get("nls_feedback", {})
    target    = columns_to_remove

    entries = feedback.get("feedback", [])
    if operation == "REMOVE":
        entries = [
            e for e in entries
            if not any(col in json.dumps(e) for col in target)
        ]
    feedback["feedback"] = entries
    updated["nls_feedback"] = feedback
    import_and_verify(
        updated, dep["guid"], "MODEL", profile_name,
        operation=operation, results=results, phase="fix", name=dep["name"],
        columns_to_remove=columns_to_remove if operation == "REMOVE" else None,
    )

Apply to dependent Models:

Dependent Models may join on the renamed/removed column. Apply the same removal/rename logic as Step 9b (source) — including join condition removal AND model-level filter removal — to each dependent Model's TML. Both are required to avoid TS rejection (open-items.md #4 and #12).

def fix_model(m, cols_to_remove):
    """Strip a column from a model TML body. Returns the same dict mutated."""
    # Strip from columns[]
    formula_ids_to_remove = {
        c.get("formula_id") for c in m.get("columns", [])
        if c.get("name") in cols_to_remove and c.get("formula_id")
    }
    m["columns"] = [c for c in m.get("columns", []) if c.get("name") not in cols_to_remove]
    m["formulas"] = [f for f in m.get("formulas", []) if f.get("id") not in formula_ids_to_remove]

    # Strip join conditions referencing the column (open-items.md #4)
    # model_tables uses `joins` (Scenario A referencing + Scenario B inline);
    # `joins_with` only exists at the table/view level, never on model_tables.
    for tbl in m.get("model_tables", []):
        tbl["joins"] = [
            j for j in tbl.get("joins", [])
            if not any(c in j.get("on", "") for c in cols_to_remove)
        ]

    # Strip model-level filters referencing the column (open-items.md #12)
    # TS rejects the import with error_code 14518 if a filter references a missing column.
    m["filters"] = [
        f for f in m.get("filters", [])
        if not any(c in cols_to_remove for c in f.get("column", []))
    ]

    return m

Track all results:

results = {"succeeded": [], "failed": [], "skipped": []}
# Add source to succeeded (already confirmed above)
results["succeeded"].append({"name": source_name, "type": source_type, "guid": source_guid})

for dep in objects_to_fix:
    # ... apply change, call import_tml, call import_status ...
    if ok:
        results["succeeded"].append(dep)
    else:
        results["failed"].append({**dep, "error": err})
        print(f"  ✗ {dep['name']} — {err}")

9d — Process reusable Sets

After all dependent objects have been updated, process the affected_sets list.

Read references/open-items.md #11 before implementing the delete step — the ts metadata delete command for Sets is unverified.

For REMOVE — delete sets that operated on the removed column:

Sets with action == "DELETE_SAFE" have no consumers and can be deleted immediately. Sets with action == "DELETE_AFTER_DEPENDENTS" had consumers that should have been updated in 9c — but only delete the Set if every consumer fix actually succeeded. Deleting a Set whose consumer fixes failed leaves the consumers pointing at a missing Set GUID (silent breakage). Skip the Set delete in that case and surface in the Change Report so the user can investigate.

# Index 9c results by guid for O(1) lookup
fix_results_by_guid = {r.get("guid"): r for r in results["succeeded"]
                       if r.get("phase") in ("fix", None)}
fix_failed_guids = {r["guid"] for r in results["failed"]
                    if r.get("phase") in ("fix", None) and r.get("guid")}

for s in affected_sets:
    if s["action"] not in ("DELETE_SAFE", "DELETE_AFTER_DEPENDENTS"):
        continue

    # GUARD (Fix #2, 2026-04-26): if any consumer fix failed in 9c, do NOT delete the
    # Set. Deleting it would silently break the failed consumer (now points at a
    # missing GUID with no chance for the skill to detect or restore).
    consumer_guids = set(s.get("in_use_by", []))
    failed_consumers = consumer_guids & fix_failed_guids
    if failed_consumers:
        msg = (f"skipped — {len(failed_consumers)} consumer fix(es) failed in 9c; "
               f"deleting the Set would dangle those consumers. "
               f"Failed consumer GUIDs: {sorted(failed_consumers)}")
        print(f"  ⚠ Skip delete '{s['name']}' ({s['guid']}): {msg}")
        results["skipped"].append({"name": s["name"], "type": "SET", "guid": s["guid"],
                                   "phase": "set_delete", "reason": msg})
        continue

    print(f"  Deleting set '{s['name']}' ({s['guid']})...")
    result = subprocess.run(
        ["bash", "-c",
         f"source ~/.zshenv && ts metadata delete {s['guid']} "
         f"--profile '{profile_name}'"],
        capture_output=True, text=True,
    )
    if result.returncode == 0:
        print(f"  ✓ Deleted set: {s['name']}")
        results["succeeded"].append({"name": s["name"], "type": "SET", "guid": s["guid"],
                                     "phase": "set_delete", "action": "deleted"})
    else:
        err = result.stderr.strip() or result.stdout.strip()
        print(f"  ✗ Failed to delete set '{s['name']}': {err}")
        results["failed"].append({"name": s["name"], "type": "SET", "guid": s["guid"],
                                  "phase": "set_delete", "error": err})

Step 10 — Change Report

Show a full summary of what succeeded, what failed, and what was skipped:

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 CHANGE REPORT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Operation:  {operation_description}
Source:     {source_name} ({source_type})

 ✓ Updated ({N}):
   - {source_name} (source — {source_type})
   - Q4 Sales Overview        (LIVEBOARD)
   - Customer 360 Model       (MODEL)

 ✗ Deleted ({D}):  ⚠  rollback re-imports as NEW GUIDs — see warning below
   - Sales by Region Q4       (ANSWER)
   - Historical Comparison    (ANSWER)

 ✗ Failed ({M}):
   - Outbound Trends Model    (MODEL)
     Error: column 'Legacy Region' appears in a join condition —
            join conditions cannot be modified by TML import on this instance.
     Backup: {backup_dir}/model_{guid}_Outbound_Trends_Model.json

 ─ Skipped ({K}):
   - Old Trend Liveboard      (LIVEBOARD) — not selected
   - Q3 Sales Liveboard       (LIVEBOARD) — DRIFT_DETECTED: modified at scan was
                              1714123456000, now 1714234567890. Re-run the skill
                              to refresh the impact plan against the current state.
   - Customer Segment Set     (SET) — set_cascade: 1 consumer fix(es) failed in 9c;
                              deleting the Set would dangle those consumers.

Backup location: {backup_dir}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

If any objects were deleted, append:

  ⚠  Deleted objects can be re-imported from {backup_dir} but will receive NEW GUIDs.
     Other objects (visible or not) that referenced the original GUIDs will not be
     automatically reconnected. Plan manual reattachment if needed.

For each failed object, provide an actionable next step (manual UI edit, different approach, or a note that the backup is ready to restore if needed).


Step 11 — Rollback Option

After the change report, ask:

Would you like to roll back any changes? (Y / N):

If N, proceed to Cleanup.

If Y:

Which objects would you like to roll back?

  A  Roll back ALL successfully changed objects (updates AND deletes)
  U  Updated objects only — restore original TML for in-place updates
  D  Deleted objects only — re-import deleted objects from backup (NEW GUIDs)
  S  Select specific objects

Enter A / U / D / S:

If S, show a numbered list of all changed objects (both updated and deleted) and let the user pick.

For any deleted object the user chose to roll back, show this caveat once and require acknowledgment before proceeding:

⚠  Restoring deleted objects:

  Deleted objects will be re-imported from the TML backup. The new objects will receive
  NEW GUIDs assigned by ThoughtSpot. Any other object that referenced the deleted object's
  ORIGINAL GUID will remain broken — including objects you cannot see.

  Continue? (Y / N):

Rollback implementation:

import json

def rollback_objects(backup_dir, guids_to_rollback, profile_name):
    manifest_path = os.path.join(backup_dir, "manifest.json")
    with open(manifest_path) as f:
        manifest = json.load(f)

    restore_policy = {
        "table":    "PARTIAL",
        "model":    "ALL_OR_NONE",
        "answer":   "ALL_OR_NONE",
        "liveboard": "ALL_OR_NONE",
    }

    rollback_results = {"succeeded": [], "failed": [], "new_guids": {}}

    # Restore dependents before source (reverse dependency order)
    entries = [e for e in manifest["objects"] if e["guid"] in guids_to_rollback]
    entries.sort(key=lambda e: 0 if e["type"] == "table" else 1)

    for entry in reversed(entries):
        with open(entry["backup_file"]) as f:
            backup_item = json.load(f)

        policy = restore_policy.get(backup_item["type"], "ALL_OR_NONE")
        was_deleted = entry.get("intent") == "DELETE"

        if was_deleted:
            # Re-import as NEW object — strip guid from TML so TS assigns a new one
            tml_dict = dict(backup_item["tml"])
            tml_dict.pop("guid", None)
            # Use create-new policy: object no longer exists at original GUID
            resp = import_tml(tml_dict, None, profile_name, policy=policy, create_new=True)
        else:
            resp = import_tml(backup_item["tml"], entry["guid"], profile_name, policy=policy)

        ok, err = import_status(resp)

        if ok:
            new_guid = (resp.get("object", [{}])[0]
                            .get("response", {}).get("header", {}).get("id_guid"))
            label = entry["name"] + (f" (new GUID: {new_guid})" if was_deleted and new_guid else "")
            rollback_results["succeeded"].append(label)
            if was_deleted and new_guid:
                rollback_results["new_guids"][entry["guid"]] = new_guid
            print(f"  ✓ Rolled back: {label}")
        else:
            rollback_results["failed"].append({"name": entry["name"], "error": err})
            print(f"  ✗ Rollback failed: {entry['name']} — {err}")

    return rollback_results

Note: import_tml here is assumed to support a create_new=True flag that removes --no-create-new from the underlying ts tml import call when restoring a deleted object. Update the helper in §9b accordingly: when create_new=True, drop the --no-create-new flag.

Show rollback results. If any deletes were restored, surface the GUID mapping table (old_guid → new_guid) so the user can update any external references manually.

If any rollbacks failed, tell the user the backup file path for manual restoration.


Error Handling

Symptom Action
ts auth whoami returns 401 Token expired — follow the refresh steps in /ts-profile-thoughtspot
Import returns 403 / UNAUTHORIZED User lacks edit access on the object — skip it, note in report; backup available
Import: column_id not found in dependent Column reference format may differ from display name — see open-items.md #2
Import: search_query validation error The search_query still references a removed column — sanitize_search_query() may have missed a token. Check for bracket-format variants like [TABLE::col] and strip manually
Import: join condition references removed column source_affected_joins detection in Step 4 may have missed a join — check model_tables[].joins_with[].on for the column name and remove the join entry manually
Liveboard import fails after REMOVE_CHART decision Viz ID in viz_remove_decisions may not match visualizations[].id — print dep["viz_actions"] keys and compare to viz.get("id") in the liveboard TML
Import: model already exists with same name guid: may be missing or mis-placed — check the YAML serialization in import_tml()
Import creates a new object instead of updating --no-create-new flag missing — add it; delete the duplicate via ts metadata delete
Dependency scan returns 0 results unexpectedly Batch export may have timed out — reduce batch_size to 5 and re-run Step 4
View TML import fails after column removal view_columns[].column_id may use TABLE_PATH::col format — check that column path ID prefix is retained in the update
Feedback TML import fails Feedback shares the Model GUID — ensure guid: at document root matches the Model's GUID, not a separate feedback GUID
Liveboard TML import fails validation Chart column IDs may differ from display names — see open-items.md #2
Rollback import fails Open the backup JSON manually; the tml field contains the original YAML — import via the ThoughtSpot UI TML editor
pyyaml not installed pip install pyyaml

Cleanup

The backup directory at {backup_dir} is kept after the skill completes to enable rollback. Remind the user:

Backup retained at: {backup_dir}
Remove when you are confident the changes are correct: rm -rf {backup_dir}

Remove any temp import files created during the session:

rm -f /tmp/ts_dep_*.yaml

Changelog

Version Date Summary
1.1.0 2026-06-09 Repoint: obj_id-based references with fqn fallback. Step 9 now detects obj_id support via export_options flags and prefers obj_id-based model_table references over fqn to avoid VERSION_CONFLICT (error 14009) on some TS builds (open-item #23). New repoint_model() function handles model_tables obj_id/name/joins/column_id/formula swaps. repoint_answer() and repoint_view() also support obj_id-first matching. Backup location prompt — Step 7 now asks the user where to save backups (current directory, /tmp, or custom path) instead of hardcoding /tmp. Requires ts-cli v0.8.0 (--include-obj-id, --include-obj-id-ref, --no-guid flags).
1.0.0 2026-04-27 Initial release. Three modes: Audit (read-only blast-radius report), Remove (drop one or more columns and clean up dependents), Repoint (redirect Answers/Liveboards to a different Model/View/Table with column-gap detection). Step 4 dep walk uses the v2 metadata/search dependents API (ts metadata dependents) with alias propagation — per-Model/View aliases for the target column are extracted at scan time so Answers/Liveboards/Sets that reference renamed columns (e.g. ZIPCODECustomer Zipcode) are caught. STOP-condition handling for inaccessible dependents (v2 hasInaccessibleDependents flag), source-table RLS rules, model-level join conditions referencing the column (open-items.md #4), model-level filters (#12), and chart-axis conflicts. Step 5 impact report drives the Scan Coverage block from the canonical references/dependency-types.md status table via references/build_coverage.py. Steps 7 / 8 require typed "DELETE" / "ACCEPT INCOMPLETE IMPACT" confirmations and take a TML backup before any change is applied. Step 9 wraps every import with post-import verification (import_and_verify) — re-exports the TML and confirms the change actually applied, since TS sometimes returns status_code=ERROR while applying the change anyway (open-item #15). Object-version drift detection captures metadata_header.modified at scan time and aborts the per-object change (or the entire run, if the source drifted) when the timestamp moves between scan and apply. Step 11 supports rollback for both updates and deletes. RENAME mode is intentionally not supported on this build — see the rationale at the top of SKILL.md.
Install via CLI
npx skills add https://github.com/thoughtspot/thoughtspot-agent-skills --skill ts-dependency-manager
Repository Details
star Stars 1
call_split Forks 2
navigation Branch main
article Path SKILL.md
More from Creator