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

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)
@@ -2848,7 +2993,7 @@ class AiBuilderFacade:
2848
2993
  },
2849
2994
  "verified": layout_verified,
2850
2995
  }
2851
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2996
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
2852
2997
 
2853
2998
  def app_flow_apply(
2854
2999
  self,
@@ -2867,34 +3012,40 @@ class AiBuilderFacade:
2867
3012
  "transitions": transitions,
2868
3013
  "publish": publish,
2869
3014
  }
2870
- permission_block = self._guard_app_permission(
3015
+ permission_outcomes: list[PermissionCheckOutcome] = []
3016
+ permission_outcome = self._guard_app_permission(
2871
3017
  profile=profile,
2872
3018
  app_key=app_key,
2873
3019
  required_permission="data_manage",
2874
3020
  normalized_args=normalized_args,
2875
3021
  )
2876
- if permission_block is not None:
2877
- return permission_block
3022
+ if permission_outcome.block is not None:
3023
+ return permission_outcome.block
3024
+ permission_outcomes.append(permission_outcome)
3025
+
3026
+ def finalize(response: JSONObject) -> JSONObject:
3027
+ return _apply_permission_outcomes(response, *permission_outcomes)
3028
+
2878
3029
  if mode != "replace":
2879
- return _failed(
3030
+ return finalize(_failed(
2880
3031
  "UNSUPPORTED_FLOW_MODE",
2881
3032
  "only mode='replace' is supported",
2882
3033
  normalized_args=normalized_args,
2883
3034
  allowed_values={"modes": ["replace"]},
2884
3035
  suggested_next_call={"tool_name": "app_flow_apply", "arguments": {"profile": profile, **normalized_args}},
2885
- )
3036
+ ))
2886
3037
  try:
2887
3038
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
2888
3039
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
2889
3040
  except (QingflowApiError, RuntimeError) as error:
2890
3041
  api_error = _coerce_api_error(error)
2891
- return _failed_from_api_error(
3042
+ return finalize(_failed_from_api_error(
2892
3043
  "FLOW_READ_FAILED",
2893
3044
  api_error,
2894
3045
  normalized_args=normalized_args,
2895
- details={"app_key": app_key},
3046
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="workflow", error=api_error),
2896
3047
  suggested_next_call={"tool_name": "app_read_flow_summary", "arguments": {"profile": profile, "app_key": app_key}},
2897
- )
3048
+ ))
2898
3049
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
2899
3050
  current_fields = _parse_schema(schema)["fields"]
2900
3051
  normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
@@ -3006,7 +3157,7 @@ class AiBuilderFacade:
3006
3157
  suggested_next_call["tool_name"] = "app_flow_apply"
3007
3158
  suggested_next_call["arguments"] = arguments
3008
3159
  failed["suggested_next_call"] = suggested_next_call
3009
- return failed
3160
+ return finalize(failed)
3010
3161
  verified_nodes, verified_nodes_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
3011
3162
  workflow_structure_verified = bool(verified_nodes) and _workflow_nodes_semantically_equal(
3012
3163
  current_workflow=verified_nodes,
@@ -3065,7 +3216,7 @@ class AiBuilderFacade:
3065
3216
  "flow_diff": {"mode": "replace", "node_count": desired_node_count},
3066
3217
  "verified": workflow_verified,
3067
3218
  }
3068
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3219
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3069
3220
 
3070
3221
  def app_views_apply(
3071
3222
  self,
@@ -3102,27 +3253,33 @@ class AiBuilderFacade:
3102
3253
  "verified": True,
3103
3254
  }
3104
3255
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3105
- permission_block = self._guard_app_permission(
3256
+ permission_outcomes: list[PermissionCheckOutcome] = []
3257
+ permission_outcome = self._guard_app_permission(
3106
3258
  profile=profile,
3107
3259
  app_key=app_key,
3108
3260
  required_permission="data_manage",
3109
3261
  normalized_args=normalized_args,
3110
3262
  )
3111
- if permission_block is not None:
3112
- return permission_block
3263
+ if permission_outcome.block is not None:
3264
+ return permission_outcome.block
3265
+ permission_outcomes.append(permission_outcome)
3266
+
3267
+ def finalize(response: JSONObject) -> JSONObject:
3268
+ return _apply_permission_outcomes(response, *permission_outcomes)
3269
+
3113
3270
  try:
3114
3271
  base = self.apps.app_get_base(profile=profile, app_key=app_key, include_raw=True).get("result") or {}
3115
3272
  schema, _schema_source = self._read_schema_with_fallback(profile=profile, app_key=app_key)
3116
3273
  existing_views, _views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=False)
3117
3274
  except (QingflowApiError, RuntimeError) as error:
3118
3275
  api_error = _coerce_api_error(error)
3119
- return _failed_from_api_error(
3276
+ return finalize(_failed_from_api_error(
3120
3277
  "VIEWS_READ_FAILED",
3121
3278
  api_error,
3122
3279
  normalized_args=normalized_args,
3123
- details={"app_key": app_key},
3280
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
3124
3281
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3125
- )
3282
+ ))
3126
3283
  existing_views = existing_views or []
3127
3284
  existing_by_key: dict[str, dict[str, Any]] = {}
3128
3285
  existing_by_name: dict[str, list[dict[str, Any]]] = {}
@@ -3475,13 +3632,13 @@ class AiBuilderFacade:
3475
3632
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
3476
3633
  except (QingflowApiError, RuntimeError) as error:
3477
3634
  api_error = _coerce_api_error(error)
3478
- return _failed_from_api_error(
3635
+ return finalize(_failed_from_api_error(
3479
3636
  "VIEWS_READ_FAILED",
3480
3637
  api_error,
3481
3638
  normalized_args=normalized_args,
3482
- details={"app_key": app_key},
3639
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="views", error=api_error),
3483
3640
  suggested_next_call={"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
3484
- )
3641
+ ))
3485
3642
  verified_names = {
3486
3643
  _extract_view_name(item)
3487
3644
  for item in (verified_view_result or [])
@@ -3636,7 +3793,7 @@ class AiBuilderFacade:
3636
3793
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
3637
3794
  "verified": verified and view_filters_verified,
3638
3795
  }
3639
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3796
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3640
3797
  warnings: list[dict[str, Any]] = []
3641
3798
  if filter_readback_pending or filter_mismatches:
3642
3799
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
@@ -3670,7 +3827,7 @@ class AiBuilderFacade:
3670
3827
  "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
3671
3828
  "verified": verified and view_filters_verified,
3672
3829
  }
3673
- return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
3830
+ return finalize(self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response))
3674
3831
 
