@josephyan/qingflow-cli 0.2.0-beta.59 → 0.2.0-beta.61

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.
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from copy import deepcopy
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
5
  import json
6
6
  import os
7
7
  import re
@@ -129,6 +129,14 @@ class ResolvedApp:
129
129
  tag_ids: list[int]
130
130
 
131
131
 
132
+ @dataclass(slots=True)
133
+ class PermissionCheckOutcome:
134
+ block: JSONObject | None = None
135
+ warnings: list[dict[str, Any]] = field(default_factory=list)
136
+ details: JSONObject = field(default_factory=dict)
137
+ verification: JSONObject = field(default_factory=dict)
138
+
139
+
132
140
  class AiBuilderFacade:
133
141
  def __init__(
134
142
  self,
@@ -804,21 +812,53 @@ class AiBuilderFacade:
804
812
  app_key: str,
805
813
  app_title: str = "",
806
814
  ) -> JSONObject:
815
+ normalized_args = {"tag_id": tag_id, "app_key": app_key, "app_title": app_title}
807
816
  if _coerce_positive_int(tag_id) is None:
808
817
  return _failed("TAG_ID_REQUIRED", "tag_id must be positive", suggested_next_call=None)
809
- package_permission_block = self._guard_package_permission(
818
+ permission_outcomes: list[PermissionCheckOutcome] = []
819
+ package_permission_outcome = self._guard_package_permission(
810
820
  profile=profile,
811
821
  tag_id=tag_id,
812
822
  required_permission="edit_app",
813
- normalized_args={"tag_id": tag_id, "app_key": app_key, "app_title": app_title},
823
+ normalized_args=normalized_args,
814
824
  )
815
- if package_permission_block is not None:
816
- return package_permission_block
825
+ if package_permission_outcome.block is not None:
826
+ return package_permission_outcome.block
827
+ permission_outcomes.append(package_permission_outcome)
828
+
829
+ def finalize(response: JSONObject) -> JSONObject:
830
+ return _apply_permission_outcomes(response, *permission_outcomes)
831
+
817
832
  resolved = self.app_resolve(profile=profile, app_key=app_key)
818
833
  if resolved.get("status") == "failed":
819
- return resolved
820
- base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
821
- tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
834
+ return finalize(resolved)
835
+ resolved_outcome = _permission_outcome_from_result(resolved)
836
+ if resolved_outcome is not None:
837
+ permission_outcomes.append(resolved_outcome)
838
+ try:
839
+ base_before = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
840
+ tag_ids_before = _coerce_int_list(base_before.get("tagIds"))
841
+ except (QingflowApiError, RuntimeError) as error:
842
+ api_error = _coerce_api_error(error)
843
+ tag_ids_before = []
844
+ if _is_permission_restricted_api_error(api_error):
845
+ permission_outcomes.append(
846
+ _verification_read_outcome(
847
+ resource="app",
848
+ target={"app_key": app_key},
849
+ transport_error=api_error,
850
+ )
851
+ )
852
+ else:
853
+ return finalize(
854
+ _failed_from_api_error(
855
+ "PACKAGE_ATTACH_FAILED",
856
+ api_error,
857
+ normalized_args=normalized_args,
858
+ details={"tag_id": tag_id, "app_key": app_key},
859
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
860
+ )
861
+ )
822
862
  already_attached = tag_id in tag_ids_before
823
863
  try:
824
864
  self._attach_app_to_package(
@@ -829,33 +869,72 @@ class AiBuilderFacade:
829
869
  )
830
870
  except (QingflowApiError, RuntimeError) as error:
831
871
  api_error = _coerce_api_error(error)
832
- return _failed_from_api_error(
833
- "PACKAGE_ATTACH_FAILED",
834
- api_error,
835
- details={"tag_id": tag_id, "app_key": app_key},
836
- suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
872
+ return finalize(
873
+ _failed_from_api_error(
874
+ "PACKAGE_ATTACH_FAILED",
875
+ api_error,
876
+ normalized_args=normalized_args,
877
+ details=_with_state_read_blocked_details(
878
+ {"tag_id": tag_id, "app_key": app_key},
879
+ resource="package",
880
+ error=api_error,
881
+ ),
882
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
883
+ )
837
884
  )
838
- base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
839
- tag_ids_after = _coerce_int_list(base.get("tagIds"))
840
- attached = tag_id in tag_ids_after
841
- return {
885
+ verification_error: QingflowApiError | None = None
886
+ try:
887
+ base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
888
+ tag_ids_after = _coerce_int_list(base.get("tagIds"))
889
+ attached = tag_id in tag_ids_after
890
+ except (QingflowApiError, RuntimeError) as error:
891
+ verification_error = _coerce_api_error(error)
892
+ tag_ids_after = []
893
+ attached = False
894
+ if _is_permission_restricted_api_error(verification_error):
895
+ permission_outcomes.append(
896
+ _verification_read_outcome(
897
+ resource="app",
898
+ target={"app_key": app_key},
899
+ transport_error=verification_error,
900
+ )
901
+ )
902
+ else:
903
+ return finalize(
904
+ _failed_from_api_error(
905
+ "PACKAGE_ATTACH_FAILED",
906
+ verification_error,
907
+ normalized_args=normalized_args,
908
+ details={"tag_id": tag_id, "app_key": app_key},
909
+ suggested_next_call={"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
910
+ )
911
+ )
912
+ response = {
842
913
  "status": "success" if attached else "partial_success",
843
- "error_code": None,
844
- "recoverable": False,
845
- "message": "attached app to package" if attached else "app attachment could not be verified",
846
- "normalized_args": {"tag_id": tag_id, "app_key": app_key, "app_title": app_title},
914
+ "error_code": None if attached else "PACKAGE_ATTACH_READBACK_PENDING",
915
+ "recoverable": verification_error is not None or not attached,
916
+ "message": "attached app to package" if attached else "app attachment could not be fully verified",
917
+ "normalized_args": normalized_args,
847
918
  "missing_fields": [],
848
919
  "allowed_values": {},
849
920
  "details": {},
850
- "request_id": None,
921
+ "request_id": verification_error.request_id if verification_error is not None else None,
851
922
  "suggested_next_call": None if attached else {"tool_name": "package_attach_app", "arguments": {"profile": profile, "tag_id": tag_id, "app_key": app_key}},
852
923
  "noop": already_attached,
853
- "verification": {"tag_ids_before": tag_ids_before, "tag_ids_after": tag_ids_after},
924
+ "verification": {
925
+ "tag_ids_before": tag_ids_before,
926
+ "tag_ids_after": tag_ids_after,
927
+ "attachment_verified": attached if verification_error is None else None,
928
+ "readback_unavailable": verification_error is not None,
929
+ },
854
930
  "app_key": app_key,
855
931
  "tag_id": tag_id,
856
932
  "tag_ids_after": tag_ids_after,
857
933
  "attached": attached,
858
934
  }
935
+ if verification_error is not None:
936
+ response["details"]["verification_error"] = _transport_error_payload(verification_error)
937
+ return finalize(response)
859
938
 
860
939
  def app_release_edit_lock_if_mine(
861
940
  self,
@@ -979,6 +1058,42 @@ class AiBuilderFacade:
979
1058
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True)
980
1059
  except (QingflowApiError, RuntimeError) as exc:
981
1060
  api_error = _coerce_api_error(exc)
1061
+ if _is_permission_restricted_api_error(api_error):
1062
+ return {
1063
+ "status": "success",
1064
+ "error_code": None,
1065
+ "recoverable": False,
1066
+ "message": "resolved app key; metadata unverified",
1067
+ "normalized_args": {"app_key": app_key},
1068
+ "missing_fields": [],
1069
+ "allowed_values": {},
1070
+ "details": {
1071
+ "match_scope": "app_key",
1072
+ "lookup_permission_blocked": {
1073
+ "scope": "app",
1074
+ "target": {"app_key": app_key},
1075
+ "required_permission": None,
1076
+ "transport_error": _transport_error_payload(api_error),
1077
+ },
1078
+ "permission_check_skipped": True,
1079
+ },
1080
+ "request_id": api_error.request_id,
1081
+ "suggested_next_call": {"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1082
+ "noop": False,
1083
+ "warnings": [
1084
+ _warning(
1085
+ "PERMISSION_CHECK_SKIPPED",
1086
+ "app metadata lookup was permission-restricted; continuing with explicit app_key",
1087
+ scope="app",
1088
+ app_key=app_key,
1089
+ )
1090
+ ],
1091
+ "verification": {"metadata_unverified": True},
1092
+ "app_key": app_key,
1093
+ "app_name": app_key,
1094
+ "tag_ids": [],
1095
+ "publish_status": None,
1096
+ }
982
1097
  return _failed_from_api_error(
983
1098
  "APP_NOT_FOUND" if api_error.http_status == 404 else "APP_RESOLVE_FAILED",
984
1099
  api_error,
@@ -1285,67 +1400,73 @@ class AiBuilderFacade:
1285
1400
  app_key: str,
1286
1401
  required_permission: str,
1287
1402
  normalized_args: JSONObject,
1288
- ) -> JSONObject | None:
1403
+ ) -> PermissionCheckOutcome:
1289
1404
  try:
1290
1405
  permission_summary = self._read_app_permission_summary(profile=profile, app_key=app_key)
1291
1406
  except (QingflowApiError, RuntimeError) as error:
1292
1407
  api_error = _coerce_api_error(error)
1293
- return _failed(
1294
- "APP_PERMISSION_UNVERIFIED",
1295
- "could not confirm current user's builder permissions for this app",
1296
- normalized_args=normalized_args,
1297
- details={
1298
- "app_key": app_key,
1299
- "required_permission": required_permission,
1300
- "permission_read_error": {
1301
- "message": api_error.message,
1302
- "http_status": api_error.http_status,
1303
- "backend_code": api_error.backend_code,
1304
- "category": api_error.category,
1408
+ if _is_permission_restricted_api_error(api_error):
1409
+ return _permission_skip_outcome(
1410
+ scope="app",
1411
+ target={"app_key": app_key},
1412
+ required_permission=required_permission,
1413
+ transport_error=_transport_error_payload(api_error),
1414
+ )
1415
+ return PermissionCheckOutcome(
1416
+ block=_failed(
1417
+ "APP_PERMISSION_UNVERIFIED",
1418
+ "could not confirm current user's builder permissions for this app",
1419
+ normalized_args=normalized_args,
1420
+ details={
1421
+ "app_key": app_key,
1422
+ "required_permission": required_permission,
1423
+ "permission_read_error": {
1424
+ "message": api_error.message,
1425
+ "http_status": api_error.http_status,
1426
+ "backend_code": api_error.backend_code,
1427
+ "category": api_error.category,
1428
+ },
1305
1429
  },
1306
- },
1307
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1308
- request_id=api_error.request_id,
1309
- backend_code=api_error.backend_code,
1310
- http_status=None if api_error.http_status == 404 else api_error.http_status,
1430
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1431
+ request_id=api_error.request_id,
1432
+ backend_code=api_error.backend_code,
1433
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
1434
+ )
1311
1435
  )
1312
1436
  permission_key = {
1313
1437
  "edit_app": "can_edit_app",
1314
1438
  "data_manage": "can_manage_data",
1315
1439
  }.get(required_permission)
1316
1440
  if permission_key is None:
1317
- return None
1441
+ return PermissionCheckOutcome()
1318
1442
  permission_value = permission_summary.get(permission_key)
1319
1443
  if permission_value is None:
1320
- return _failed(
1321
- "APP_PERMISSION_UNVERIFIED",
1322
- "could not confirm current user's builder permissions for this app",
1323
- normalized_args=normalized_args,
1324
- details={
1325
- "app_key": app_key,
1326
- "required_permission": required_permission,
1327
- "permission_summary": permission_summary,
1328
- },
1329
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1444
+ return _permission_skip_outcome(
1445
+ scope="app",
1446
+ target={"app_key": app_key},
1447
+ required_permission=required_permission,
1448
+ permission_summary=permission_summary,
1330
1449
  )
1331
1450
  if permission_value is not False:
1332
- return None
1451
+ return PermissionCheckOutcome()
1333
1452
  error_code = "EDIT_APP_UNAUTHORIZED" if required_permission == "edit_app" else "DATA_MANAGE_UNAUTHORIZED"
1334
1453
  message = (
1335
1454
  "current user does not have builder edit-app permission on this app"
1336
1455
  if required_permission == "edit_app"
1337
1456
  else "current user does not have data-management permission on this app"
1338
1457
  )
1339
- return _failed(
1340
- error_code,
1341
- message,
1342
- normalized_args=normalized_args,
1343
- details={
1344
- "app_key": app_key,
1345
- "required_permission": required_permission,
1346
- "permission_summary": permission_summary,
1347
- },
1348
- suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1458
+ return PermissionCheckOutcome(
1459
+ block=_failed(
1460
+ error_code,
1461
+ message,
1462
+ normalized_args=normalized_args,
1463
+ details={
1464
+ "app_key": app_key,
1465
+ "required_permission": required_permission,
1466
+ "permission_summary": permission_summary,
1467
+ },
1468
+ suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": app_key}},
1469
+ )
1349
1470
  )
1350
1471
 
1351
1472
  def _guard_package_permission(
@@ -1355,29 +1476,38 @@ class AiBuilderFacade:
1355
1476
  tag_id: int,
1356
1477
  required_permission: str,
1357
1478
  normalized_args: JSONObject,
1358
- ) -> JSONObject | None:
1479
+ ) -> PermissionCheckOutcome:
1359
1480
  try:
1360
1481
  permission_summary = self._read_package_permission_summary(profile=profile, tag_id=tag_id)
1361
1482
  except (QingflowApiError, RuntimeError) as error:
1362
1483
  api_error = _coerce_api_error(error)
1363
- return _failed(
1364
- "PACKAGE_PERMISSION_UNVERIFIED",
1365
- "could not confirm current user's builder permissions for this package",
1366
- normalized_args=normalized_args,
1367
- details={
1368
- "tag_id": tag_id,
1369
- "required_permission": required_permission,
1370
- "permission_read_error": {
1371
- "message": api_error.message,
1372
- "http_status": api_error.http_status,
1373
- "backend_code": api_error.backend_code,
1374
- "category": api_error.category,
1484
+ if _is_permission_restricted_api_error(api_error):
1485
+ return _permission_skip_outcome(
1486
+ scope="package",
1487
+ target={"tag_id": tag_id},
1488
+ required_permission=required_permission,
1489
+ transport_error=_transport_error_payload(api_error),
1490
+ )
1491
+ return PermissionCheckOutcome(
1492
+ block=_failed(
1493
+ "PACKAGE_PERMISSION_UNVERIFIED",
1494
+ "could not confirm current user's builder permissions for this package",
1495
+ normalized_args=normalized_args,
1496
+ details={
1497
+ "tag_id": tag_id,
1498
+ "required_permission": required_permission,
1499
+ "permission_read_error": {
1500
+ "message": api_error.message,
1501
+ "http_status": api_error.http_status,
1502
+ "backend_code": api_error.backend_code,
1503
+ "category": api_error.category,
1504
+ },
1375
1505
  },
1376
- },
1377
- suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1378
- request_id=api_error.request_id,
1379
- backend_code=api_error.backend_code,
1380
- http_status=None if api_error.http_status == 404 else api_error.http_status,
1506
+ suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1507
+ request_id=api_error.request_id,
1508
+ backend_code=api_error.backend_code,
1509
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
1510
+ )
1381
1511
  )
1382
1512
  permission_specs = {
1383
1513
  "add_app": (
@@ -1398,12 +1528,21 @@ class AiBuilderFacade:
1398
1528
  }
1399
1529
  permission_key, error_code, message = permission_specs.get(required_permission, (None, None, None))
1400
1530
  if permission_key is None:
1401
- return None
1531
+ return PermissionCheckOutcome()
1402
1532
  permission_value = permission_summary.get(permission_key)
1403
1533
  if permission_value is None:
1404
- return _failed(
1405
- "PACKAGE_PERMISSION_UNVERIFIED",
1406
- "could not confirm current user's builder permissions for this package",
1534
+ return _permission_skip_outcome(
1535
+ scope="package",
1536
+ target={"tag_id": tag_id},
1537
+ required_permission=required_permission,
1538
+ permission_summary=permission_summary,
1539
+ )
1540
+ if permission_value is not False:
1541
+ return PermissionCheckOutcome()
1542
+ return PermissionCheckOutcome(
1543
+ block=_failed(
1544
+ error_code or "PACKAGE_PERMISSION_UNVERIFIED",
1545
+ message or "current user does not have the required package permission",
1407
1546
  normalized_args=normalized_args,
1408
1547
  details={
1409
1548
  "tag_id": tag_id,
@@ -1412,18 +1551,6 @@ class AiBuilderFacade:
1412
1551
  },
1413
1552
  suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1414
1553
  )
1415
- if permission_value is not False:
1416
- return None
1417
- return _failed(
1418
- error_code or "PACKAGE_PERMISSION_UNVERIFIED",
1419
- message or "current user does not have the required package permission",
1420
- normalized_args=normalized_args,
1421
- details={
1422
- "tag_id": tag_id,
1423
- "required_permission": required_permission,
1424
- "permission_summary": permission_summary,
1425
- },
1426
- suggested_next_call={"tool_name": "package_get_base", "arguments": {"profile": profile, "tag_id": tag_id}},
1427
1554
  )
1428
1555
 
1429
1556
  def _guard_portal_permission(
@@ -1433,25 +1560,26 @@ class AiBuilderFacade:
1433
1560
  dash_key: str,
1434
1561
  normalized_args: JSONObject,
1435
1562
  portal_result: dict[str, Any],
1436
- ) -> JSONObject | None:
1563
+ ) -> PermissionCheckOutcome:
1437
1564
  permission_summary = self._read_portal_permission_summary(dash_key=dash_key, portal_result=portal_result)
1438
1565
  permission_value = permission_summary.get("can_edit_portal")
1439
1566
  if permission_value is None:
1440
- return _failed(
1441
- "PORTAL_PERMISSION_UNVERIFIED",
1442
- "could not confirm current user's builder permissions for this portal",
1567
+ return _permission_skip_outcome(
1568
+ scope="portal",
1569
+ target={"dash_key": dash_key},
1570
+ required_permission="edit_portal",
1571
+ permission_summary=permission_summary,
1572
+ )
1573
+ if permission_value is not False:
1574
+ return PermissionCheckOutcome()
1575
+ return PermissionCheckOutcome(
1576
+ block=_failed(
1577
+ "PORTAL_EDIT_UNAUTHORIZED",
1578
+ "current user does not have builder edit permission on this portal",
1443
1579
  normalized_args=normalized_args,
1444
1580
  details={"dash_key": dash_key, "permission_summary": permission_summary},
1445
1581
  suggested_next_call={"tool_name": "portal_read_summary", "arguments": {"profile": profile, "dash_key": dash_key}},
1446
1582
  )
1447
- if permission_value is not False:
1448
- return None
1449
- return _failed(
1450
- "PORTAL_EDIT_UNAUTHORIZED",
1451
- "current user does not have builder edit permission on this portal",
1452
- normalized_args=normalized_args,
1453
- details={"dash_key": dash_key, "permission_summary": permission_summary},
1454
- suggested_next_call={"tool_name": "portal_read_summary", "arguments": {"profile": profile, "dash_key": dash_key}},
1455
1583
  )
1456
1584
 
1457
1585
  def app_read_summary(self, *, profile: str, app_key: str) -> JSONObject:
@@ -2226,6 +2354,7 @@ class AiBuilderFacade:
2226
2354
  "update_fields": [patch.model_dump(mode="json") for patch in update_fields],
2227
2355
  "remove_fields": [patch.model_dump(mode="json") for patch in remove_fields],
2228
2356
  }
2357
+ permission_outcomes: list[PermissionCheckOutcome] = []
2229
2358
  requested_field_changes = bool(add_fields or update_fields or remove_fields)
2230
2359
  resolved: JSONObject
2231
2360
  if app_key:
@@ -2234,31 +2363,37 @@ class AiBuilderFacade:
2234
2363
  resolved = self.app_resolve(profile=profile, app_name=app_name, package_tag_id=package_tag_id)
2235
2364
  else:
2236
2365
  return _failed("APP_NAME_REQUIRED", "app_name or app_key is required", normalized_args=normalized_args, suggested_next_call=None)
2366
+
2367
+ def finalize(response: JSONObject) -> JSONObject:
2368
+ return _apply_permission_outcomes(response, *permission_outcomes)
2369
+
2237
2370
  if resolved.get("status") == "failed":
2238
2371
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
2239
2372
  resolved["normalized_args"] = normalized_args
2240
2373
  if not create_if_missing or app_key or resolved.get("error_code") != "APP_NOT_FOUND":
2241
- return resolved
2374
+ return finalize(resolved)
2242
2375
  permission_tag_id = _coerce_positive_int(package_tag_id)
2243
2376
  if permission_tag_id is None:
2244
2377
  permission_tag_id = 0
2245
- add_permission_block = self._guard_package_permission(
2378
+ add_permission_outcome = self._guard_package_permission(
2246
2379
  profile=profile,
2247
2380
  tag_id=permission_tag_id,
2248
2381
  required_permission="add_app",
2249
2382
  normalized_args=normalized_args,
2250
2383
  )
2251
- if add_permission_block is not None:
2252
- return add_permission_block
2384
+ if add_permission_outcome.block is not None:
2385
+ return add_permission_outcome.block
2386
+ permission_outcomes.append(add_permission_outcome)
2253
2387
  if requested_field_changes:
2254
- edit_permission_block = self._guard_package_permission(
2388
+ edit_permission_outcome = self._guard_package_permission(
2255
2389
  profile=profile,
2256
2390
  tag_id=permission_tag_id,
2257
2391
  required_permission="edit_app",
2258
2392
  normalized_args=normalized_args,
2259
2393
  )
2260
- if edit_permission_block is not None:
2261
- return edit_permission_block
2394
+ if edit_permission_outcome.block is not None:
2395
+ return edit_permission_outcome.block
2396
+ permission_outcomes.append(edit_permission_outcome)
2262
2397
  resolved = self._create_target_app_shell(
2263
2398
  profile=profile,
2264
2399
  app_name=app_name,
@@ -2267,23 +2402,27 @@ class AiBuilderFacade:
2267
2402
  if resolved.get("status") == "failed":
2268
2403
  if not isinstance(resolved.get("normalized_args"), dict) or not resolved.get("normalized_args"):
2269
2404
  resolved["normalized_args"] = normalized_args
2270
- return resolved
2405
+ return finalize(resolved)
2406
+ resolved_outcome = _permission_outcome_from_result(resolved)
2407
+ if resolved_outcome is not None:
2408
+ permission_outcomes.append(resolved_outcome)
2271
2409
  target = ResolvedApp(
2272
2410
  app_key=str(resolved["app_key"]),
2273
2411
  app_name=str(resolved["app_name"]),
2274
2412
  tag_ids=_coerce_int_list(resolved.get("tag_ids")),
2275
2413
  )
2276
2414
  if not bool(resolved.get("created")):
2277
- permission_block = self._guard_app_permission(
2415
+ permission_outcome = self._guard_app_permission(
2278
2416
  profile=profile,
2279
2417
  app_key=target.app_key,
2280
2418
  required_permission="edit_app",
2281
2419
  normalized_args=normalized_args,
2282
2420
  )
2283
- if permission_block is not None:
2284
- return permission_block
2421
+ if permission_outcome.block is not None:
2422
+ return permission_outcome.block
2423
+ permission_outcomes.append(permission_outcome)
2285
2424
  if bool(resolved.get("created")) and not requested_field_changes:
2286
- return {
2425
+ return finalize({
2287
2426
  "status": "success",
2288
2427
  "error_code": None,
2289
2428
  "recoverable": False,
@@ -2310,21 +2449,21 @@ class AiBuilderFacade:
2310
2449
  "package_attached": None if package_tag_id is None else package_tag_id in target.tag_ids,
2311
2450
  "publish_requested": False,
2312
2451
  "published": False,
2313
- }
2452
+ })
2314
2453
  schema_readback_delayed = False
2315
2454
  try:
2316
2455
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=target.app_key)
2317
2456
  except (QingflowApiError, RuntimeError) as error:
2318
2457
  api_error = _coerce_api_error(error)
2319
2458
  if not bool(resolved.get("created")) or api_error.http_status != 404:
2320
- return _failed_from_api_error(
2459
+ return finalize(_failed_from_api_error(
2321
2460
  "SCHEMA_READBACK_FAILED",
2322
2461
  api_error,
2323
2462
  normalized_args=normalized_args,
2324
2463
  allowed_values={"field_types": [item.value for item in PublicFieldType]},
2325
- details={"app_key": target.app_key},
2464
+ details=_with_state_read_blocked_details({"app_key": target.app_key}, resource="schema", error=api_error),
2326
2465
  suggested_next_call={"tool_name": "app_read_summary", "arguments": {"profile": profile, "app_key": target.app_key}},
2327
- )
2466
+ ))
2328
2467
  schema_result = _empty_schema_result(target.app_name)
2329
2468
  _schema_source = "synthetic_new_app"
2330
2469
  schema_readback_delayed = True
@@ -2442,7 +2581,7 @@ class AiBuilderFacade:
2442
2581
  "package_attached": package_attached,
2443
2582
  }
2444
2583
  response["details"]["relation_field_count"] = relation_field_count
2445
- return self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
2584
+ return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
2446
2585
 
2447
2586
  payload = _build_form_payload_from_fields(
2448
2587
  title=schema_result.get("formTitle") or target.app_name,
@@ -2581,7 +2720,7 @@ class AiBuilderFacade:
2581
2720
  "http_status": verification_error.http_status,
2582
2721
  "backend_code": verification_error.backend_code,
2583
2722
  }
2584
- return response
2723
+ return finalize(response)
2585
2724
 
2586
2725
  def app_layout_apply(
2587
2726
  self,
@@ -2599,25 +2738,31 @@ class AiBuilderFacade:
2599
2738
  "sections": requested_sections,
2600
2739
  "publish": publish,
2601
2740
  }
2602
- permission_block = self._guard_app_permission(
2741
+ permission_outcomes: list[PermissionCheckOutcome] = []
2742
+ permission_outcome = self._guard_app_permission(
2603
2743
  profile=profile,
2604
2744
  app_key=app_key,
2605
2745
  required_permission="edit_app",
2606
2746
  normalized_args=normalized_args,
2607
2747
  )
2608
- if permission_block is not None:
2609
- return permission_block
2748
+ if permission_outcome.block is not None:
2749
+ return permission_outcome.block
2750
+ permission_outcomes.append(permission_outcome)
2751
+
2752
+ def finalize(response: JSONObject) -> JSONObject:
2753
+ return _apply_permission_outcomes(response, *permission_outcomes)
2754
+
2610
2755
  try:
2611
2756
  schema_result, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2612
2757
  except (QingflowApiError, RuntimeError) as error:
2613
2758
  api_error = _coerce_api_error(error)
2614
- return _failed_from_api_error(
2759
+ return finalize(_failed_from_api_error(
2615
2760
  "LAYOUT_READ_FAILED",
2616
2761
  api_error,
2617
2762
  normalized_args=normalized_args,
2618
- details={"app_key": app_key},
2763
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="schema", error=api_error),
2619
2764
  suggested_next_call={"tool_name": "app_read_layout_summary", "arguments": {"profile": profile, "app_key": app_key}},
2620
- )
2765
+ ))
2621
2766
  parsed = _parse_schema(schema_result)
2622
2767
  current_fields = parsed["fields"]
2623
2768
  requested_sections, missing_selectors = _resolve_layout_sections_to_names(requested_sections, current_fields)
@@ -2709,7 +2854,7 @@ class AiBuilderFacade:
2709
2854
  },
2710
2855
  "verified": True,
2711
2856
  }
2712
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2857
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2713
2858
  payload = _build_form_payload_from_existing_schema(
2714
2859
  current_schema=schema_result,
2715
2860
  layout=target_layout,
@@ -2742,7 +2887,7 @@ class AiBuilderFacade:
2742
2887
  fallback_applied = "flatten_sections"
2743
2888
  except (QingflowApiError, RuntimeError) as fallback_error:
2744
2889
  api_fallback_error = _coerce_api_error(fallback_error)
2745
- return _failed_from_api_error(
2890
+ return finalize(_failed_from_api_error(
2746
2891
  "LAYOUT_APPLY_FAILED",
2747
2892
  api_fallback_error,
2748
2893
  normalized_args=normalized_args,
@@ -2755,9 +2900,9 @@ class AiBuilderFacade:
2755
2900
  "fallback_layout": flattened_layout,
2756
2901
  },
2757
2902
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
2758
- )
2903
+ ))
2759
2904
  else:
2760
- return _failed_from_api_error(
2905
+ return finalize(_failed_from_api_error(
2761
2906
  "LAYOUT_APPLY_FAILED",
2762
2907
  api_error,
2763
2908
  normalized_args=normalized_args,
@@ -2768,7 +2913,7 @@ class AiBuilderFacade:
2768
2913
  "current_field_names": [field["name"] for field in current_fields],
2769
2914
  },
2770
2915
  suggested_next_call={"tool_name": "app_layout_apply", "arguments": {"profile": profile, **normalized_args}},
2771
- )
2916
+ ))
2772
2917
  try:
2773
2918
  verified_schema, _verified_schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2774
2919
  except (QingflowApiError, RuntimeError) as error:
@@ -2798,7 +2943,7 @@ class AiBuilderFacade:
2798
2943
  "verified": False,
2799
2944
  }
2800
2945
  response["request_id"] = api_error.request_id
2801
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2946
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2802
2947
  verified_layout = _parse_schema(verified_schema)["layout"]
2803
2948
  layout_verified = _layouts_equal(verified_layout, applied_layout) or _layouts_semantically_equal(verified_layout, applied_layout)
2804
2949
  raw_layout_has_content = _schema_has_layout_content(verified_schema)
@@ -2810,19 +2955,24 @@ class AiBuilderFacade:
2810
2955
  warnings: list[dict[str, Any]] = []
2811
2956
  if not layout_summary_verified:
2812
2957
  warnings.append(_warning("LAYOUT_SUMMARY_UNVERIFIED", "layout summary is incomplete relative to raw schema readback"))
2958
+ if fallback_applied is not None and layout_verified and layout_summary_verified:
2959
+ warnings.append(_warning("LAYOUT_FALLBACK_APPLIED", "layout readback normalized sectioned layout into flat layout while preserving field placement"))
2813
2960
  response = {
2814
- "status": "success" if layout_verified and layout_summary_verified and fallback_applied is None else "partial_success",
2961
+ "status": "success" if layout_verified and layout_summary_verified else "partial_success",
2815
2962
  "error_code": (
2816
2963
  None
2817
- if layout_verified and layout_summary_verified and fallback_applied is None
2964
+ if layout_verified and layout_summary_verified
2818
2965
  else "LAYOUT_SUMMARY_UNVERIFIED"
2819
2966
  if not layout_summary_verified
2820
2967
  else "LAYOUT_READBACK_MISMATCH"
2821
2968
  ),
2822
- "recoverable": not (layout_verified and layout_summary_verified and fallback_applied is None),
2969
+ "recoverable": not (layout_verified and layout_summary_verified),
2823
2970
  "message": (
2971
+ "applied app layout with flattened section fallback"
2972
+ if layout_verified and layout_summary_verified and fallback_applied is not None
2973
+ else
2824
2974
  "applied app layout"
2825
- if layout_verified and layout_summary_verified and fallback_applied is None
2975
+ if layout_verified and layout_summary_verified
2826
2976
  else "applied app layout; raw layout verified but compact summary is incomplete"
2827
2977
  if layout_verified and not layout_summary_verified
2828
2978
  else "applied app layout with flattened section fallback"
@@ -2848,7 +2998,7 @@ class AiBuilderFacade:
2848
2998
  },
2849
2999
  "verified": layout_verified,
2850
3000
  }
2851
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3001
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2852
3002
 
2853
3003
  def app_flow_apply(
2854
3004
  self,
@@ -2867,34 +3017,40 @@ class AiBuilderFacade:
2867
3017
  "transitions": transitions,
2868
3018
  "publish": publish,
2869
3019
  }
2870
- permission_block = self._guard_app_permission(
3020
+ permission_outcomes: list[PermissionCheckOutcome] = []
3021
+ permission_outcome = self._guard_app_permission(
2871
3022
  profile=profile,
2872
3023
  app_key=app_key,
2873
3024
  required_permission="data_manage",
2874
3025
  normalized_args=normalized_args,
2875
3026
  )
2876
- if permission_block is not None:
2877
- return permission_block
3027
+ if permission_outcome.block is not None:
3028
+ return permission_outcome.block
3029
+ permission_outcomes.append(permission_outcome)
3030
+
3031
+ def finalize(response: JSONObject) -> JSONObject:
3032
+ return _apply_permission_outcomes(response, *permission_outcomes)
3033
+
2878
3034
  if mode != "replace":
2879
- return _failed(
3035
+ return finalize(_failed(
2880
3036
  "UNSUPPORTED_FLOW_MODE",
2881
3037
  "only mode='replace' is supported",
2882
3038
  normalized_args=normalized_args,
2883
3039
  allowed_values={"modes": ["replace"]},
2884
3040
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
2885
- )
3041
+ ))
2886
3042
  try:
2887
3043
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2888
3044
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2889
3045
  except (QingflowApiError, RuntimeError) as error:
2890
3046
  api_error = _coerce_api_error(error)
2891
- return _failed_from_api_error(
3047
+ return finalize(_failed_from_api_error(
2892
3048
  "FLOW_READ_FAILED",
2893
3049
  api_error,
2894
3050
  normalized_args=normalized_args,
2895
- details={"app_key": app_key},
3051
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
2896
3052
  suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
2897
- )
3053
+ ))
2898
3054
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
2899
3055
  current_fields = _parse_schema(schema)["fields"]
2900
3056
  normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
@@ -3006,7 +3162,7 @@ class AiBuilderFacade:
3006
3162
  suggested_next_call["tool_name"] = "app_flow_apply"
3007
3163
  suggested_next_call["arguments"] = arguments
3008
3164
  failed["suggested_next_call"] = suggested_next_call
3009
- return failed
3165
+ return finalize(failed)
3010
3166
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
3011
3167
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
3012
3168
  current_workflow=verified_nodes,
@@ -3065,7 +3221,7 @@ class AiBuilderFacade:
3065
3221
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
3066
3222
  "verified": workflow_verified,
3067
3223
  }
3068
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3224
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3069
3225
 
3070
3226
  def app_views_apply(
3071
3227
  self,
@@ -3102,27 +3258,33 @@ class AiBuilderFacade:
3102
3258
  "verified": True,
3103
3259
  }
3104
3260
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3105
- permission_block = self._guard_app_permission(
3261
+ permission_outcomes: list[PermissionCheckOutcome] = []
3262
+ permission_outcome = self._guard_app_permission(
3106
3263
  profile=profile,
3107
3264
  app_key=app_key,
3108
3265
  required_permission="data_manage",
3109
3266
  normalized_args=normalized_args,
3110
3267
  )
3111
- if permission_block is not None:
3112
- return permission_block
3268
+ if permission_outcome.block is not None:
3269
+ return permission_outcome.block
3270
+ permission_outcomes.append(permission_outcome)
3271
+
3272
+ def finalize(response: JSONObject) -> JSONObject:
3273
+ return _apply_permission_outcomes(response, *permission_outcomes)
3274
+
3113
3275
  try:
3114
3276
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
3115
3277
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3116
3278
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
3117
3279
  except (QingflowApiError, RuntimeError) as error:
3118
3280
  api_error = _coerce_api_error(error)
3119
- return _failed_from_api_error(
3281
+ return finalize(_failed_from_api_error(
3120
3282
  "VIEWS_READ_FAILED",
3121
3283
  api_error,
3122
3284
  normalized_args=normalized_args,
3123
- details={"app_key": app_key},
3285
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
3124
3286
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3125
- )
3287
+ ))
3126
3288
  existing_views = existing_views or []
3127
3289
  existing_by_key: dict[str, dict[str, Any]] = {}
3128
3290
  existing_by_name: dict[str, list[dict[str, Any]]] = {}
@@ -3475,13 +3637,13 @@ class AiBuilderFacade:
3475
3637
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3476
3638
  except (QingflowApiError, RuntimeError) as error:
3477
3639
  api_error = _coerce_api_error(error)
3478
- return _failed_from_api_error(
3640
+ return finalize(_failed_from_api_error(
3479
3641
  "VIEWS_READ_FAILED",
3480
3642
  api_error,
3481
3643
  normalized_args=normalized_args,
3482
- details={"app_key": app_key},
3644
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
3483
3645
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3484
- )
3646
+ ))
3485
3647
  verified_names = {
3486
3648
  _extract_view_name(item)
3487
3649
  for item in (verified_view_result or [])
@@ -3636,7 +3798,7 @@ class AiBuilderFacade:
3636
3798
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
3637
3799
  "verified": verified and view_filters_verified,
3638
3800
  }
3639
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3801
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3640
3802
  warnings: list[dict[str, Any]] = []
3641
3803
  if filter_readback_pending or filter_mismatches:
3642
3804
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
@@ -3670,7 +3832,7 @@ class AiBuilderFacade:
3670
3832
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
3671
3833
  "verified": verified and view_filters_verified,
3672
3834
  }
3673
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3835
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3674
3836
 
3675
3837
  def app_publish_verify(
3676
3838
  self,
@@ -3803,18 +3965,27 @@ class AiBuilderFacade:
3803
3965
 
3804
3966
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
3805
3967
  normalized_args = request.model_dump(mode="json")
3968
+ permission_outcomes: list[PermissionCheckOutcome] = []
3806
3969
  app_result = self.app_resolve(profile=profile, app_key=request.app_key)
3807
3970
  if app_result.get("status") != "success":
3808
3971
  return app_result
3972
+ resolved_outcome = _permission_outcome_from_result(app_result)
3973
+ if resolved_outcome is not None:
3974
+ permission_outcomes.append(resolved_outcome)
3809
3975
  app_key = str(app_result.get("app_key") or request.app_key)
3810
- permission_block = self._guard_app_permission(
3976
+ permission_outcome = self._guard_app_permission(
3811
3977
  profile=profile,
3812
3978
  app_key=app_key,
3813
3979
  required_permission="data_manage",
3814
3980
  normalized_args=normalized_args,
3815
3981
  )
3816
- if permission_block is not None:
3817
- return permission_block
3982
+ if permission_outcome.block is not None:
3983
+ return permission_outcome.block
3984
+ permission_outcomes.append(permission_outcome)
3985
+
3986
+ def finalize(response: JSONObject) -> JSONObject:
3987
+ return _apply_permission_outcomes(response, *permission_outcomes)
3988
+
3818
3989
  try:
3819
3990
  schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
3820
3991
  parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
@@ -3823,13 +3994,13 @@ class AiBuilderFacade:
3823
3994
  existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3824
3995
  except (QingflowApiError, RuntimeError) as error:
3825
3996
  api_error = _coerce_api_error(error)
3826
- return _failed_from_api_error(
3997
+ return finalize(_failed_from_api_error(
3827
3998
  "CHART_APPLY_FAILED",
3828
3999
  api_error,
3829
4000
  normalized_args=normalized_args,
3830
- details={"app_key": app_key},
4001
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
3831
4002
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
3832
- )
4003
+ ))
3833
4004
 
3834
4005
  field_lookup = _build_public_field_lookup(fields)
3835
4006
  qingbi_fields_by_id = {
@@ -4069,7 +4240,7 @@ class AiBuilderFacade:
4069
4240
 
4070
4241
  if failed_items:
4071
4242
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
4072
- return {
4243
+ return finalize({
4073
4244
  "status": "partial_success" if successful_changes else "failed",
4074
4245
  "error_code": "CHART_APPLY_PARTIAL" if successful_changes else "CHART_APPLY_FAILED",
4075
4246
  "recoverable": True,
@@ -4097,9 +4268,9 @@ class AiBuilderFacade:
4097
4268
  "app_key": app_key,
4098
4269
  "chart_results": chart_results,
4099
4270
  "verified": False if failed_items else verified,
4100
- }
4271
+ })
4101
4272
  result_verified = verified or noop
4102
- return {
4273
+ return finalize({
4103
4274
  "status": "success" if result_verified else "partial_success",
4104
4275
  "error_code": None if result_verified else "CHART_READBACK_PENDING",
4105
4276
  "recoverable": not result_verified,
@@ -4125,10 +4296,11 @@ class AiBuilderFacade:
4125
4296
  "app_key": app_key,
4126
4297
  "chart_results": chart_results,
4127
4298
  "verified": result_verified,
4128
- }
4299
+ })
4129
4300
 
4130
4301
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
4131
4302
  normalized_args = request.model_dump(mode="json")
4303
+ permission_outcomes: list[PermissionCheckOutcome] = []
4132
4304
  dash_key = str(request.dash_key or "").strip()
4133
4305
  creating = not dash_key
4134
4306
  verify_dash_name = creating or request.dash_name is not None
@@ -4141,24 +4313,31 @@ class AiBuilderFacade:
4141
4313
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
4142
4314
  except (QingflowApiError, RuntimeError) as error:
4143
4315
  api_error = _coerce_api_error(error)
4144
- return _failed_from_api_error(
4316
+ return _failed(
4145
4317
  "PORTAL_APPLY_FAILED",
4146
- api_error,
4318
+ _public_error_message("PORTAL_APPLY_FAILED", api_error),
4147
4319
  normalized_args=normalized_args,
4148
- details={"dash_key": dash_key or None},
4320
+ details=_with_state_read_blocked_details({"dash_key": dash_key or None}, resource="portal", error=api_error),
4149
4321
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4322
+ request_id=api_error.request_id,
4323
+ backend_code=api_error.backend_code,
4324
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
4150
4325
  )
4151
4326
  if not isinstance(base_payload, dict):
4152
4327
  base_payload = {}
4153
4328
  if not creating:
4154
- portal_permission_block = self._guard_portal_permission(
4329
+ portal_permission_outcome = self._guard_portal_permission(
4155
4330
  profile=profile,
4156
4331
  dash_key=dash_key,
4157
4332
  normalized_args=normalized_args,
4158
4333
  portal_result=base_payload,
4159
4334
  )
4160
- if portal_permission_block is not None:
4161
- return portal_permission_block
4335
+ if portal_permission_outcome.block is not None:
4336
+ return portal_permission_outcome.block
4337
+ permission_outcomes.append(portal_permission_outcome)
4338
+
4339
+ def finalize(response: JSONObject) -> JSONObject:
4340
+ return _apply_permission_outcomes(response, *permission_outcomes)
4162
4341
  target_package_tag_id = request.package_tag_id
4163
4342
  if target_package_tag_id is None:
4164
4343
  target_package_tag_id = _coerce_positive_int(((base_payload.get("tags") or [{}])[0] if isinstance(base_payload.get("tags"), list) and base_payload.get("tags") else {}).get("tagId"))
@@ -4170,22 +4349,24 @@ class AiBuilderFacade:
4170
4349
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4171
4350
  )
4172
4351
  if creating:
4173
- package_add_block = self._guard_package_permission(
4352
+ package_add_outcome = self._guard_package_permission(
4174
4353
  profile=profile,
4175
4354
  tag_id=target_package_tag_id,
4176
4355
  required_permission="add_app",
4177
4356
  normalized_args=normalized_args,
4178
4357
  )
4179
- if package_add_block is not None:
4180
- return package_add_block
4181
- package_edit_block = self._guard_package_permission(
4358
+ if package_add_outcome.block is not None:
4359
+ return package_add_outcome.block
4360
+ permission_outcomes.append(package_add_outcome)
4361
+ package_edit_outcome = self._guard_package_permission(
4182
4362
  profile=profile,
4183
4363
  tag_id=target_package_tag_id,
4184
4364
  required_permission="edit_app",
4185
4365
  normalized_args=normalized_args,
4186
4366
  )
4187
- if package_edit_block is not None:
4188
- return package_edit_block
4367
+ if package_edit_outcome.block is not None:
4368
+ return package_edit_outcome.block
4369
+ permission_outcomes.append(package_edit_outcome)
4189
4370
  try:
4190
4371
  if creating:
4191
4372
  create_payload = _build_public_portal_base_payload(
@@ -4242,7 +4423,7 @@ class AiBuilderFacade:
4242
4423
  "PORTAL_APPLY_FAILED",
4243
4424
  _public_error_message("PORTAL_APPLY_FAILED", api_error) if api_error else str(error),
4244
4425
  normalized_args=normalized_args,
4245
- details={"dash_key": dash_key or None},
4426
+ details=_with_state_read_blocked_details({"dash_key": dash_key or None}, resource="portal", error=api_error) if api_error is not None else {"dash_key": dash_key or None},
4246
4427
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4247
4428
  request_id=api_error.request_id if api_error else None,
4248
4429
  backend_code=api_error.backend_code if api_error else None,
@@ -4316,7 +4497,7 @@ class AiBuilderFacade:
4316
4497
  live_meta_verified=live_meta_verified,
4317
4498
  publish_requested=request.publish,
4318
4499
  )
4319
- return {
4500
+ return finalize({
4320
4501
  "status": status,
4321
4502
  "error_code": error_code,
4322
4503
  "recoverable": not verified,
@@ -4347,7 +4528,7 @@ class AiBuilderFacade:
4347
4528
  "verified": verified,
4348
4529
  "draft_result": draft_result,
4349
4530
  "live_result": live_result,
4350
- }
4531
+ })
4351
4532
 
4352
4533
  def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
4353
4534
  normalized_args = {"app_key": app_key}
@@ -4912,6 +5093,177 @@ def _failed_from_api_error(
4912
5093
  )
4913
5094
 
4914
5095
 
5096
+ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
5097
+ return {
5098
+ "http_status": error.http_status,
5099
+ "backend_code": error.backend_code,
5100
+ "category": error.category,
5101
+ "request_id": error.request_id,
5102
+ }
5103
+
5104
+
5105
+ def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
5106
+ return error.backend_code in {40002, 40027}
5107
+
5108
+
5109
+ def _append_response_detail(details: JSONObject, *, key: str, value: Any) -> None:
5110
+ if value is None:
5111
+ return
5112
+ copied_value = deepcopy(value)
5113
+ existing = details.get(key)
5114
+ if existing is None:
5115
+ details[key] = copied_value
5116
+ return
5117
+ if isinstance(existing, list):
5118
+ existing.append(copied_value)
5119
+ return
5120
+ details[key] = [existing, copied_value]
5121
+
5122
+
5123
+ def _permission_skip_outcome(
5124
+ *,
5125
+ scope: str,
5126
+ target: JSONObject,
5127
+ required_permission: str | None,
5128
+ transport_error: JSONObject | None = None,
5129
+ permission_summary: JSONObject | None = None,
5130
+ ) -> PermissionCheckOutcome:
5131
+ lookup_payload: JSONObject = {
5132
+ "scope": scope,
5133
+ "target": deepcopy(target),
5134
+ "required_permission": required_permission,
5135
+ }
5136
+ if transport_error:
5137
+ lookup_payload["transport_error"] = deepcopy(transport_error)
5138
+ if permission_summary:
5139
+ lookup_payload["permission_summary"] = deepcopy(permission_summary)
5140
+ warning_message = (
5141
+ "builder permission lookup was permission-restricted; continuing with downstream operations"
5142
+ if transport_error
5143
+ else "builder permission summary was incomplete; continuing with downstream operations"
5144
+ )
5145
+ return PermissionCheckOutcome(
5146
+ warnings=[
5147
+ _warning(
5148
+ "PERMISSION_CHECK_SKIPPED",
5149
+ warning_message,
5150
+ scope=scope,
5151
+ required_permission=required_permission,
5152
+ )
5153
+ ],
5154
+ details={
5155
+ "lookup_permission_blocked": lookup_payload,
5156
+ "permission_check_skipped": True,
5157
+ },
5158
+ verification={"metadata_unverified": True},
5159
+ )
5160
+
5161
+
5162
+ def _verification_read_outcome(
5163
+ *,
5164
+ resource: str,
5165
+ target: JSONObject,
5166
+ transport_error: QingflowApiError,
5167
+ ) -> PermissionCheckOutcome:
5168
+ return PermissionCheckOutcome(
5169
+ warnings=[
5170
+ _warning(
5171
+ "VERIFICATION_READ_UNAVAILABLE",
5172
+ "post-write verification readback was permission-restricted",
5173
+ resource=resource,
5174
+ )
5175
+ ],
5176
+ details={
5177
+ "lookup_permission_blocked": {
5178
+ "scope": resource,
5179
+ "target": deepcopy(target),
5180
+ "phase": "verification",
5181
+ "transport_error": _transport_error_payload(transport_error),
5182
+ },
5183
+ "permission_check_skipped": True,
5184
+ },
5185
+ verification={"metadata_unverified": True},
5186
+ )
5187
+
5188
+
5189
+ def _permission_outcome_from_result(result: JSONObject) -> PermissionCheckOutcome | None:
5190
+ details = result.get("details") if isinstance(result.get("details"), dict) else {}
5191
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
5192
+ warnings = result.get("warnings") if isinstance(result.get("warnings"), list) else []
5193
+ filtered_details: JSONObject = {}
5194
+ if details.get("lookup_permission_blocked") is not None:
5195
+ filtered_details["lookup_permission_blocked"] = deepcopy(details["lookup_permission_blocked"])
5196
+ if details.get("permission_check_skipped") is not None:
5197
+ filtered_details["permission_check_skipped"] = bool(details.get("permission_check_skipped"))
5198
+ filtered_verification: JSONObject = {}
5199
+ if verification.get("metadata_unverified") is not None:
5200
+ filtered_verification["metadata_unverified"] = bool(verification.get("metadata_unverified"))
5201
+ filtered_warnings = [
5202
+ deepcopy(item)
5203
+ for item in warnings
5204
+ if isinstance(item, dict) and str(item.get("code") or "") in {"PERMISSION_CHECK_SKIPPED", "VERIFICATION_READ_UNAVAILABLE"}
5205
+ ]
5206
+ if not filtered_details and not filtered_verification and not filtered_warnings:
5207
+ return None
5208
+ return PermissionCheckOutcome(
5209
+ warnings=filtered_warnings,
5210
+ details=filtered_details,
5211
+ verification=filtered_verification,
5212
+ )
5213
+
5214
+
5215
+ def _apply_permission_outcomes(response: JSONObject, *outcomes: PermissionCheckOutcome) -> JSONObject:
5216
+ if not isinstance(response, dict):
5217
+ return response
5218
+ details = response.get("details")
5219
+ if not isinstance(details, dict):
5220
+ details = {}
5221
+ response["details"] = details
5222
+ verification = response.get("verification")
5223
+ if not isinstance(verification, dict):
5224
+ verification = {}
5225
+ response["verification"] = verification
5226
+ warnings = response.get("warnings")
5227
+ if not isinstance(warnings, list):
5228
+ warnings = []
5229
+ response["warnings"] = warnings
5230
+ for outcome in outcomes:
5231
+ if not isinstance(outcome, PermissionCheckOutcome):
5232
+ continue
5233
+ for key, value in outcome.details.items():
5234
+ if key in {"lookup_permission_blocked", "state_read_blocked"}:
5235
+ _append_response_detail(details, key=key, value=value)
5236
+ elif key == "permission_check_skipped":
5237
+ details[key] = bool(details.get(key)) or bool(value)
5238
+ elif key not in details:
5239
+ details[key] = deepcopy(value)
5240
+ for key, value in outcome.verification.items():
5241
+ verification[key] = deepcopy(value)
5242
+ for warning in outcome.warnings:
5243
+ if warning not in warnings:
5244
+ warnings.append(deepcopy(warning))
5245
+ return response
5246
+
5247
+
5248
+ def _with_state_read_blocked_details(
5249
+ details: JSONObject | None,
5250
+ *,
5251
+ resource: str,
5252
+ error: QingflowApiError,
5253
+ ) -> JSONObject:
5254
+ merged = deepcopy(details) if isinstance(details, dict) else {}
5255
+ if _is_permission_restricted_api_error(error):
5256
+ _append_response_detail(
5257
+ merged,
5258
+ key="state_read_blocked",
5259
+ value={
5260
+ "resource": resource,
5261
+ "transport_error": _transport_error_payload(error),
5262
+ },
5263
+ )
5264
+ return merged
5265
+
5266
+
4915
5267
  def _from_stage_failure(stage: JSONObject, *, fallback_tool: str) -> JSONObject:
4916
5268
  return {
4917
5269
  "status": "failed",
@@ -6944,17 +7296,22 @@ def _workflow_branch_structure_verified(*, current_workflow: Any, requested_node
6944
7296
 
6945
7297
  def _workflow_nodes_semantically_equal(*, current_workflow: Any, requested_nodes: list[dict[str, Any]]) -> bool:
6946
7298
  current_nodes = _summarize_workflow_nodes(current_workflow)
7299
+ current_effective = [
7300
+ node
7301
+ for node in current_nodes
7302
+ if _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")) not in {"start", "end"}
7303
+ ]
6947
7304
  requested_effective = [
6948
7305
  node for node in requested_nodes if str(node.get("type") or "") not in {"start", "end"}
6949
7306
  ]
6950
- if not current_nodes or not requested_effective or len(current_nodes) != len(requested_effective):
7307
+ if not current_effective or not requested_effective or len(current_effective) != len(requested_effective):
6951
7308
  return False
6952
7309
  current_signatures = sorted(
6953
7310
  (
6954
7311
  _normalize_existing_workflow_node_type(node.get("type"), node.get("deal_type")),
6955
7312
  str(node.get("name") or "").strip(),
6956
7313
  )
6957
- for node in current_nodes
7314
+ for node in current_effective
6958
7315
  )
6959
7316
  requested_signatures = sorted(
6960
7317
  (
@@ -6967,7 +7324,7 @@ def _workflow_nodes_semantically_equal(*, current_workflow: Any, requested_nodes
6967
7324
 
6968
7325
 
6969
7326
  def _normalize_existing_workflow_node_type(raw_type: Any, deal_type: Any) -> str:
6970
- normalized = str(raw_type or "").strip().lower()
7327
+ normalized = "" if raw_type is None else str(raw_type).strip().lower()
6971
7328
  if normalized in {"audit", "approve"}:
6972
7329
  return "approve"
6973
7330
  if normalized in {"fill", "copy", "branch", "condition", "webhook", "start", "end"}: