@josephyan/qingflow-app-user-mcp 0.2.0-beta.21 → 0.2.0-beta.22

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.
@@ -243,7 +243,8 @@ class RecordTools(ToolBase):
243
243
  @mcp.tool(
244
244
  description=(
245
245
  "Write Qingflow records with a SQL-like JSON DSL. "
246
- "Use record_schema_get first, then choose operation=insert|update|delete and mode=plan|apply. "
246
+ "Use record_schema_get first, then choose operation=insert|update|delete. "
247
+ "This tool performs internal preflight validation before any write is applied. "
247
248
  "This route does not accept raw SQL strings or free-form WHERE clauses."
248
249
  )
249
250
  )
@@ -251,7 +252,6 @@ class RecordTools(ToolBase):
251
252
  profile: str = DEFAULT_PROFILE,
252
253
  app_key: str = "",
253
254
  operation: str = "insert",
254
- mode: str = "plan",
255
255
  record_id: int | None = None,
256
256
  record_ids: list[int] | None = None,
257
257
  values: list[JSONObject] | None = None,
@@ -264,7 +264,6 @@ class RecordTools(ToolBase):
264
264
  profile=profile,
265
265
  app_key=app_key,
266
266
  operation=operation,
267
- mode=mode,
268
267
  record_id=record_id,
269
268
  record_ids=record_ids or [],
270
269
  values=values or [],
@@ -564,7 +563,6 @@ class RecordTools(ToolBase):
564
563
  profile: str,
565
564
  app_key: str,
566
565
  operation: str,
567
- mode: str,
568
566
  record_id: int | None,
569
567
  record_ids: list[int],
570
568
  values: list[JSONObject],
@@ -572,14 +570,18 @@ class RecordTools(ToolBase):
572
570
  submit_type: str | int,
573
571
  verify_write: bool,
574
572
  output_profile: str,
573
+ mode: str | None = None,
575
574
  ) -> JSONObject:
576
575
  normalized_output_profile = self._normalize_public_output_profile(output_profile)
577
576
  normalized_operation = operation.strip().lower()
578
- normalized_mode = mode.strip().lower()
579
577
  if normalized_operation not in {"insert", "update", "delete"}:
580
578
  raise_tool_error(QingflowApiError.config_error("operation must be insert, update, or delete"))
581
- if normalized_mode not in {"plan", "apply"}:
582
- raise_tool_error(QingflowApiError.config_error("mode must be plan or apply"))
579
+ if mode is not None:
580
+ raise_tool_error(
581
+ QingflowApiError.config_error(
582
+ "record_write no longer accepts mode; it now runs internal preflight automatically before apply"
583
+ )
584
+ )
583
585
  if not app_key:
584
586
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
585
587
  normalized_record_ids = [item for item in record_ids if isinstance(item, int) and item > 0]
@@ -591,34 +593,36 @@ class RecordTools(ToolBase):
591
593
  if set:
592
594
  raise_tool_error(QingflowApiError.config_error("insert must use values, not set"))
593
595
  normalized_answers = self._normalize_record_write_clauses(values, location="values")
