@josephyan/qingflow-cli 0.2.0-beta.67 → 0.2.0-beta.68

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.
@@ -620,7 +620,8 @@ class RecordTools(ToolBase):
620
620
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
621
621
 
622
622
  def runner(session_profile, context):
623
- schema = self._get_form_schema(profile, context, app_key, force_refresh=False)
623
+ self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
624
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
624
625
  question_relations = _collect_question_relations(schema)
625
626
  index = _build_applicant_top_level_field_index(schema)
626
627
  runtime_linked_field_ids = _collect_linked_required_field_ids(question_relations)
@@ -754,7 +755,8 @@ class RecordTools(ToolBase):
754
755
 
755
756
  def runner(session_profile, context):
756
757
  request_route = self._request_route_payload(context)
757
- app_schema = self._get_form_schema(profile, context, app_key, force_refresh=False)
758
+ self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
759
+ app_schema = self._get_form_schema(profile, context, app_key, force_refresh=True)
758
760
  question_relations = _collect_question_relations(app_schema)
759
761
  app_index = _build_applicant_top_level_field_index(app_schema)
760
762
  linked_field_ids = _collect_linked_required_field_ids(question_relations)
@@ -822,7 +824,7 @@ class RecordTools(ToolBase):
822
824
  context,
823
825
  app_key,
824
826
  candidate,
825
- force_refresh=False,
827
+ force_refresh=True,
826
828
  )
827
829
  index = cast(FieldIndex, browse_scope["index"])
828
830
  browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
@@ -1671,6 +1673,34 @@ class RecordTools(ToolBase):
1671
1673
  cast(list[JSONValue], answer_list),
1672
1674
  selected_fields,
1673
1675
  )
1676
+ if self._record_get_needs_schema_refresh(
1677
+ answer_list=cast(list[JSONValue], answer_list),
1678
+ selected_fields=selected_fields,
1679
+ record=row,
1680
+ normalized_record=normalized_record,
1681
+ ):
1682
+ self._clear_record_schema_caches(
1683
+ profile=profile,
1684
+ app_key=app_key,
1685
+ resolved_view=resolved_view,
1686
+ clear_view_caches=True,
1687
+ )
1688
+ index = self._get_browse_field_index(profile, context, app_key, resolved_view, force_refresh=True)
1689
+ selected_fields = (
1690
+ self._resolve_select_columns(
1691
+ normalized_columns,
1692
+ index,
1693
+ max_columns=len(normalized_columns),
1694
+ default_limit=MAX_RECORD_COLUMN_LIMIT,
1695
+ )
1696
+ if normalized_columns
1697
+ else list(index.by_id.values())
1698
+ )
1699
+ row = _build_flat_row(cast(list[JSONValue], answer_list), selected_fields, apply_id=record_id)
1700
+ normalized_record, normalized_ambiguous_fields = _build_normalized_row_from_answers(
1701
+ cast(list[JSONValue], answer_list),
1702
+ selected_fields,
1703
+ )
1674
1704
  warnings: list[JSONObject] = []
1675
1705
  warnings.extend(compatibility_warnings)
1676
1706
  warnings.extend(_view_filter_trust_warnings(resolved_view))
@@ -1735,6 +1765,7 @@ class RecordTools(ToolBase):
1735
1765
  view_key=None,
1736
1766
  view_name=None,
1737
1767
  )
1768
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
1738
1769
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
1739
1770
  normalized_payload: JSONObject = self._record_write_normalized_payload(
1740
1771
  operation="insert",
@@ -1761,7 +1792,7 @@ class RecordTools(ToolBase):
1761
1792
  fields={},
1762
1793
  submit_type=submit_type_value,
1763
1794
  verify_write=verify_write,
1764
- force_refresh_form=False,
1795
+ force_refresh_form=preflight_used_force_refresh,
1765
1796
  )
1766
1797
  except QingflowApiError as exc:
1767
1798
  self._raise_record_write_permission_error(
@@ -1805,6 +1836,7 @@ class RecordTools(ToolBase):
1805
1836
  fields=cast(JSONObject, fields or {}),
1806
1837
  force_refresh_form=False,
1807
1838
  )
1839
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
1808
1840
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
1809
1841
  normalized_payload = self._record_write_normalized_payload(
1810
1842
  operation="update",
@@ -1832,7 +1864,7 @@ class RecordTools(ToolBase):
1832
1864
  fields={},
1833
1865
  role=1,
1834
1866
  verify_write=verify_write,
1835
- force_refresh_form=False,
1867
+ force_refresh_form=preflight_used_force_refresh,
1836
1868
  )
1837
1869
  except QingflowApiError as exc:
1838
1870
  self._raise_record_write_permission_error(
@@ -1863,178 +1895,231 @@ class RecordTools(ToolBase):
1863
1895
  ) -> JSONObject:
1864
1896
  def runner(session_profile, context):