3675
3832
  def app_publish_verify(
3676
3833
  self,
@@ -3803,18 +3960,27 @@ class AiBuilderFacade:
3803
3960
 
3804
3961
  def chart_apply(self, *, profile: str, request: ChartApplyRequest) -> JSONObject:
3805
3962
  normalized_args = request.model_dump(mode="json")
3963
+ permission_outcomes: list[PermissionCheckOutcome] = []
3806
3964
  app_result = self.app_resolve(profile=profile, app_key=request.app_key)
3807
3965
  if app_result.get("status") != "success":
3808
3966
  return app_result
3967
+ resolved_outcome = _permission_outcome_from_result(app_result)
3968
+ if resolved_outcome is not None:
3969
+ permission_outcomes.append(resolved_outcome)
3809
3970
  app_key = str(app_result.get("app_key") or request.app_key)
3810
- permission_block = self._guard_app_permission(
3971
+ permission_outcome = self._guard_app_permission(
3811
3972
  profile=profile,
3812
3973
  app_key=app_key,
3813
3974
  required_permission="data_manage",
3814
3975
  normalized_args=normalized_args,
3815
3976
  )
3816
- if permission_block is not None:
3817
- return permission_block
3977
+ if permission_outcome.block is not None:
3978
+ return permission_outcome.block
3979
+ permission_outcomes.append(permission_outcome)
3980
+
3981
+ def finalize(response: JSONObject) -> JSONObject:
3982
+ return _apply_permission_outcomes(response, *permission_outcomes)
3983
+
3818
3984
  try:
3819
3985
  schema_state = self._load_base_schema_state(profile=profile, app_key=app_key)
3820
3986
  parsed = schema_state.get("parsed") if isinstance(schema_state.get("parsed"), dict) else {}
@@ -3823,13 +3989,13 @@ class AiBuilderFacade:
3823
3989
  existing_chart_items, existing_chart_list_source = self._load_chart_list_for_builder(profile=profile, app_key=app_key)
3824
3990
  except (QingflowApiError, RuntimeError) as error:
3825
3991
  api_error = _coerce_api_error(error)
3826
- return _failed_from_api_error(
3992
+ return finalize(_failed_from_api_error(
3827
3993
  "CHART_APPLY_FAILED",
3828
3994
  api_error,
3829
3995
  normalized_args=normalized_args,
3830
- details={"app_key": app_key},
3996
+ details=_with_state_read_blocked_details({"app_key": app_key}, resource="chart", error=api_error),
3831
3997
  suggested_next_call={"tool_name": "app_charts_apply", "arguments": {"profile": profile, **normalized_args}},
3832
- )
3998
+ ))
3833
3999
 
3834
4000
  field_lookup = _build_public_field_lookup(fields)
3835
4001
  qingbi_fields_by_id = {
@@ -4069,7 +4235,7 @@ class AiBuilderFacade:
4069
4235
 
4070
4236
  if failed_items:
4071
4237
  successful_changes = bool(created_ids or updated_ids or removed_ids or reordered)
4072
- return {
4238
+ return finalize({
4073
4239
  "status": "partial_success" if successful_changes else "failed",
4074
4240
  "error_code": "CHART_APPLY_PARTIAL" if successful_changes else "CHART_APPLY_FAILED",
4075
4241
  "recoverable": True,
@@ -4097,9 +4263,9 @@ class AiBuilderFacade:
4097
4263
  "app_key": app_key,
4098
4264
  "chart_results": chart_results,
4099
4265
  "verified": False if failed_items else verified,
4100
- }
4266
+ })
4101
4267
  result_verified = verified or noop
4102
- return {
4268
+ return finalize({
4103
4269
  "status": "success" if result_verified else "partial_success",
4104
4270
  "error_code": None if result_verified else "CHART_READBACK_PENDING",
4105
4271
  "recoverable": not result_verified,
@@ -4125,10 +4291,11 @@ class AiBuilderFacade:
4125
4291
  "app_key": app_key,
4126
4292
  "chart_results": chart_results,
4127
4293
  "verified": result_verified,
4128
- }
4294
+ })
4129
4295
 
4130
4296
  def portal_apply(self, *, profile: str, request: PortalApplyRequest) -> JSONObject:
4131
4297
  normalized_args = request.model_dump(mode="json")
4298
+ permission_outcomes: list[PermissionCheckOutcome] = []
4132
4299
  dash_key = str(request.dash_key or "").strip()
4133
4300
  creating = not dash_key
4134
4301
  verify_dash_name = creating or request.dash_name is not None
@@ -4141,24 +4308,31 @@ class AiBuilderFacade:
4141
4308
  base_payload = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=True).get("result") if dash_key else {}
4142
4309
  except (QingflowApiError, RuntimeError) as error:
4143
4310
  api_error = _coerce_api_error(error)
4144
- return _failed_from_api_error(
4311
+ return _failed(
4145
4312
  "PORTAL_APPLY_FAILED",
4146
- api_error,
4313
+ _public_error_message("PORTAL_APPLY_FAILED", api_error),
4147
4314
  normalized_args=normalized_args,
4148
- details={"dash_key": dash_key or None},
4315
+ details=_with_state_read_blocked_details({"dash_key": dash_key or None}, resource="portal", error=api_error),
4149
4316
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4317
+ request_id=api_error.request_id,
4318
+ backend_code=api_error.backend_code,
4319
+ http_status=None if api_error.http_status == 404 else api_error.http_status,
4150
4320
  )
4151
4321
  if not isinstance(base_payload, dict):
4152
4322
  base_payload = {}
4153
4323
  if not creating:
4154
- portal_permission_block = self._guard_portal_permission(
4324
+ portal_permission_outcome = self._guard_portal_permission(
4155
4325
  profile=profile,
4156
4326
  dash_key=dash_key,
4157
4327
  normalized_args=normalized_args,
4158
4328
  portal_result=base_payload,
4159
4329
  )
4160
- if portal_permission_block is not None:
4161
- return portal_permission_block
4330
+ if portal_permission_outcome.block is not None:
4331
+ return portal_permission_outcome.block
4332
+ permission_outcomes.append(portal_permission_outcome)
4333
+
4334
+ def finalize(response: JSONObject) -> JSONObject:
4335
+ return _apply_permission_outcomes(response, *permission_outcomes)
4162
4336
  target_package_tag_id = request.package_tag_id
4163
4337
  if target_package_tag_id is None:
4164
4338
  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 +4344,24 @@ class AiBuilderFacade:
4170
4344
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4171
4345
  )
4172
4346
  if creating:
4173
- package_add_block = self._guard_package_permission(
4347
+ package_add_outcome = self._guard_package_permission(
4174
4348
  profile=profile,
4175
4349
  tag_id=target_package_tag_id,
4176
4350
  required_permission="add_app",
4177
4351
  normalized_args=normalized_args,
4178
4352
  )
4179
- if package_add_block is not None:
4180
- return package_add_block
4181
- package_edit_block = self._guard_package_permission(
4353
+ if package_add_outcome.block is not None:
4354
+ return package_add_outcome.block
4355
+ permission_outcomes.append(package_add_outcome)
4356
+ package_edit_outcome = self._guard_package_permission(
4182
4357
  profile=profile,
4183
4358
  tag_id=target_package_tag_id,
4184
4359
  required_permission="edit_app",
4185
4360
  normalized_args=normalized_args,
4186
4361
  )
4187
- if package_edit_block is not None:
4188
- return package_edit_block
4362
+ if package_edit_outcome.block is not None:
4363
+ return package_edit_outcome.block
4364
+ permission_outcomes.append(package_edit_outcome)
4189
4365
  try:
4190
4366
  if creating:
4191
4367
  create_payload = _build_public_portal_base_payload(
@@ -4242,7 +4418,7 @@ class AiBuilderFacade:
4242
4418
  "PORTAL_APPLY_FAILED",
4243
4419
  _public_error_message("PORTAL_APPLY_FAILED", api_error) if api_error else str(error),
4244
4420
  normalized_args=normalized_args,
4245
- details={"dash_key": dash_key or None},
4421
+ 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
4422
  suggested_next_call={"tool_name": "portal_apply", "arguments": {"profile": profile, **normalized_args}},
4247
4423
  request_id=api_error.request_id if api_error else None,
4248
4424
  backend_code=api_error.backend_code if api_error else None,
@@ -4316,7 +4492,7 @@ class AiBuilderFacade:
4316
4492
  live_meta_verified=live_meta_verified,
4317
4493
  publish_requested=request.publish,
4318
4494
  )
4319
- return {
4495
+ return finalize({
4320
4496
  "status": status,
4321
4497
  "error_code": error_code,
4322
4498
  "recoverable": not verified,
@@ -4347,7 +4523,7 @@ class AiBuilderFacade:
4347
4523
  "verified": verified,
4348
4524
  "draft_result": draft_result,
4349
4525
  "live_result": live_result,
4350
- }
4526
+ })
4351
4527
 
4352
4528
  def _publish_current_edit_version(self, *, profile: str, app_key: str) -> JSONObject:
4353
4529
  normalized_args = {"app_key": app_key}
@@ -4912,6 +5088,177 @@ def _failed_from_api_error(
4912
5088
  )
4913
5089
 
4914
5090
 
5091
+ def _transport_error_payload(error: QingflowApiError) -> JSONObject:
5092
+ return {
5093
+ "http_status": error.http_status,
5094
+ "backend_code": error.backend_code,
5095
+ "category": error.category,
5096
+ "request_id": error.request_id,
5097
+ }
5098
+
5099
+
5100
+ def _is_permission_restricted_api_error(error: QingflowApiError) -> bool:
5101
+ return error.backend_code in {40002, 40027}
5102
+
5103
+
5104
+ def _append_response_detail(details: JSONObject, *, key: str, value: Any) -> None:
5105
+ if value is None:
5106
+ return
5107
+ copied_value = deepcopy(value)
5108
+ existing = details.get(key)
5109
+ if existing is None:
5110
+ details[key] = copied_value
5111
+ return
5112
+ if isinstance(existing, list):
5113
+ existing.append(copied_value)
5114
+ return
5115
+ details[key] = [existing, copied_value]
5116
+
5117
+
5118
+ def _permission_skip_outcome(
5119
+ *,
5120
+ scope: str,
5121
+ target: JSONObject,
5122
+ required_permission: str | None,
5123
+ transport_error: JSONObject | None = None,
5124
+ permission_summary: JSONObject | None = None,
5125
+ ) -> PermissionCheckOutcome:
5126
+ lookup_payload: JSONObject = {
5127
+ "scope": scope,
5128
+ "target": deepcopy(target),
5129
+ "required_permission": required_permission,
5130
+ }
5131
+ if transport_error:
5132
+ lookup_payload["transport_error"] = deepcopy(transport_error)
5133
+ if permission_summary:
5134
+ lookup_payload["permission_summary"] = deepcopy(permission_summary)
5135
+ warning_message = (
5136
+ "builder permission lookup was permission-restricted; continuing with downstream operations"
5137
+ if transport_error
5138
+ else "builder permission summary was incomplete; continuing with downstream operations"
5139
+ )
5140
+ return PermissionCheckOutcome(
5141
+ warnings=[
5142
+ _warning(
5143
+ "PERMISSION_CHECK_SKIPPED",
5144
+ warning_message,
5145
+ scope=scope,
5146
+ required_permission=required_permission,
5147
+ )
5148
+ ],
5149
+ details={
5150
+ "lookup_permission_blocked": lookup_payload,
5151
+ "permission_check_skipped": True,
5152
+ },
5153
+ verification={"metadata_unverified": True},
5154
+ )
5155
+
5156
+
5157
+ def _verification_read_outcome(
5158
+ *,
5159
+ resource: str,
5160
+ target: JSONObject,
5161
+ transport_error: QingflowApiError,
5162
+ ) -> PermissionCheckOutcome:
5163
+ return PermissionCheckOutcome(
5164
+ warnings=[
5165
+ _warning(
5166
+ "VERIFICATION_READ_UNAVAILABLE",
5167
+ "post-write verification readback was permission-restricted",
5168
+ resource=resource,
5169
+ )
5170
+ ],
5171
+ details={
5172
+ "lookup_permission_blocked": {
5173
+ "scope": resource,
5174
+ "target": deepcopy(target),
5175
+ "phase": "verification",
5176
+ "transport_error": _transport_error_payload(transport_error),
5177
+ },
5178
+ "permission_check_skipped": True,
5179
+ },
5180
+ verification={"metadata_unverified": True},
5181
+ )
5182
+
5183
+
5184
+ def _permission_outcome_from_result(result: JSONObject) -> PermissionCheckOutcome | None:
5185
+ details = result.get("details") if isinstance(result.get("details"), dict) else {}
5186
+ verification = result.get("verification") if isinstance(result.get("verification"), dict) else {}
5187
+ warnings = result.get("warnings") if isinstance(result.get("warnings"), list) else []
5188
+ filtered_details: JSONObject = {}
5189
+ if details.get("lookup_permission_blocked") is not None:
5190
+ filtered_details["lookup_permission_blocked"] = deepcopy(details["lookup_permission_blocked"])
5191
+ if details.get("permission_check_skipped") is not None:
5192
+ filtered_details["permission_check_skipped"] = bool(details.get("permission_check_skipped"))
5193
+ filtered_verification: JSONObject = {}
5194
+ if verification.get("metadata_unverified") is not None:
5195
+ filtered_verification["metadata_unverified"] = bool(verification.get("metadata_unverified"))
5196
+ filtered_warnings = [
5197
+ deepcopy(item)
5198
+ for item in warnings
5199
+ if isinstance(item, dict) and str(item.get("code") or "") in {"PERMISSION_CHECK_SKIPPED", "VERIFICATION_READ_UNAVAILABLE"}
5200
+ ]
5201
+ if not filtered_details and not filtered_verification and not filtered_warnings:
5202
+ return None
5203
+ return PermissionCheckOutcome(
5204
+ warnings=filtered_warnings,
5205
+ details=filtered_details,
5206
+ verification=filtered_verification,
5207
+ )
5208
+
5209
+
5210
+ def _apply_permission_outcomes(response: JSONObject, *outcomes: PermissionCheckOutcome) -> JSONObject:
5211
+ if not isinstance(response, dict):
5212
+ return response
5213
+ details = response.get("details")
5214
+ if not isinstance(details, dict):
5215
+ details = {}
5216
+ response["details"] = details
5217
+ verification = response.get("verification")
5218
+ if not isinstance(verification, dict):
5219
+ verification = {}
5220
+ response["verification"] = verification
5221
+ warnings = response.get("warnings")
5222
+ if not isinstance(warnings, list):
5223
+ warnings = []
5224
+ response["warnings"] = warnings
5225
+ for outcome in outcomes:
5226
+ if not isinstance(outcome, PermissionCheckOutcome):
5227
+ continue
5228
+ for key, value in outcome.details.items():
5229
+ if key in {"lookup_permission_blocked", "state_read_blocked"}:
5230
+ _append_response_detail(details, key=key, value=value)
5231
+ elif key == "permission_check_skipped":
5232
+ details[key] = bool(details.get(key)) or bool(value)
5233
+ elif key not in details:
5234
+ details[key] = deepcopy(value)
5235
+ for key, value in outcome.verification.items():
5236
+ verification[key] = deepcopy(value)
5237
+ for warning in outcome.warnings:
5238
+ if warning not in warnings:
5239
+ warnings.append(deepcopy(warning))
5240
+ return response
5241
+
5242
+
5243
+ def _with_state_read_blocked_details(
5244
+ details: JSONObject | None,
5245
+ *,
5246
+ resource: str,
5247
+ error: QingflowApiError,
5248
+ ) -> JSONObject:
5249
+ merged = deepcopy(details) if isinstance(details, dict) else {}
5250
+ if _is_permission_restricted_api_error(error):
5251
+ _append_response_detail(
5252
+ merged,
5253
+ key="state_read_blocked",
5254
+ value={
5255
+ "resource": resource,
5256
+ "transport_error": _transport_error_payload(error),
5257
+ },
5258
+ )
5259
+ return merged
5260
+
5261
+
4915
5262
  def _from_stage_failure(stage: JSONObject, *, fallback_tool: str) -> JSONObject:
4916
5263
  return {
4917
5264
  "status": "failed",