594
- normalized_payload: JSONObject = {
595
- "operation": "insert",
596
- "record_id": None,
597
- "record_ids": [],
598
- "answers": normalized_answers,
599
- "submit_type": submit_type_value,
600
- }
601
- if normalized_mode == "plan":
602
- raw_plan = self.record_write_plan(
603
- profile=profile,
604
- operation="create",
605
- app_key=app_key,
606
- apply_id=None,
607
- answers=normalized_answers,
608
- fields={},
609
- force_refresh_form=False,
610
- )
611
- return self._record_write_plan_response(
612
- raw_plan,
596
+ raw_preflight = self._preflight_record_write(
597
+ profile=profile,
598
+ operation="create",
599
+ app_key=app_key,
600
+ apply_id=None,
601
+ answers=normalized_answers,
602
+ fields={},
603
+ force_refresh_form=False,
604
+ )
605
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
606
+ normalized_payload: JSONObject = self._record_write_normalized_payload(
607
+ operation="insert",
608
+ record_id=None,
609
+ record_ids=[],
610
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
611
+ submit_type=submit_type_value,
612
+ )
613
+ if preflight_data.get("blockers"):
614
+ return self._record_write_blocked_response(
615
+ raw_preflight,
613
616
  operation="insert",
614
617
  normalized_payload=normalized_payload,
615
618
  output_profile=normalized_output_profile,
616
619
  human_review=False,
620
+ target_resource={"type": "record", "app_key": app_key, "record_id": None, "record_ids": []},
617
621
  )
618
622
  raw_apply = self.record_create(
619
623
  profile=profile,
620
624
  app_key=app_key,
621
- answers=normalized_answers,
625
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
622
626
  fields={},
623
627
  submit_type=submit_type_value,
624
628
  verify_write=verify_write,
@@ -630,6 +634,7 @@ class RecordTools(ToolBase):
630
634
  normalized_payload=normalized_payload,
631
635
  output_profile=normalized_output_profile,
632
636
  human_review=False,
637
+ preflight=raw_preflight,
633
638
  )
634
639
 
635
640
  if normalized_operation == "update":
@@ -640,35 +645,37 @@ class RecordTools(ToolBase):
640
645
  if values:
641
646
  raise_tool_error(QingflowApiError.config_error("update must use set, not values"))
642
647
  normalized_answers = self._normalize_record_write_clauses(set, location="set")
643
- normalized_payload = {
644
- "operation": "update",
645
- "record_id": record_id,
646
- "record_ids": [],
647
- "answers": normalized_answers,
648
- "submit_type": submit_type_value,
649
- }
650
- if normalized_mode == "plan":
651
- raw_plan = self.record_write_plan(
652
- profile=profile,
653
- operation="update",
654
- app_key=app_key,
655
- apply_id=record_id,
656
- answers=normalized_answers,
657
- fields={},
658
- force_refresh_form=False,
659
- )
660
- return self._record_write_plan_response(
661
- raw_plan,
648
+ raw_preflight = self._preflight_record_write(
649
+ profile=profile,
650
+ operation="update",
651
+ app_key=app_key,
652
+ apply_id=record_id,
653
+ answers=normalized_answers,
654
+ fields={},
655
+ force_refresh_form=False,
656
+ )
657
+ preflight_data = cast(JSONObject, raw_preflight.get("data", {}))
658
+ normalized_payload = self._record_write_normalized_payload(
659
+ operation="update",
660
+ record_id=record_id,
661
+ record_ids=[],
662
+ normalized_answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
663
+ submit_type=submit_type_value,
664
+ )
665
+ if preflight_data.get("blockers"):
666
+ return self._record_write_blocked_response(
667
+ raw_preflight,
662
668
  operation="update",
663
669
  normalized_payload=normalized_payload,
664
670
  output_profile=normalized_output_profile,
665
671
  human_review=True,
672
+ target_resource={"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": []},
666
673
  )
667
674
  raw_apply = self.record_update(
668
675
  profile=profile,
669
676
  app_key=app_key,
670
677
  apply_id=record_id,
671
- answers=normalized_answers,
678
+ answers=cast(list[JSONObject], preflight_data.get("normalized_answers", [])),
672
679
  fields={},
673
680
  role=1,
674
681
  verify_write=verify_write,
@@ -680,6 +687,7 @@ class RecordTools(ToolBase):
680
687
  normalized_payload=normalized_payload,
681
688
  output_profile=normalized_output_profile,
682
689
  human_review=True,
690
+ preflight=raw_preflight,
683
691
  )
684
692
 
685
693
  if values or set:
@@ -694,22 +702,6 @@ class RecordTools(ToolBase):
694
702
  "answers": [],
695
703
  "submit_type": submit_type_value,
696
704
  }
697
- if normalized_mode == "plan":
698
- return {
699
- "profile": profile,
700
- "ok": True,
701
- "request_route": None,
702
- "warnings": [],
703
- "output_profile": normalized_output_profile,
704
- "data": {
705
- "action": {"operation": "delete", "mode": "plan"},
706
- "resource": {"type": "record", "app_key": app_key, "record_id": record_id, "record_ids": delete_ids},
707
- "verification": None,
708
- "normalized_payload": normalized_payload,
709
- "blockers": [],
710
- "human_review": self._record_write_human_review_payload("delete", enabled=True),
711
- },
712
- }
713
705
  raw_apply = self._record_delete_many(profile=profile, app_key=app_key, record_ids=delete_ids)
714
706
  return self._record_write_apply_response(
715
707
  raw_apply,
@@ -717,6 +709,7 @@ class RecordTools(ToolBase):
717
709
  normalized_payload=normalized_payload,
718
710
  output_profile=normalized_output_profile,
719
711
  human_review=True,
712
+ preflight=None,
720
713
  )
721
714
 
722
715
  def _schema_field_payload(self, field: FormField) -> JSONObject:
@@ -1259,7 +1252,6 @@ class RecordTools(ToolBase):
1259
1252
  requested_pages = int(analysis_paging["requested_pages"])
1260
1253
  scan_max_pages = int(analysis_paging["scan_max_pages"])
1261
1254
  auto_expand_pages = bool(analysis_paging["auto_expand_pages"])
1262
- query_id = _query_id()
1263
1255
  pages_to_scan = min(max(requested_pages, 1), max(scan_max_pages, 1))
1264
1256
  current_page = 1
1265
1257
  scanned_pages = 0
@@ -1349,82 +1341,101 @@ class RecordTools(ToolBase):
1349
1341
  ]
1350
1342
  all_rows = self._sort_analyze_rows(all_rows, sort, dimensions, metrics)
1351
1343
  rows_truncated = len(all_rows) > limit
1352
- limited_rows = all_rows[:limit]
1353
- rows = limited_rows
1354
- rows_returned = len(limited_rows)
1344
+ rows = all_rows[:limit]
1355
1345
  group_count = len(all_rows)
1356
1346
  statement_scope = "returned_groups_only" if rows_truncated else "full_population"
1357
1347
  else:
1358
1348
  rows_truncated = False
1359
1349
  rows = [{"dimensions": {}, "metrics": metric_totals}]
1360
- rows_returned = 1
1361
1350
  group_count = 1