1865
1897
  request_route = self._request_route_payload(context)
1866
- try:
1867
- current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
1868
- except QingflowApiError:
1869
- return {
1870
- "profile": profile,
1871
- "ws_id": session_profile.selected_ws_id,
1872
- "ok": True,
1873
- "request_route": request_route,
1874
- "data": self._build_auto_view_blocked_preflight_data(
1875
- app_key=app_key,
1876
- record_id=record_id,
1877
- blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
1878
- warnings=[
1879
- "update preflight could not load the current record; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
1880
- ],
1881
- recommended_next_actions=[
1882
- "Retry after the record becomes readable in the current workspace/profile context.",
1883
- "Call record_update_schema_get to inspect the overall writable field set for this record after context access is restored.",
1884
- ],
1885
- view_probe_summary=[],
1886
- ),
1887
- }
1898
+ def build_once(*, effective_force_refresh: bool) -> JSONObject:
1899
+ try:
1900
+ current_answers = self._load_record_answers_for_preflight(context, app_key=app_key, apply_id=record_id)
1901
+ except QingflowApiError:
1902
+ return {
1903
+ "profile": profile,
1904
+ "ws_id": session_profile.selected_ws_id,
1905
+ "ok": True,
1906
+ "request_route": request_route,
1907
+ "data": self._build_auto_view_blocked_preflight_data(
1908
+ app_key=app_key,
1909
+ record_id=record_id,
1910
+ blockers=["CURRENT_RECORD_CONTEXT_UNAVAILABLE"],
1911
+ warnings=[
1912
+ "update preflight could not load the current record; automatic view selection was blocked to avoid resolving lookup fields against a partial patch."
1913
+ ],
1914
+ recommended_next_actions=[
1915
+ "Retry after the record becomes readable in the current workspace/profile context.",
1916
+ "Call record_update_schema_get to inspect the overall writable field set for this record after context access is restored.",
1917
+ ],
1918
+ view_probe_summary=[],
1919
+ ),
1920
+ }
1888
1921
 
1889
- candidate_routes = self._candidate_update_views(profile, context, app_key)
1890
- probe_summary: list[JSONObject] = []
1891
- matched_any = False
1892
- first_blocked_plan: JSONObject | None = None
1893
- first_confirmation_plan: JSONObject | None = None
1922
+ candidate_routes = self._candidate_update_views(profile, context, app_key)
1923
+ probe_summary: list[JSONObject] = []
1924
+ matched_any = False
1925
+ matched_routes: list[AccessibleViewRoute] = []
1926
+ first_blocked_plan: JSONObject | None = None
1927
+ first_confirmation_plan: JSONObject | None = None
1894
1928
 