1362
1351
  statement_scope = "full_population"
1363
1352
  raw_scan_complete = not has_more
1364
1353
  completeness_status = "complete" if raw_scan_complete else "incomplete"
1365
1354
  reason_code = "LOCAL_VIEW_FILTERING" if local_filtering and raw_scan_complete else ("SOURCE_EXHAUSTED" if raw_scan_complete else "SCAN_LIMIT_HIT")
1366
- totals = {
1367
- "scanned_count": matched_rows,
1368
- "group_count": group_count,
1369
- "metric_totals": metric_totals,
1355
+ query_payload: JSONObject = {
1356
+ "app_key": app_key,
1357
+ "dimensions": [
1358
+ {
1359
+ "field_id": cast(FormField, item["field"]).que_id,
1360
+ "alias": item["alias"],
1361
+ "bucket": item["bucket"],
1362
+ }
1363
+ for item in dimensions
1364
+ ],
1365
+ "metrics": [
1366
+ {
1367
+ "op": item["op"],
1368
+ **(
1369
+ {"field_id": cast(FormField, item["field"]).que_id}
1370
+ if item["field"] is not None
1371
+ else {}
1372
+ ),
1373
+ "alias": item["alias"],
1374
+ }
1375
+ for item in metrics
1376
+ ],
1377
+ "filters": [
1378
+ {
1379
+ "field_id": cast(FormField, item["field"]).que_id,
1380
+ "op": item["op"],
1381
+ **({"value": item.get("value")} if item.get("value") is not None else {}),
1382
+ }
1383
+ for item in filters
1384
+ ],
1385
+ "sort": [{"by": item["by"], "order": item["order"]} for item in sort],
1386
+ "limit": limit,
1387
+ "strict_full": strict_full,
1370
1388
  }
1371
- data: JSONObject = {
1372
- "query": {
1373
- "app_key": app_key,
1374
- "dimensions": [
1375
- {
1376
- "field_id": cast(FormField, item["field"]).que_id,
1377
- "title": cast(FormField, item["field"]).que_title,
1378
- "alias": item["alias"],
1379
- "bucket": item["bucket"],
1380
- }
1381
- for item in dimensions
1382
- ],
1383
- "metrics": [
1384
- {
1385
- "op": item["op"],
1386
- "field_id": cast(FormField | None, item["field"]).que_id if item["field"] is not None else None,
1387
- "title": cast(FormField | None, item["field"]).que_title if item["field"] is not None else None,
1388
- "alias": item["alias"],
1389
- }
1390
- for item in metrics
1391
- ],
1392
- "filters": [
1393
- {
1394
- "field_id": cast(FormField, item["field"]).que_id,
1395
- "title": cast(FormField, item["field"]).que_title,
1396
- "op": item["op"],
1397
- "value": item.get("value"),
1398
- }
1399
- for item in filters
1400
- ],
1401
- "applied_sort": [{"by": item["by"], "order": item["order"]} for item in sort],
1402
- "view": _view_selection_payload(view_selection),
1403
- },
1404
- "rows": rows,
1405
- "totals": totals,
1406
- "completeness": {
1407
- "status": completeness_status,
1408
- "reason_code": reason_code,
1409
- "local_filtering_applied": local_filtering,
1410
- "safe_for_final_conclusion": completeness_status == "complete",
1411
- },
1412
- "presentation": {
1413
- "row_limit": limit,
1414
- "rows_returned": rows_returned,
1415
- "rows_truncated": rows_truncated,
1416
- "statement_scope": statement_scope,
1417
- },
1418
- "warnings": self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated),
1389
+ view_payload = _view_selection_payload(view_selection)
1390
+ if view_payload:
1391
+ query_payload["view"] = view_payload
1392
+
1393
+ ranking: JSONObject | None = None
1394
+ if dimensions and sort:
1395
+ primary_sort = sort[0]
1396
+ ranking = {
1397
+ "order_by": primary_sort["by"],
1398
+ "order_direction": primary_sort["order"],
1399
+ "ranked": True,
1400
+ }
1401
+ rows = [
1402
+ {
1403
+ "dimensions": cast(JSONObject, row["dimensions"]),
1404
+ "metrics": cast(JSONObject, row["metrics"]),
1405
+ "rank": idx,
1406
+ }
1407
+ for idx, row in enumerate(rows, start=1)
1408
+ ]
1409
+
1410
+ warnings = self._build_analyze_warnings(local_filtering=local_filtering, rows_truncated=rows_truncated)
1411
+ completeness: JSONObject = {
1412
+ "status": completeness_status,
1413
+ "safe_for_final_conclusion": completeness_status == "complete",
1414
+ "rows_truncated": rows_truncated,
1415
+ "statement_scope": statement_scope,
1416
+ "warnings": warnings,
1419
1417
  }
1418
+ if completeness_status != "complete":
1419
+ completeness["reason_code"] = reason_code
1420
+
1420
1421
  response: JSONObject = {
1421
- "profile": profile,
1422
- "ws_id": session_profile.selected_ws_id,
1423
1422
  "ok": True,
1424
1423
  "status": "success" if raw_scan_complete else "partial",
1425
- "query_id": query_id,
1426
- "request_route": self._request_route_payload(context),
1427
- "data": data,
1424
+ "query": query_payload,
1425
+ "result": {
1426
+ "totals": {
1427
+ "metric_totals": metric_totals,
1428
+ },
1429
+ "rows": rows,
1430
+ "row_count": len(rows),
1431
+ },
1432
+ "ranking": ranking,
1433
+ "ratios": None,
1434
+ "completeness": completeness,
1435
+ "presentation": {
1436
+ "returned_group_count": len(rows),
1437
+ "total_group_count": group_count,
1438
+ },
1428
1439
  }
1429
1440
  if strict_full and not raw_scan_complete:
1430
1441
  response["ok"] = False
@@ -1435,13 +1446,15 @@ class RecordTools(ToolBase):
1435
1446
  "fix_hint": "Narrow the scope with view/filter constraints, or retry after reducing the dataset size.",
1436
1447
  }
1437
1448
  if output_profile == "verbose":
1438
- response["data"]["debug"] = {
1449
+ response["debug"] = {
1450
+ "request_route": self._request_route_payload(context),
1439
1451
  "elapsed_ms": int((time.perf_counter() - started_at) * 1000),
1440
1452
  "backend_total_hint": scan_control.get("backend_total_count", result_amount),
1441
1453
  "backend_page_amount": scan_control.get("backend_page_amount"),
1442
1454
  "source_pages": source_pages,
1443
- "raw_scan_complete": raw_scan_complete,
1444
1455
  "scan_control": scan_control,
1456
+ "reason_code": reason_code,
1457
+ "local_filtering_applied": local_filtering,
1445
1458
  }
1446
1459
  return response
1447
1460
 
@@ -1559,19 +1572,9 @@ class RecordTools(ToolBase):
1559
1572
  def _build_analyze_warnings(self, *, local_filtering: bool, rows_truncated: bool) -> list[JSONObject]:
1560
1573
  warnings: list[JSONObject] = []
1561
1574
  if local_filtering:
1562
- warnings.append(
1563
- {
1564
- "code": "LOCAL_FILTERING_APPLIED",
1565
- "message": "Current analysis applies local filtering after scanning source pages.",
1566
- }
1567
- )
1575
+ warnings.append({"code": "LOCAL_VIEW_FILTERING"})
1568
1576
  if rows_truncated:
1569
- warnings.append(
1570
- {
1571
- "code": "ROWS_TRUNCATED",
1572
- "message": "Result rows were truncated by limit; totals remain based on the full analyzed result set.",
1573
- }
1574
- )
1577
+ warnings.append({"code": "ROWS_TRUNCATED"})
1575
1578
  return warnings
1576
1579
 
1577
1580
  def record_write_plan(
@@ -1589,123 +1592,180 @@ class RecordTools(ToolBase):
1589
1592
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
1590
1593
  inferred_operation = operation if operation in {"create", "update"} else ("update" if apply_id else "create")
1591
1594
 
1595
+ return self._preflight_record_write(
1596
+ profile=profile,
1597
+ operation=inferred_operation,
1598
+ app_key=app_key,
1599
+ apply_id=apply_id,
1600
+ answers=answers or [],
1601
+ fields=fields or {},
1602
+ force_refresh_form=force_refresh_form,
1603
+ )
1604
+
1605
+ def _preflight_record_write(
1606
+ self,
1607
+ *,
1608
+ profile: str,
1609
+ operation: str,
1610
+ app_key: str,
1611
+ apply_id: int | None,
1612
+ answers: list[JSONObject],
1613
+ fields: JSONObject,
1614
+ force_refresh_form: bool,
1615
+ ) -> JSONObject:
1616
+ if not app_key:
1617
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
1618
+
1592
1619
  def runner(session_profile, context):
1593
- schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
1594
- index = _build_field_index(schema)
1595
- normalized_fields = fields or {}
1596
- normalized_answers_input = answers or []
1597
- resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=index)
1598
- support_matrix = _summarize_write_support(resolved_fields)
1599
- invalid_fields: list[JSONObject] = []
1600
- normalized_answers: list[JSONObject] = []
1601
- try:
1602
- normalized_answers = self._resolve_answers(
1603
- profile,
1604
- context,
1605
- app_key,
1606
- answers=normalized_answers_input,
1607
- fields=normalized_fields,
1608
- force_refresh_form=force_refresh_form,
1609
- )
1610
- except RecordInputError as error:
1611
- invalid_fields.append(
1612
- {
1613
- "location": _stringify_json(error.details.get("location") if error.details else None),
1614
- "message": error.message,
1615
- "error_code": error.error_code,
1616
- "field": error.details.get("field") if error.details and isinstance(error.details.get("field"), dict) else None,
1617
- "expected_format": error.details.get("expected_format") if error.details and isinstance(error.details.get("expected_format"), dict) else None,
1618
- "received_value": error.details.get("received_value") if error.details else None,
1619
- }
1620
- )
1621
- readonly_or_system_fields = [
1622
- {
1623
- "que_id": entry.get("que_id"),
1624
- "que_title": entry.get("que_title"),
1625
- "que_type": entry.get("que_type"),
1626
- "readonly": entry.get("readonly"),
1627
- "system": entry.get("system"),
1628
- "source": entry.get("source"),
1629
- "requested": entry.get("requested"),
1630
- }
1631
- for entry in resolved_fields
1632
- if bool(entry.get("resolved")) and (bool(entry.get("readonly")) or bool(entry.get("system")))
1633
- ]
1634
- provided_field_ids = {
1635
- str(answer.get("queId"))
1636
- for answer in normalized_answers
1637
- if isinstance(answer.get("queId"), int) and int(answer["queId"]) > 0
1638
- }
1639
- missing_required_fields = []
1640
- for field in index.by_id.values():
1641
- if not field.required or str(field.que_id) in provided_field_ids:
1642
- continue
1643
- missing_required_fields.append(
1644
- {
1645
- "que_id": field.que_id,
1646
- "que_title": field.que_title,
1647
- "que_type": field.que_type,
1648
- "reason": "required field not provided",
1649
- }
1650
- )
1651
- question_relations = _collect_question_relations(schema)
1652
- option_links = _collect_option_links(resolved_fields)
1653
- validation = {
1654
- "valid": not invalid_fields and not missing_required_fields and not readonly_or_system_fields,
1655
- "missing_required_fields": missing_required_fields,
1656
- "likely_hidden_required_fields": [],
1657
- "readonly_or_system_fields": readonly_or_system_fields,
1658
- "invalid_fields": invalid_fields,
1659
- "warnings": [
1660
- "record_write_plan is a static preflight built from form metadata; runtime visibility and dynamic linkage can still reject writes."
1661
- ],
1662
- }
1663
- blockers = []
1664
- if invalid_fields:
1665
- blockers.append("payload contains invalid field values")
1666
- if missing_required_fields:
1667
- blockers.append("required fields are missing")
1668
- if readonly_or_system_fields:
1669
- blockers.append("payload writes readonly or system-managed fields")
1670
- if question_relations:
1671
- validation["warnings"].append("form contains questionRelations; linked visibility and runtime required rules may differ at submit time.")
1672
- actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
1673
- if support_matrix["restricted"]:
1674
- actions.append("Review write_format.required_presteps for restricted fields before submit.")
1675
- if invalid_fields:
1676
- actions.append("Fix invalid_fields before calling record_create or record_update.")
1677
- if missing_required_fields:
1678
- actions.append("Fill missing required fields before submit.")
1679
- if readonly_or_system_fields:
1680
- actions.append("Remove readonly/system fields from payload before submit.")
1681
- if question_relations:
1682
- actions.append("Treat ready_to_submit as static-only because linked fields can still appear at runtime.")
1620
+ plan_data = self._build_record_write_preflight(
1621
+ profile=profile,
1622
+ context=context,
1623
+ operation=operation,
1624
+ app_key=app_key,
1625
+ apply_id=apply_id,
1626
+ answers=answers,
1627
+ fields=fields,
1628
+ force_refresh_form=force_refresh_form,
1629
+ )
1683
1630
  return {
1684
1631
  "profile": profile,
1685
1632
  "ws_id": session_profile.selected_ws_id,
1686
1633
  "ok": True,
1687
1634
  "request_route": self._request_route_payload(context),
1688
- "data": {
1689
- "operation": inferred_operation,
1690
- "app_key": app_key,
1691
- "apply_id": apply_id,
1692
- "normalized_answers": normalized_answers,
1693
- "resolved_fields": resolved_fields,
1694
- "support_matrix": support_matrix,
1695
- "validation": validation,
1696
- "dependencies": {
1697
- "question_relations_present": bool(question_relations),
1698
- "question_relations": question_relations,
1699
- "option_links": option_links,
1700
- },
1701
- "ready_to_submit": validation["valid"],
1702
- "blockers": blockers,
1703
- "recommended_next_actions": actions,
1704
- },
1635
+ "data": plan_data,
1705
1636
  }
1706
1637
 
1707
1638
  return self._run_record_tool(profile, runner)
1708
1639
 
1640
+ def _build_record_write_preflight(
1641
+ self,
1642
+ *,
1643
+ profile: str,
1644
+ context, # type: ignore[no-untyped-def]
1645
+ operation: str,
1646
+ app_key: str,
1647
+ apply_id: int | None,
1648
+ answers: list[JSONObject],
1649
+ fields: JSONObject,
1650
+ force_refresh_form: bool,
1651
+ ) -> JSONObject:
1652
+ schema = self._get_form_schema(profile, context, app_key, force_refresh=force_refresh_form)
1653
+ index = _build_field_index(schema)
1654
+ normalized_fields = fields or {}
1655
+ normalized_answers_input = answers or []
1656
+ resolved_fields = self._collect_write_plan_field_refs(fields=normalized_fields, answers=normalized_answers_input, index=index)
1657
+ support_matrix = _summarize_write_support(resolved_fields)
1658
+ invalid_fields: list[JSONObject] = []
1659
+ normalized_answers: list[JSONObject] = []
1660
+ try:
1661
+ normalized_answers = self._resolve_answers(
1662
+ profile,
1663
+ context,
1664
+ app_key,
1665
+ answers=normalized_answers_input,
1666
+ fields=normalized_fields,
1667
+ force_refresh_form=force_refresh_form,
1668
+ )
1669
+ except RecordInputError as error:
1670
+ invalid_fields.append(
1671
+ {
1672
+ "location": _stringify_json(error.details.get("location") if error.details else None),
1673
+ "message": error.message,
1674
+ "error_code": error.error_code,
1675
+ "field": error.details.get("field") if error.details and isinstance(error.details.get("field"), dict) else None,
1676
+ "expected_format": error.details.get("expected_format") if error.details and isinstance(error.details.get("expected_format"), dict) else None,
1677
+ "received_value": error.details.get("received_value") if error.details else None,
1678
+ }
1679
+ )
1680
+ readonly_or_system_fields = [
1681
+ {
1682
+ "que_id": entry.get("que_id"),
1683
+ "que_title": entry.get("que_title"),
1684
+ "que_type": entry.get("que_type"),
1685
+ "readonly": entry.get("readonly"),
1686
+ "system": entry.get("system"),
1687
+ "source": entry.get("source"),
1688
+ "requested": entry.get("requested"),
1689
+ }
1690
+ for entry in resolved_fields
1691
+ if bool(entry.get("resolved")) and (bool(entry.get("readonly")) or bool(entry.get("system")))
1692
+ ]
1693
+ provided_field_ids = {
1694
+ str(answer.get("queId"))
1695
+ for answer in normalized_answers
1696
+ if isinstance(answer.get("queId"), int) and int(answer["queId"]) > 0
1697
+ }
1698
+ missing_required_fields = []
1699
+ for field in index.by_id.values():
1700
+ if not field.required or str(field.que_id) in provided_field_ids:
1701
+ continue
1702
+ missing_required_fields.append(
1703
+ {
1704
+ "que_id": field.que_id,
1705
+ "que_title": field.que_title,
1706
+ "que_type": field.que_type,
1707
+ "reason": "required field not provided",
1708
+ }
1709
+ )
1710
+ question_relations = _collect_question_relations(schema)
1711
+ option_links = _collect_option_links(resolved_fields)
1712
+ validation_warnings = [
1713
+ "record_write performs static preflight from form metadata before apply; runtime visibility and dynamic linkage can still reject writes."
1714
+ ]
1715
+ if question_relations:
1716
+ validation_warnings.append(
1717
+ "form contains questionRelations; linked visibility and runtime required rules may differ at submit time."
1718
+ )
1719
+ validation = {
1720
+ "valid": not invalid_fields and not missing_required_fields and not readonly_or_system_fields,
1721
+ "missing_required_fields": missing_required_fields,
1722
+ "likely_hidden_required_fields": [],
1723
+ "readonly_or_system_fields": readonly_or_system_fields,
1724
+ "invalid_fields": invalid_fields,
1725
+ "warnings": validation_warnings,
1726
+ }
1727
+ field_errors = self._record_write_field_errors(
1728
+ invalid_fields=invalid_fields,
1729
+ missing_required_fields=missing_required_fields,
1730
+ readonly_or_system_fields=readonly_or_system_fields,
1731
+ )
1732
+ blockers = []
1733
+ if invalid_fields:
1734
+ blockers.append("payload contains invalid field values")
1735
+ if missing_required_fields:
1736
+ blockers.append("required fields are missing")
1737
+ if readonly_or_system_fields:
1738
+ blockers.append("payload writes readonly or system-managed fields")
1739
+ actions = ["Use record_schema_get when field titles or field ids are ambiguous."]
1740
+ if support_matrix["restricted"]:
1741
+ actions.append("Review write_format.required_presteps for restricted fields before submit.")
1742
+ if invalid_fields:
1743
+ actions.append("Fix invalid_fields before applying the write.")
1744
+ if missing_required_fields:
1745
+ actions.append("Fill missing required fields before applying the write.")
1746
+ if readonly_or_system_fields:
1747
+ actions.append("Remove readonly/system fields from payload before applying the write.")
1748
+ if question_relations:
1749
+ actions.append("Treat static preflight as conservative only because linked fields can still appear at runtime.")
1750
+ return {
1751
+ "operation": operation if operation in {"create", "update"} else ("update" if apply_id else "create"),
1752
+ "app_key": app_key,
1753
+ "apply_id": apply_id,
1754
+ "normalized_answers": normalized_answers,
1755
+ "resolved_fields": resolved_fields,
1756
+ "support_matrix": support_matrix,
1757
+ "validation": validation,
1758
+ "field_errors": field_errors,
1759
+ "dependencies": {
1760
+ "question_relations_present": bool(question_relations),
1761
+ "question_relations": question_relations,
1762
+ "option_links": option_links,
1763
+ },
1764
+ "ready_to_submit": validation["valid"],
1765
+ "blockers": blockers,
1766
+ "recommended_next_actions": actions,
1767
+ }
1768
+
1709
1769
  def record_query(
1710
1770
  self,
1711
1771
  *,
@@ -2950,6 +3010,23 @@ class RecordTools(ToolBase):
2950
3010
  normalized.append(payload)
2951
3011
  return normalized
2952
3012
 
3013
+ def _record_write_normalized_payload(
3014
+ self,
3015
+ *,
3016
+ operation: str,
3017
+ record_id: int | None,
3018
+ record_ids: list[int],
3019
+ normalized_answers: list[JSONObject],
3020
+ submit_type: int,
3021
+ ) -> JSONObject:
3022
+ return {
3023
+ "operation": operation,
3024
+ "record_id": record_id,
3025
+ "record_ids": record_ids,
3026
+ "answers": normalized_answers,
3027
+ "submit_type": submit_type,
3028
+ }
3029
+
2953
3030
  def _record_write_human_review_payload(self, operation: str, *, enabled: bool) -> JSONObject | None:
2954
3031
  if not enabled:
2955
3032
  return None
@@ -2959,38 +3036,41 @@ class RecordTools(ToolBase):
2959
3036
  "message": "Read the current record first and confirm the exact target before applying this high-risk write.",
2960
3037
  }
2961
3038
 
2962
- def _record_write_plan_response(
3039
+ def _record_write_blocked_response(
2963
3040
  self,
2964
- raw_plan: JSONObject,
3041
+ raw_preflight: JSONObject,
2965
3042
  *,
2966
3043
  operation: str,
2967
3044
  normalized_payload: JSONObject,
2968
3045
  output_profile: str,
2969
3046
  human_review: bool,
3047
+ target_resource: JSONObject,
2970
3048
  ) -> JSONObject:
2971
- plan_data = cast(JSONObject, raw_plan.get("data", {}))
3049
+ plan_data = cast(JSONObject, raw_preflight.get("data", {}))
2972
3050
  validation = cast(JSONObject, plan_data.get("validation", {}))
3051
+ field_errors = self._record_write_public_field_errors(plan_data)
2973
3052
  warnings_payload = validation.get("warnings", [])
2974
- warnings = [{"code": "PLAN_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
3053
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
2975
3054
  response: JSONObject = {
2976
- "profile": raw_plan.get("profile"),
2977
- "ws_id": raw_plan.get("ws_id"),
2978
- "ok": bool(raw_plan.get("ok", True)),
2979
- "request_route": raw_plan.get("request_route"),
3055
+ "profile": raw_preflight.get("profile"),
3056
+ "ws_id": raw_preflight.get("ws_id"),
3057
+ "ok": False,
3058
+ "request_route": raw_preflight.get("request_route"),
2980
3059
  "warnings": warnings,
2981
3060
  "output_profile": output_profile,
2982
3061
  "data": {
2983
- "action": {"operation": operation, "mode": "plan"},
2984
- "resource": {"type": "record", "app_key": plan_data.get("app_key"), "record_id": plan_data.get("apply_id")},
3062
+ "action": {"operation": operation, "executed": False},
3063
+ "resource": target_resource,
2985
3064
  "verification": None,
2986
3065
  "normalized_payload": normalized_payload,
2987
3066
  "blockers": plan_data.get("blockers", []),
3067
+ "field_errors": field_errors,
2988
3068
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
2989
3069
  },
2990
3070
  }
2991
3071
  if output_profile == "verbose":
2992
3072
  response["data"]["debug"] = {
2993
- "legacy_plan": plan_data,
3073
+ "preflight": plan_data,
2994
3074
  }
2995
3075
  return response
2996
3076
 
@@ -3002,31 +3082,107 @@ class RecordTools(ToolBase):
3002
3082
  normalized_payload: JSONObject,
3003
3083
  output_profile: str,
3004
3084
  human_review: bool,
3085
+ preflight: JSONObject | None,
3005
3086
  ) -> JSONObject:
3087
+ preflight_data = cast(JSONObject, preflight.get("data", {})) if isinstance(preflight, dict) else {}
3088
+ validation = cast(JSONObject, preflight_data.get("validation", {}))
3089
+ warnings_payload = validation.get("warnings", [])
3090
+ warnings = [{"code": "PREFLIGHT_WARNING", "message": str(item)} for item in warnings_payload] if isinstance(warnings_payload, list) else []
3006
3091
  response: JSONObject = {
3007
3092
  "profile": raw_apply.get("profile"),
3008
3093
  "ws_id": raw_apply.get("ws_id"),
3009
3094
  "ok": bool(raw_apply.get("ok", True)),
3010
3095
  "request_route": raw_apply.get("request_route"),
3011
- "warnings": [],
3096
+ "warnings": warnings,
3012
3097
  "output_profile": output_profile,
3013
3098
  "data": {
3014
- "action": {"operation": operation, "mode": "apply"},
3099
+ "action": {"operation": operation, "executed": True},
3015
3100
  "resource": raw_apply.get("resource"),
3016
3101
  "verification": raw_apply.get("verification"),
3017
3102
  "normalized_payload": normalized_payload,
3018
3103
  "blockers": [],
3104
+ "field_errors": [],
3019
3105
  "human_review": self._record_write_human_review_payload(operation, enabled=human_review),
3020
3106
  },
3021
3107
  }
3022
3108
  if output_profile == "verbose":
3023
- response["data"]["debug"] = {
3109
+ debug: JSONObject = {
3024
3110
  "legacy_result": raw_apply.get("result"),
3025
3111
  "status": raw_apply.get("status"),
3026
3112
  "write_verified": raw_apply.get("write_verified"),
3027
3113
  }
3114
+ if preflight_data:
3115
+ debug["preflight"] = preflight_data
3116
+ response["data"]["debug"] = debug
3028
3117
  return response
3029
3118
 
3119
+ def _record_write_public_field_errors(self, plan_data: JSONObject) -> list[JSONObject]:
3120
+ existing = plan_data.get("field_errors")
3121
+ if isinstance(existing, list):
3122
+ return [cast(JSONObject, item) for item in existing if isinstance(item, dict)]
3123
+ validation = cast(JSONObject, plan_data.get("validation", {}))
3124
+ invalid_fields = cast(list[JSONObject], validation.get("invalid_fields", []))
3125
+ missing_required_fields = cast(list[JSONObject], validation.get("missing_required_fields", []))
3126
+ readonly_or_system_fields = cast(list[JSONObject], validation.get("readonly_or_system_fields", []))
3127
+ return self._record_write_field_errors(
3128
+ invalid_fields=invalid_fields,
3129
+ missing_required_fields=missing_required_fields,
3130
+ readonly_or_system_fields=readonly_or_system_fields,
3131
+ )
3132
+
3133
+ def _record_write_field_errors(
3134
+ self,
3135
+ *,
3136
+ invalid_fields: list[JSONObject],
3137
+ missing_required_fields: list[JSONObject],
3138
+ readonly_or_system_fields: list[JSONObject],
3139
+ ) -> list[JSONObject]:
3140
+ errors: list[JSONObject] = []
3141
+ for item in invalid_fields:
3142
+ errors.append(
3143
+ {
3144
+ "error_code": item.get("error_code") or "INVALID_FIELD_VALUE",
3145
+ "message": item.get("message") or "invalid field value",
3146
+ "field": item.get("field"),
3147
+ "location": item.get("location"),
3148
+ "expected_format": item.get("expected_format"),
3149
+ "received_value": item.get("received_value"),
3150
+ }
3151
+ )
3152
+ for item in missing_required_fields:
3153
+ errors.append(
3154
+ {
3155
+ "error_code": "MISSING_REQUIRED_FIELD",
3156
+ "message": item.get("reason") or "required field not provided",
3157
+ "field": {
3158
+ "que_id": item.get("que_id"),
3159
+ "que_title": item.get("que_title"),
3160
+ "que_type": item.get("que_type"),
3161
+ },
3162
+ "location": "fields",
3163
+ "expected_format": None,
3164
+ "received_value": None,
3165
+ }
3166
+ )
3167
+ for item in readonly_or_system_fields:
3168
+ errors.append(
3169
+ {
3170
+ "error_code": "READONLY_OR_SYSTEM_FIELD",
3171
+ "message": "field is readonly or system-managed",
3172
+ "field": {
3173
+ "que_id": item.get("que_id"),
3174
+ "que_title": item.get("que_title"),
3175
+ "que_type": item.get("que_type"),
3176
+ "readonly": item.get("readonly"),
3177
+ "system": item.get("system"),
3178
+ },
3179
+ "location": item.get("source") or "fields",
3180
+ "expected_format": None,
3181
+ "received_value": item.get("requested"),
3182
+ }
3183
+ )
3184
+ return errors
3185
+
3030
3186
  def _record_delete_many(self, *, profile: str, app_key: str, record_ids: list[int]) -> JSONObject:
3031
3187
  if not app_key:
3032
3188
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
@@ -3122,11 +3278,11 @@ class RecordTools(ToolBase):
3122
3278
  )
3123
3279
 
3124
3280
  def _resolve_field_from_answer_item(self, item: JSONObject, index: FieldIndex) -> FormField:
3125
- for key in ("queId", "que_id", "queTitle", "que_title"):
3281
+ for key in ("queId", "que_id", "queTitle", "que_title", "field_id", "fieldId"):
3126
3282
  if key in item:
3127
3283
  return self._resolve_field_selector(cast(str | int, item[key]), index, location="answers")
3128
3284
  raise RecordInputError(
3129
- message="answer item requires queId/que_id or queTitle/que_title",
3285
+ message="answer item requires queId/que_id, queTitle/que_title, or field_id",
3130
3286
  error_code="MISSING_FIELD_SELECTOR",
3131
3287
  fix_hint="Provide a field selector in each answer item.",
3132
3288
  )
@@ -3452,7 +3608,10 @@ class RecordTools(ToolBase):
3452
3608
  for item in answers:
3453
3609
  if not isinstance(item, dict):
3454
3610
  continue
3455
- selector = item.get("queId", item.get("que_id", item.get("queTitle", item.get("que_title"))))
3611
+ selector = item.get(
3612
+ "field_id",
3613
+ item.get("fieldId", item.get("queId", item.get("que_id", item.get("queTitle", item.get("que_title"))))),
3614
+ )
3456
3615
  if selector is None:
3457
3616
  continue
3458
3617
  refs.append(self._resolve_write_plan_field_ref("answers", str(selector), index))