1895
- for candidate in candidate_routes:
1896
- matched_record = self._record_matches_accessible_view(
1897
- context,
1898
- current_answers,
1899
- resolved_view=candidate,
1900
- )
1901
- if not matched_record:
1902
- probe_summary.append(
1903
- {
1904
- "view_id": candidate.view_id,
1905
- "name": candidate.name,
1906
- "kind": candidate.kind,
1907
- "matched_record": False,
1908
- "writable_field_titles": [],
1909
- "missing_field_titles": [],
1910
- "context_complete": True,
1911
- "selected": False,
1912
- }
1929
+ for candidate in candidate_routes:
1930
+ matched_record = self._record_matches_accessible_view(
1931
+ context,
1932
+ current_answers,
1933
+ resolved_view=candidate,
1913
1934
  )
1914
- continue
1935
+ if not matched_record:
1936
+ probe_summary.append(
1937
+ {
1938
+ "view_id": candidate.view_id,
1939
+ "name": candidate.name,
1940
+ "kind": candidate.kind,
1941
+ "matched_record": False,
1942
+ "writable_field_titles": [],
1943
+ "missing_field_titles": [],
1944
+ "context_complete": True,
1945
+ "selected": False,
1946
+ }
1947
+ )
1948
+ continue
1915
1949
 
1916
- matched_any = True
1917
- browse_scope = self._build_browse_write_scope(
1918
- profile,
1919
- context,
1920
- app_key,
1921
- candidate,
1922
- force_refresh=force_refresh_form,
1923
- )
1924
- browse_index = cast(FieldIndex, browse_scope["index"])
1925
- browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
1926
- candidate_titles = [
1927
- field.que_title
1928
- for field in self._schema_fields_for_mode(
1950
+ matched_any = True
1951
+ matched_routes.append(candidate)
1952
+ browse_scope = self._build_browse_write_scope(
1929
1953
  profile,
1930
1954
  context,
1931
1955
  app_key,
1932
- browse_index,
1933
- schema_mode="browse",
1934
- resolved_view=candidate,
1956
+ candidate,
1957
+ force_refresh=effective_force_refresh,
1935
1958
  )
1936
- if field.que_id in browse_writable_field_ids and field.que_title
1937
- ]
1938
- plan_data = self._build_record_write_preflight(
1959
+ browse_index = cast(FieldIndex, browse_scope["index"])
1960
+ browse_writable_field_ids = cast(set[int], browse_scope["writable_field_ids"])
1961
+ candidate_titles = [
1962
+ field.que_title
1963
+ for field in self._schema_fields_for_mode(
1964
+ profile,
1965
+ context,
1966
+ app_key,
1967
+ browse_index,
1968
+ schema_mode="browse",
1969
+ resolved_view=candidate,
1970
+ )
1971
+ if field.que_id in browse_writable_field_ids and field.que_title
1972
+ ]
1973
+ plan_data = self._build_record_write_preflight(
1974
+ profile=profile,
1975
+ context=context,
1976
+ operation="update",
1977
+ app_key=app_key,
1978
+ apply_id=record_id,
1979
+ answers=[],
1980
+ fields=fields,
1981
+ force_refresh_form=effective_force_refresh,
1982
+ view_id=candidate.view_id,
1983
+ list_type=None,
1984
+ view_key=None,
1985
+ view_name=None,
1986
+ existing_answers_override=current_answers,
1987
+ )
1988
+ readonly_entries = plan_data.get("validation", {}).get("readonly_or_system_fields", []) if isinstance(plan_data.get("validation"), dict) else []
1989
+ invalid_entries = plan_data.get("validation", {}).get("invalid_fields", []) if isinstance(plan_data.get("validation"), dict) else []
1990
+ missing_field_titles: list[str] = []
1991
+ for entry in readonly_entries:
1992
+ if not isinstance(entry, dict):
1993
+ continue
1994
+ title = _normalize_optional_text(entry.get("que_title"))
1995
+ if title and title not in missing_field_titles:
1996
+ missing_field_titles.append(title)
1997
+ for entry in invalid_entries:
1998
+ if not isinstance(entry, dict):
1999
+ continue
2000
+ if _normalize_optional_text(entry.get("error_code")) != "VIEW_SCOPE_FIELD_HIDDEN":
2001
+ continue
2002
+ field_payload = entry.get("field")
2003
+ if isinstance(field_payload, dict):
2004
+ title = _normalize_optional_text(field_payload.get("que_title"))
2005
+ if title and title not in missing_field_titles:
2006
+ missing_field_titles.append(title)
2007
+ candidate_summary: JSONObject = {
2008
+ "view_id": candidate.view_id,
2009
+ "name": candidate.name,
2010
+ "kind": candidate.kind,
2011
+ "matched_record": True,
2012
+ "writable_field_titles": candidate_titles,
2013
+ "missing_field_titles": missing_field_titles,
2014
+ "context_complete": True,
2015
+ "selected": False,
2016
+ }
2017
+ if plan_data.get("blockers"):
2018
+ confirmation_requests = plan_data.get("confirmation_requests")
2019
+ if (
2020
+ isinstance(confirmation_requests, list)
2021
+ and confirmation_requests
2022
+ and first_confirmation_plan is None
2023
+ ):
2024
+ selection = plan_data.get("selection")
2025
+ if not isinstance(selection, dict):
2026
+ selection = {}
2027
+ plan_data["selection"] = selection
2028
+ view_payload = selection.get("view")
2029
+ if not isinstance(view_payload, dict):
2030
+ view_payload = _accessible_view_payload(candidate)
2031
+ selection["view"] = view_payload
2032
+ view_payload["auto_selected"] = True
2033
+ view_payload["selection_source"] = "first_satisfying_accessible_view"
2034
+ candidate_summary["selected"] = True
2035
+ first_confirmation_plan = plan_data
2036
+ elif first_blocked_plan is None:
2037
+ first_blocked_plan = plan_data
2038
+ probe_summary.append(candidate_summary)
2039
+ continue
2040
+
2041
+ selection = plan_data.get("selection")
2042
+ if not isinstance(selection, dict):
2043
+ selection = {}
2044
+ plan_data["selection"] = selection
2045
+ view_payload = selection.get("view")
2046
+ if not isinstance(view_payload, dict):
2047
+ view_payload = _accessible_view_payload(candidate)
2048
+ selection["view"] = view_payload
2049
+ view_payload["auto_selected"] = True
2050
+ view_payload["selection_source"] = "first_satisfying_accessible_view"
2051
+ candidate_summary["selected"] = True
2052
+ probe_summary.append(candidate_summary)
2053
+ plan_data["view_probe_summary"] = probe_summary
2054
+ return {
2055
+ "profile": profile,
2056
+ "ws_id": session_profile.selected_ws_id,
2057
+ "ok": True,
2058
+ "request_route": request_route,
2059
+ "data": plan_data,
2060
+ }
2061
+
2062
+ if not matched_any:
2063
+ blocked_data = self._build_auto_view_blocked_preflight_data(
2064
+ app_key=app_key,
2065
+ record_id=record_id,
2066
+ blockers=["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"],
2067
+ warnings=[],
2068
+ recommended_next_actions=[
2069
+ "Use record_get or record_list to confirm the record still exists in the current workspace.",
2070
+ "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
2071
+ ],
2072
+ view_probe_summary=probe_summary,
2073
+ )
2074
+ return {
2075
+ "profile": profile,
2076
+ "ws_id": session_profile.selected_ws_id,
2077
+ "ok": True,
2078
+ "request_route": request_route,
2079
+ "data": blocked_data,
2080
+ }
2081
+
2082
+ if first_confirmation_plan is not None:
2083
+ first_confirmation_plan["view_probe_summary"] = probe_summary
2084
+ return {
2085
+ "profile": profile,
2086
+ "ws_id": session_profile.selected_ws_id,
2087
+ "ok": True,
2088
+ "request_route": request_route,
2089
+ "data": first_confirmation_plan,
2090
+ }
2091
+
2092
+ union_plan = self._build_record_update_union_preflight(
1939
2093
  profile=profile,
1940
2094
  context=context,
1941
- operation="update",
1942
2095
  app_key=app_key,
1943
- apply_id=record_id,
1944
- answers=[],
2096
+ record_id=record_id,
1945
2097
  fields=fields,
1946
- force_refresh_form=force_refresh_form,
1947
- view_id=candidate.view_id,
1948
- list_type=None,
1949
- view_key=None,
1950
- view_name=None,
1951
- existing_answers_override=current_answers,
2098
+ current_answers=current_answers,
2099
+ matched_routes=matched_routes,
2100
+ force_refresh_form=effective_force_refresh,
1952
2101
  )
1953
- readonly_entries = plan_data.get("validation", {}).get("readonly_or_system_fields", []) if isinstance(plan_data.get("validation"), dict) else []
1954
- invalid_entries = plan_data.get("validation", {}).get("invalid_fields", []) if isinstance(plan_data.get("validation"), dict) else []
1955
- missing_field_titles: list[str] = []
1956
- for entry in readonly_entries:
1957
- if not isinstance(entry, dict):
1958
- continue
1959
- title = _normalize_optional_text(entry.get("que_title"))
1960
- if title and title not in missing_field_titles:
1961
- missing_field_titles.append(title)
1962
- for entry in invalid_entries:
1963
- if not isinstance(entry, dict):
1964
- continue
1965
- if _normalize_optional_text(entry.get("error_code")) != "VIEW_SCOPE_FIELD_HIDDEN":
1966
- continue
1967
- field_payload = entry.get("field")
1968
- if isinstance(field_payload, dict):
1969
- title = _normalize_optional_text(field_payload.get("que_title"))
1970
- if title and title not in missing_field_titles:
1971
- missing_field_titles.append(title)
1972
- candidate_summary: JSONObject = {
1973
- "view_id": candidate.view_id,
1974
- "name": candidate.name,
1975
- "kind": candidate.kind,
1976
- "matched_record": True,
1977
- "writable_field_titles": candidate_titles,
1978
- "missing_field_titles": missing_field_titles,
1979
- "context_complete": True,
1980
- "selected": False,
1981
- }
1982
- if plan_data.get("blockers"):
1983
- confirmation_requests = plan_data.get("confirmation_requests")
1984
- if (
1985
- isinstance(confirmation_requests, list)
1986
- and confirmation_requests
1987
- and first_confirmation_plan is None
1988
- ):
1989
- selection = plan_data.get("selection")
1990
- if not isinstance(selection, dict):
1991
- selection = {}
1992
- plan_data["selection"] = selection
1993
- view_payload = selection.get("view")
1994
- if not isinstance(view_payload, dict):
1995
- view_payload = _accessible_view_payload(candidate)
1996
- selection["view"] = view_payload
1997
- view_payload["auto_selected"] = True
1998
- view_payload["selection_source"] = "first_satisfying_accessible_view"
1999
- candidate_summary["selected"] = True
2000
- first_confirmation_plan = plan_data
2001
- elif first_blocked_plan is None:
2002
- first_blocked_plan = plan_data
2003
- probe_summary.append(candidate_summary)
2004
- continue
2005
-
2006
- selection = plan_data.get("selection")
2007
- if not isinstance(selection, dict):
2008
- selection = {}
2009
- plan_data["selection"] = selection
2010
- view_payload = selection.get("view")
2011
- if not isinstance(view_payload, dict):
2012
- view_payload = _accessible_view_payload(candidate)
2013
- selection["view"] = view_payload
2014
- view_payload["auto_selected"] = True
2015
- view_payload["selection_source"] = "first_satisfying_accessible_view"
2016
- candidate_summary["selected"] = True
2017
- probe_summary.append(candidate_summary)
2018
- plan_data["view_probe_summary"] = probe_summary
2019
- return {
2020
- "profile": profile,
2021
- "ws_id": session_profile.selected_ws_id,
2022
- "ok": True,
2023
- "request_route": request_route,
2024
- "data": plan_data,
2025
- }
2102
+ if union_plan is not None:
2103
+ union_plan["view_probe_summary"] = probe_summary
2104
+ return {
2105
+ "profile": profile,
2106
+ "ws_id": session_profile.selected_ws_id,
2107
+ "ok": True,
2108
+ "request_route": request_route,
2109
+ "data": union_plan,
2110
+ }
2026
2111
 
2027
- if not matched_any:
2028
2112
  blocked_data = self._build_auto_view_blocked_preflight_data(
2029
2113
  app_key=app_key,
2030
2114
  record_id=record_id,
2031
- blockers=["NO_MATCHING_ACCESSIBLE_VIEW_FOR_RECORD"],
2115
+ blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
2032
2116
  warnings=[],
2033
2117
  recommended_next_actions=[
2034
- "Use record_get or record_list to confirm the record still exists in the current workspace.",
2035
- "Call record_update_schema_get to inspect whether any accessible view still matches this record.",
2118
+ "Call record_update_schema_get first to inspect the overall writable field set for this record.",
2119
+ "Reduce the update payload until all requested fields fit inside one matched accessible view.",
2036
2120
  ],
2037
2121
  view_probe_summary=probe_summary,
2122
+ template=first_blocked_plan,
2038
2123
  )
2039
2124
  return {
2040
2125
  "profile": profile,
@@ -2044,37 +2129,180 @@ class RecordTools(ToolBase):
2044
2129
  "data": blocked_data,
2045
2130
  }
2046
2131
 
2047
- if first_confirmation_plan is not None:
2048
- first_confirmation_plan["view_probe_summary"] = probe_summary
2049
- return {
2050
- "profile": profile,
2051
- "ws_id": session_profile.selected_ws_id,
2052
- "ok": True,
2053
- "request_route": request_route,
2054
- "data": first_confirmation_plan,
2132
+ result = build_once(effective_force_refresh=force_refresh_form)
2133
+ if (
2134
+ not force_refresh_form
2135
+ and self._record_preflight_suggests_stale_schema(cast(JSONObject, result.get("data", {})))
2136
+ ):
2137
+ self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
2138
+ result = build_once(effective_force_refresh=True)
2139
+ cast(JSONObject, result.setdefault("data", {}))["schema_force_refreshed"] = True
2140
+ return result
2141
+
2142
+ return self._run_record_tool(profile, runner)
2143
+
2144
+ def _build_record_update_union_preflight(
2145
+ self,
2146
+ *,
2147
+ profile: str,
2148
+ context, # type: ignore[no-untyped-def]
2149
+ app_key: str,
2150
+ record_id: int,
2151
+ fields: JSONObject,
2152
+ current_answers: list[JSONObject],
2153
+ matched_routes: list[AccessibleViewRoute],
2154
+ force_refresh_form: bool,
2155
+ ) -> JSONObject | None:
2156
+ if len(matched_routes) < 2:
2157
+ return None
2158
+
2159
+ union_writable_field_ids: set[int] = set()
2160
+ union_visible_question_ids: set[int] = set()
2161
+ matched_view_payloads: list[JSONObject] = []
2162
+
2163
+ for candidate in matched_routes:
2164
+ browse_scope = self._build_browse_write_scope(
2165
+ profile,
2166
+ context,
2167
+ app_key,
2168
+ candidate,
2169
+ force_refresh=force_refresh_form,
2170
+ )
2171
+ union_writable_field_ids.update(cast(set[int], browse_scope["writable_field_ids"]))
2172
+ union_visible_question_ids.update(cast(set[int], browse_scope["visible_question_ids"]))
2173
+ matched_view_payloads.append(_accessible_view_payload(candidate))
2174
+
2175
+ if not union_writable_field_ids and not union_visible_question_ids:
2176
+ return None
2177
+
2178
+ plan_data = self._build_record_write_preflight(
2179
+ profile=profile,
2180
+ context=context,
2181
+ operation="update",
2182
+ app_key=app_key,
2183
+ apply_id=record_id,
2184
+ answers=[],
2185
+ fields=fields,
2186
+ force_refresh_form=force_refresh_form,
2187
+ view_id=None,
2188
+ list_type=None,
2189
+ view_key=None,
2190
+ view_name=None,
2191
+ existing_answers_override=current_answers,
2192
+ )
2193
+
2194
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
2195
+ app_index = _build_applicant_top_level_field_index(schema)
2196
+ validation = cast(JSONObject, plan_data.get("validation", {}))
2197
+ invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
2198
+ missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
2199
+ readonly_or_system_fields = cast(list[JSONObject], validation.get("readonly_or_system_fields", []))
2200
+ confirmation_requests = cast(list[JSONObject], plan_data.get("confirmation_requests", []))
2201
+
2202
+ if union_visible_question_ids:
2203
+ invalid_fields.extend(
2204
+ self._validate_view_scoped_subtable_answers(
2205
+ normalized_answers=cast(list[JSONObject], plan_data.get("normalized_answers", [])),
2206
+ full_index=app_index,
2207
+ selector_index=app_index,
2208
+ visible_question_ids=union_visible_question_ids,
2209
+ )
2210
+ )
2211
+
2212
+ existing_readonly_ids = {
2213
+ str(_coerce_count(item.get("que_id")))
2214
+ for item in readonly_or_system_fields
2215
+ if isinstance(item, dict) and _coerce_count(item.get("que_id")) is not None
2216
+ }
2217
+ for entry in cast(list[JSONObject], plan_data.get("resolved_fields", [])):
2218
+ if not isinstance(entry, dict) or not bool(entry.get("resolved")):
2219
+ continue
2220
+ que_id = _coerce_count(entry.get("que_id"))
2221
+ if que_id is None or que_id in union_writable_field_ids or str(que_id) in existing_readonly_ids:
2222
+ continue
2223
+ readonly_or_system_fields.append(
2224
+ {
2225
+ "que_id": que_id,
2226
+ "que_title": entry.get("que_title"),
2227
+ "que_type": entry.get("que_type"),
2228
+ "readonly": entry.get("readonly"),
2229
+ "system": entry.get("system"),
2230
+ "source": entry.get("source"),
2231
+ "requested": entry.get("requested"),
2232
+ "reason_code": "view_readonly",
2055
2233
  }
2234
+ )
2056
2235
 
2057
- blocked_data = self._build_auto_view_blocked_preflight_data(
2058
- app_key=app_key,
2059
- record_id=record_id,
2060
- blockers=["NO_SINGLE_VIEW_CAN_UPDATE_ALL_FIELDS"],
2061
- warnings=[],
2062
- recommended_next_actions=[
2063
- "Call record_update_schema_get first to inspect the overall writable field set for this record.",
2064
- "Reduce the update payload until all requested fields fit inside one matched accessible view.",
2065
- ],
2066
- view_probe_summary=probe_summary,
2067
- template=first_blocked_plan,
2236
+ warnings_payload = validation.get("warnings")
2237
+ warnings = list(warnings_payload) if isinstance(warnings_payload, list) else []
2238
+ union_warning = "update preflight used the union of matched accessible views because no single matched view covered the full payload."
2239
+ if union_warning not in warnings:
2240
+ warnings.append(union_warning)
2241
+
2242
+ blockers: list[str] = []
2243
+ if invalid_fields:
2244
+ blockers.append("payload contains invalid field values")
2245
+ if missing_required_fields:
2246
+ blockers.append("required fields are missing")
2247
+ if readonly_or_system_fields:
2248
+ blockers.append("payload writes fields that are not writable in any matched accessible view")
2249
+ if confirmation_requests:
2250
+ blockers.append("one or more lookup fields require confirmation before the write can execute")
2251
+
2252
+ validation["valid"] = (
2253
+ not invalid_fields
2254
+ and not missing_required_fields
2255
+ and not readonly_or_system_fields
2256
+ and not confirmation_requests
2257
+ )
2258
+ validation["invalid_fields"] = invalid_fields
2259
+ validation["missing_required_fields"] = missing_required_fields
2260
+ validation["readonly_or_system_fields"] = readonly_or_system_fields
2261
+ validation["warnings"] = warnings
2262
+ plan_data["validation"] = validation
2263
+ plan_data["field_errors"] = self._record_write_field_errors(
2264
+ invalid_fields=invalid_fields,
2265
+ missing_required_fields=missing_required_fields,
2266
+ readonly_or_system_fields=readonly_or_system_fields,
2267
+ )
2268
+ plan_data["blockers"] = blockers
2269
+
2270
+ recommended_next_actions = (
2271
+ list(plan_data.get("recommended_next_actions"))
2272
+ if isinstance(plan_data.get("recommended_next_actions"), list)
2273
+ else []
2274
+ )
2275
+ if readonly_or_system_fields:
2276
+ recommended_next_actions.append(
2277
+ "Remove fields that are not writable in any matched accessible view for this record."
2068
2278
  )
2069
- return {
2070
- "profile": profile,
2071
- "ws_id": session_profile.selected_ws_id,
2072
- "ok": True,
2073
- "request_route": request_route,
2074
- "data": blocked_data,
2075
- }
2279
+ if invalid_fields:
2280
+ recommended_next_actions.append("Fix invalid_fields before applying the write.")
2281
+ if missing_required_fields:
2282
+ recommended_next_actions.append("Fill missing required fields before applying the write.")
2283
+ if confirmation_requests:
2284
+ recommended_next_actions.append(
2285
+ "Review confirmation_requests and retry with explicit ids/objects for the ambiguous lookup fields."
2286
+ )
2287
+ plan_data["recommended_next_actions"] = list(dict.fromkeys(str(item) for item in recommended_next_actions))
2288
+
2289
+ selection = plan_data.get("selection")
2290
+ if not isinstance(selection, dict):
2291
+ selection = {}
2292
+ selection["view"] = {
2293
+ "view_id": "virtual:matched_view_union",
2294
+ "name": "Matched accessible views (union)",
2295
+ "kind": "virtual",
2296
+ "analysis_supported": False,
2297
+ "auto_selected": True,
2298
+ "selection_source": "matched_accessible_view_union",
2299
+ "matched_views": matched_view_payloads,
2300
+ }
2301
+ plan_data["selection"] = selection
2076
2302
 
2077
- return self._run_record_tool(profile, runner)
2303
+ if invalid_fields or missing_required_fields or readonly_or_system_fields:
2304
+ return None
2305
+ return plan_data
2078
2306
 
2079
2307
  def _build_auto_view_blocked_preflight_data(
2080
2308
  self,
@@ -2278,6 +2506,7 @@ class RecordTools(ToolBase):
2278
2506
  view_key=view_key,
2279
2507
  view_name=view_name,
2280
2508
  )
2509
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
2281
2510
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
2282
2511
  normalized_payload: JSONObject = self._record_write_normalized_payload(
2283
2512
  operation="insert",
@@ -2304,7 +2533,7 @@ class RecordTools(ToolBase):
2304
2533
  fields={},
2305
2534
  submit_type=submit_type_value,
2306
2535
  verify_write=verify_write,
2307
- force_refresh_form=False,
2536
+ force_refresh_form=preflight_used_force_refresh,
2308
2537
  )
2309
2538
  except QingflowApiError as exc:
2310
2539
  self._raise_record_write_permission_error(
@@ -2351,6 +2580,7 @@ class RecordTools(ToolBase):
2351
2580
  view_key=view_key,
2352
2581
  view_name=view_name,
2353
2582
  )
2583
+ preflight_used_force_refresh = self._record_preflight_used_force_refresh(raw_preflight)
2354
2584
  preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
2355
2585
  normalized_payload = self._record_write_normalized_payload(
2356
2586
  operation="update",
@@ -2378,7 +2608,7 @@ class RecordTools(ToolBase):
2378
2608
  fields={},
2379
2609
  role=1,
2380
2610
  verify_write=verify_write,
2381
- force_refresh_form=False,
2611
+ force_refresh_form=preflight_used_force_refresh,
2382
2612
  )
2383
2613
  except QingflowApiError as exc:
2384
2614
  self._raise_record_write_permission_error(
@@ -4452,6 +4682,23 @@ class RecordTools(ToolBase):
4452
4682
  view_key=view_key,
4453
4683
  view_name=view_name,
4454
4684
  )
4685
+ if not force_refresh_form and self._record_preflight_suggests_stale_schema(plan_data):
4686
+ self._clear_record_schema_caches(profile=profile, app_key=app_key, clear_view_caches=True)
4687
+ plan_data = self._build_record_write_preflight(
4688
+ profile=profile,
4689
+ context=context,
4690
+ operation=operation,
4691
+ app_key=app_key,
4692
+ apply_id=apply_id,
4693
+ answers=answers,
4694
+ fields=fields,
4695
+ force_refresh_form=True,
4696
+ view_id=view_id,
4697
+ list_type=list_type,
4698
+ view_key=view_key,
4699
+ view_name=view_name,
4700
+ )
4701
+ plan_data["schema_force_refreshed"] = True
4455
4702
  return {
4456
4703
  "profile": profile,
4457
4704
  "ws_id": session_profile.selected_ws_id,
@@ -5777,6 +6024,84 @@ class RecordTools(ToolBase):
5777
6024
  self._view_config_cache[cache_key] = normalized
5778
6025
  return normalized
5779
6026
 
6027
+ def _clear_record_schema_caches(
6028
+ self,
6029
+ *,
6030
+ profile: str,
6031
+ app_key: str,
6032
+ resolved_view: AccessibleViewRoute | None = None,
6033
+ clear_view_caches: bool = False,
6034
+ ) -> None:
6035
+ view_key = (
6036
+ resolved_view.view_selection.view_key
6037
+ if resolved_view is not None and resolved_view.view_selection is not None
6038
+ else None
6039
+ )
6040
+ for cache_key in list(self._form_cache.keys()):
6041
+ cache_profile, cache_scope, *_ = cache_key
6042
+ if cache_profile != profile:
6043
+ continue
6044
+ if cache_scope == app_key:
6045
+ self._form_cache.pop(cache_key, None)
6046
+ continue
6047
+ if view_key is not None and cache_scope == f"view:{view_key}":
6048
+ self._form_cache.pop(cache_key, None)
6049
+ continue
6050
+ if clear_view_caches and isinstance(cache_scope, str) and cache_scope.startswith("view:"):
6051
+ self._form_cache.pop(cache_key, None)
6052
+ self._applicant_node_cache.pop((profile, app_key), None)
6053
+ self._view_list_cache.pop((profile, app_key), None)
6054
+ if view_key is not None:
6055
+ self._view_config_cache.pop((profile, view_key), None)
6056
+ elif clear_view_caches:
6057
+ for cache_key in [item for item in self._view_config_cache.keys() if item[0] == profile]:
6058
+ self._view_config_cache.pop(cache_key, None)
6059
+
6060
+ def _record_preflight_used_force_refresh(self, raw_preflight: JSONObject) -> bool:
6061
+ data = raw_preflight.get("data")
6062
+ return bool(isinstance(data, dict) and data.get("schema_force_refreshed"))
6063
+
6064
+ def _record_preflight_suggests_stale_schema(self, plan_data: JSONObject) -> bool:
6065
+ validation = plan_data.get("validation")
6066
+ invalid_fields = validation.get("invalid_fields") if isinstance(validation, dict) else None
6067
+ if not isinstance(invalid_fields, list):
6068
+ return False
6069
+ return any(self._field_error_suggests_stale_schema(entry) for entry in invalid_fields if isinstance(entry, dict))
6070
+
6071
+ def _field_error_suggests_stale_schema(self, entry: JSONObject) -> bool:
6072
+ error_code = _normalize_optional_text(entry.get("error_code"))
6073
+ if error_code == "VIEW_SCOPE_FIELD_HIDDEN":
6074
+ return False
6075
+ if error_code == "FIELD_NOT_FOUND":
6076
+ return True
6077
+ expected_format = entry.get("expected_format")
6078
+ if isinstance(expected_format, dict) and _normalize_optional_text(expected_format.get("kind")) == "subtable_rows":
6079
+ return True
6080
+ location = _normalize_optional_text(entry.get("location"))
6081
+ return bool(location and "[" in location and "." in location)
6082
+
6083
+ def _record_get_needs_schema_refresh(
6084
+ self,
6085
+ *,
6086
+ answer_list: list[JSONValue],
6087
+ selected_fields: list[FormField],
6088
+ record: JSONObject,
6089
+ normalized_record: JSONObject,
6090
+ ) -> bool:
6091
+ for field in selected_fields:
6092
+ if field.que_type not in SUBTABLE_QUE_TYPES:
6093
+ continue
6094
+ answer = _find_answer_for_field(answer_list, field)
6095
+ if not isinstance(answer, dict):
6096
+ continue
6097
+ if not _subtable_answer_has_unmapped_cells(answer, field):
6098
+ continue
6099
+ if _subtable_output_effectively_empty(record.get(field.que_title)) or _subtable_output_effectively_empty(
6100
+ normalized_record.get(field.que_title)
6101
+ ):
6102
+ return True
6103
+ return False
6104
+
5780
6105
  def _get_system_browse_schema(
5781
6106
  self,
5782
6107
  profile: str,
@@ -9943,6 +10268,45 @@ def _normalize_subtable_answer_value_for_output(answer: JSONObject, field: FormF
9943
10268
  return normalized_rows or None
9944
10269
 
9945
10270
 
10271
+ def _subtable_answer_has_unmapped_cells(answer: JSONObject, field: FormField) -> bool:
10272
+ table_values = answer.get("tableValues")
10273
+ if not isinstance(table_values, list) or not table_values:
10274
+ return False
10275
+ subtable_column_ids = {
10276
+ cast(int, item["que_id"])
10277
+ for item in _subtable_columns_for_field(field)
10278
+ if isinstance(item.get("que_id"), int)
10279
+ }
10280
+ if not subtable_column_ids:
10281
+ return False
10282
+ for raw_row in table_values:
10283
+ if not isinstance(raw_row, list):
10284
+ continue
10285
+ for cell in raw_row:
10286
+ if not isinstance(cell, dict):
10287
+ continue
10288
+ que_id = _coerce_count(cell.get("queId"))
10289
+ if que_id is None or que_id in subtable_column_ids:
10290
+ continue
10291
+ if _answer_has_meaningful_content(cell):
10292
+ return True
10293
+ return False
10294
+
10295
+
10296
+ def _subtable_output_effectively_empty(value: JSONValue) -> bool:
10297
+ if value in (None, "", [], {}):
10298
+ return True
10299
+ if isinstance(value, dict):
10300
+ rows = value.get("rows")
10301
+ if not isinstance(rows, list) or not rows:
10302
+ return True
10303
+ return all(
10304
+ isinstance(row, dict) and set(row.keys()) <= {"__row_id__"}
10305
+ for row in rows
10306
+ )
10307
+ return False
10308
+
10309
+
9946
10310
  def _canonicalize_answer_value_for_compare(answer: JSONObject | None, field: FormField | None) -> JSONValue:
9947
10311
  if answer is None or field is None:
9948
10312
  return None