Browse Source

feat: establish enterprise connector platform

马小龙 2 weeks ago
parent
commit
b4af37ca38
76 changed files with 13090 additions and 129 deletions
  1. 507 15
      app/api/data_source/routes.py
  2. 15 0
      app/core/connectors/__init__.py
  3. 224 0
      app/core/connectors/bindings.py
  4. 29 0
      app/core/connectors/builtin/__init__.py
  5. 184 0
      app/core/connectors/builtin/database.py
  6. 63 0
      app/core/connectors/builtin/oracle.py
  7. 381 0
      app/core/connectors/builtin/rest_catalog.py
  8. 78 0
      app/core/connectors/builtin/sqlserver.py
  9. 84 0
      app/core/connectors/errors.py
  10. 379 0
      app/core/connectors/identity.py
  11. 52 0
      app/core/connectors/registry.py
  12. 513 0
      app/core/connectors/repository.py
  13. 573 0
      app/core/connectors/runtime.py
  14. 233 0
      app/core/connectors/sdk.py
  15. 39 0
      app/core/connectors/secrets.py
  16. 16 0
      app/core/connectors/store.py
  17. 7 1
      app/core/data_source/adapters/__init__.py
  18. 31 4
      app/core/data_source/adapters/base.py
  19. 25 0
      app/core/data_source/adapters/oracle.py
  20. 92 0
      app/core/data_source/adapters/sqlserver.py
  21. 6 0
      app/core/data_source/errors.py
  22. 32 5
      app/core/data_source/manager.py
  23. 4 1
      app/core/data_source/service.py
  24. 26 0
      app/core/system/permissions.py
  25. 10 0
      app/models/__init__.py
  26. 83 0
      app/models/connectors.py
  27. 507 15
      deployment/app/api/data_source/routes.py
  28. 15 0
      deployment/app/core/connectors/__init__.py
  29. 224 0
      deployment/app/core/connectors/bindings.py
  30. 29 0
      deployment/app/core/connectors/builtin/__init__.py
  31. 184 0
      deployment/app/core/connectors/builtin/database.py
  32. 63 0
      deployment/app/core/connectors/builtin/oracle.py
  33. 381 0
      deployment/app/core/connectors/builtin/rest_catalog.py
  34. 78 0
      deployment/app/core/connectors/builtin/sqlserver.py
  35. 84 0
      deployment/app/core/connectors/errors.py
  36. 379 0
      deployment/app/core/connectors/identity.py
  37. 52 0
      deployment/app/core/connectors/registry.py
  38. 513 0
      deployment/app/core/connectors/repository.py
  39. 573 0
      deployment/app/core/connectors/runtime.py
  40. 233 0
      deployment/app/core/connectors/sdk.py
  41. 39 0
      deployment/app/core/connectors/secrets.py
  42. 16 0
      deployment/app/core/connectors/store.py
  43. 7 1
      deployment/app/core/data_source/adapters/__init__.py
  44. 31 4
      deployment/app/core/data_source/adapters/base.py
  45. 25 0
      deployment/app/core/data_source/adapters/oracle.py
  46. 92 0
      deployment/app/core/data_source/adapters/sqlserver.py
  47. 6 0
      deployment/app/core/data_source/errors.py
  48. 32 5
      deployment/app/core/data_source/manager.py
  49. 4 1
      deployment/app/core/data_source/service.py
  50. 26 0
      deployment/app/core/system/permissions.py
  51. 10 0
      deployment/app/models/__init__.py
  52. 83 0
      deployment/app/models/connectors.py
  53. 114 0
      deployment/migrations/versions/20260802_472_enterprise_connectors.py
  54. 92 0
      deployment/migrations/versions/20260802_473_connector_runtime_hardening.py
  55. 70 0
      deployment/migrations/versions/20260802_474_connector_version_guardrails.py
  56. 145 0
      deployment/migrations/versions/20260802_475_connector_security_bindings.py
  57. 85 0
      deployment/migrations/versions/20260802_476_connector_binding_enforcement.py
  58. 19 8
      docs/DATAOPS_PHASE3_6_MONTH_DEVELOPMENT_PLAN_20260802.md
  59. 650 10
      docs/architecture/OPENAPI.yaml
  60. 55 0
      docs/development/CONNECTOR_SDK_GUIDE.md
  61. 1 1
      docs/phase3/P3_WP00_REQUIREMENTS.json
  62. 70 0
      docs/phase3/P3_WP03_ENTERPRISE_CONNECTORS.md
  63. 50 0
      docs/validation/P3_WP03_ENTERPRISE_CONNECTOR_EVIDENCE.md
  64. 51 0
      docs/validation/P3_WP03_FAILURE_INJECTION.json
  65. 9 0
      frontend/src/api/dataOrigin.js
  66. 13 0
      frontend/src/router/routes.js
  67. 3 1
      frontend/src/views/dataGovernance/dataSource/components/edit.vue
  68. 101 0
      frontend/src/views/dataGovernance/development/enterpriseConnectors.vue
  69. 114 0
      migrations/versions/20260802_472_enterprise_connectors.py
  70. 92 0
      migrations/versions/20260802_473_connector_runtime_hardening.py
  71. 70 0
      migrations/versions/20260802_474_connector_version_guardrails.py
  72. 145 0
      migrations/versions/20260802_475_connector_security_bindings.py
  73. 85 0
      migrations/versions/20260802_476_connector_binding_enforcement.py
  74. 355 57
      scripts/generate_openapi.py
  75. 1635 0
      tests/integration/test_phase3_wp03_connectors_postgres.py
  76. 1694 0
      tests/test_phase3_wp03_enterprise_connectors.py

+ 507 - 15
app/api/data_source/routes.py

@@ -5,6 +5,11 @@ import logging
 from flask import g, jsonify, request
 
 from app.api.data_source import bp
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorConfigurationError,
+    ConnectorError,
+)
 from app.core.data_source.errors import DataSourceError
 from app.core.data_source.redaction import (
     redact_mapping,
@@ -12,7 +17,6 @@ from app.core.data_source.redaction import (
 )
 from app.models.result import failed, success
 
-
 logger = logging.getLogger(__name__)
 
 
@@ -29,6 +33,19 @@ def _actor_uid():
 
 
 def _error_response(error):
+    if isinstance(error, ConnectorError):
+        logger.warning("连接器操作失败: category=%s", error.category)
+        return jsonify(
+            failed(
+                "连接器操作失败",
+                code=error.http_status,
+                error={
+                    "code": "CONNECTOR_ERROR",
+                    "category": error.category,
+                    "retryable": error.retryable,
+                },
+            )
+        ), error.http_status
     if isinstance(error, DataSourceError):
         logger.warning(
             "数据源操作失败: code=%s message=%s",
@@ -84,9 +101,7 @@ def data_source_list():
         service = get_data_source_service()
         definitions = service.list(payload)
         items = [service.serialize(item) for item in definitions]
-        return jsonify(
-            success({"data_source": items, "total": len(items)})
-        ), 200
+        return jsonify(success({"data_source": items, "total": len(items)})), 200
     except Exception as error:
         return _error_response(error)
 
@@ -146,8 +161,7 @@ def data_source_pool_list():
     try:
         service = get_data_source_service()
         items = [
-            service.serialize_pool_status(status)
-            for status in service.pool_statuses()
+            service.serialize_pool_status(status) for status in service.pool_statuses()
         ]
         return jsonify(success({"pools": items, "total": len(items)})), 200
     except Exception as error:
@@ -215,13 +229,491 @@ def data_source_pool_invalidate(data_source_uid):
 
 @bp.route("/graph", methods=["POST"])
 def data_source_graph_relationship():
-    return (
-        jsonify(
-            failed(
-                "该功能尚未实现",
-                code=501,
-                error={"code": "DATASOURCE_GRAPH_NOT_IMPLEMENTED"},
-            )
-        ),
-        501,
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        graph = ConnectorRepository(db.session).graph(
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            process_key=payload.get("process_key"),
+            run_uid=payload.get("run_uid"),
+            limit=payload.get("limit", 500),
+        )
+        return jsonify(success(graph)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+def _connector_registry():
+    from flask import current_app
+
+    from app.core.connectors.builtin import register_builtin_connectors
+    from app.core.connectors.registry import ConnectorRegistry
+    from app.core.connectors.secrets import EnvironmentSecretResolver
+    from app.core.data_source.runtime import get_data_source_manager
+
+    registry = current_app.extensions.get("connector_registry")
+    if registry is not None:
+        return registry
+    registry = ConnectorRegistry()
+    register_builtin_connectors(
+        registry,
+        connection_provider=get_data_source_manager().connect,
+        secret_resolver=current_app.config.get("CONNECTOR_SECRET_RESOLVER")
+        or EnvironmentSecretResolver(),
     )
+    current_app.extensions["connector_registry"] = registry
+    return registry
+
+
+@bp.route("/connectors/manifests", methods=["GET"])
+def connector_manifests():
+    try:
+        return jsonify(success({"manifests": _connector_registry().manifests()})), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/config/validate", methods=["POST"])
+def connector_config_validate():
+    payload = request.get_json(silent=True) or {}
+    try:
+        config = _connector_registry().validate(
+            payload.get("connector_id"), payload.get("version"), payload.get("config")
+        )
+        return jsonify(success({"valid": True, "config_keys": sorted(config)})), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/<connector_id>/<version>/health", methods=["POST"])
+def connector_health(connector_id, version):
+    payload = request.get_json(silent=True) or {}
+    try:
+        connector = _connector_registry().resolve(connector_id, version)
+        return jsonify(
+            success(vars(connector.health(payload.get("config") or {})))
+        ), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/<connector_id>/<version>/compatibility", methods=["GET"])
+def connector_compatibility(connector_id, version):
+    try:
+        connector = _connector_registry().resolve(connector_id, version)
+        return jsonify(success(vars(connector.compatibility()))), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs", methods=["GET"])
+def connector_runs_list():
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    try:
+        repository = ConnectorRepository(db.session, _actor_uid())
+        return jsonify(
+            success({"runs": repository.list(request.args.get("limit", 100))})
+        ), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+def _require_human_dry_run(payload):
+    if payload.get("dry_run") is not True:
+        raise ConnectorConfigurationError("human connector runs must use dry_run=true")
+
+
+def _require_collection_operation(payload):
+    if payload.get("operation") not in {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "evidence",
+    }:
+        raise ConnectorConfigurationError("connector collection operation is invalid")
+
+
+@bp.route("/connectors/runs", methods=["POST"])
+def connector_run_create():
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    try:
+        repository = ConnectorRepository(
+            db.session, _actor_uid(), run_access="human"
+        )
+        from dataclasses import asdict
+
+        from app.core.connectors.runtime import ConnectorRuntime
+        from app.core.connectors.sdk import OperationRequest
+
+        payload = request.get_json(silent=True) or {}
+        _require_human_dry_run(payload)
+        _require_collection_operation(payload)
+        registry = _connector_registry()
+        for manifest in (
+            registry.resolve(
+                payload.get("connector_id"), payload.get("version")
+            ).manifest,
+        ):
+            repository.register_manifest(manifest, _actor_uid())
+        db.session.commit()
+        operation_request = OperationRequest(
+            source_uid=str(payload.get("source_uid") or ""),
+            operation=str(payload.get("operation") or ""),
+            config=payload.get("config") or {},
+            scope=payload.get("scope") or {},
+            cursor=payload.get("cursor") or {},
+            checkpoint=payload.get("checkpoint") or {},
+            idempotency_key=payload.get("idempotency_key"),
+            dry_run=bool(payload.get("dry_run", False)),
+            process_key=str(payload.get("process_key") or "dry-run"),
+        )
+        result = ConnectorRuntime(registry, store=repository).execute(
+            payload.get("connector_id"), payload.get("version"), operation_request
+        )
+        return jsonify(success(asdict(result), code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/machine/runs", methods=["POST"])
+def connector_machine_run():
+    """Machine-only run boundary; a human bearer token is never accepted here."""
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+    from app.core.connectors.sdk import OperationRequest
+
+    payload = request.get_json(silent=True) or {}
+    token = request.headers.get("X-Connector-Credential", "").strip()
+    if not token:
+        return _error_response(
+            ConnectorAuthenticationError("machine credential is required")
+        )
+    try:
+        for field in (
+            "connector_id",
+            "version",
+            "source_uid",
+            "business_domain_uid",
+            "environment",
+            "process_key",
+            "operation",
+        ):
+            if not str(payload.get(field) or "").strip():
+                raise ConnectorConfigurationError(
+                    "machine connector binding is incomplete"
+                )
+        _require_collection_operation(payload)
+        if "config" in payload:
+            raise ConnectorConfigurationError(
+                "machine connector config is supplied by the approved source binding"
+            )
+        identity = ConnectorIdentityRepository(db.session).authenticate(
+            token,
+            connector_id=str(payload.get("connector_id") or ""),
+            connector_version=str(payload.get("version") or ""),
+            source_uid=str(payload.get("source_uid") or ""),
+            business_domain_uid=str(payload.get("business_domain_uid") or ""),
+            environment=str(payload.get("environment") or ""),
+            operation=str(payload.get("operation") or ""),
+            scope=payload.get("scope") or {},
+        )
+        registry = _connector_registry()
+        manifest = registry.resolve(
+            payload.get("connector_id"), payload.get("version")
+        ).manifest
+        if not identity.get("source_binding_uid"):
+            raise ConnectorConfigurationError(
+                "machine connector principal has no approved source binding"
+            )
+        repository = ConnectorRepository(
+            db.session,
+            identity["principal_uid"],
+            run_access="machine",
+            principal_uid=identity["principal_uid"],
+        )
+        repository.register_manifest(manifest, identity["principal_uid"])
+        db.session.commit()
+        operation_request = OperationRequest(
+            source_uid=str(payload.get("source_uid")),
+            operation=str(payload.get("operation")),
+            config=identity["approved_config"],
+            scope=payload.get("scope") or {},
+            cursor=payload.get("cursor") or {},
+            checkpoint=payload.get("checkpoint") or {},
+            idempotency_key=payload.get("idempotency_key"),
+            dry_run=bool(payload.get("dry_run", False)),
+            principal_uid=identity["principal_uid"],
+            business_domain_uid=str(payload.get("business_domain_uid")),
+            environment=str(payload.get("environment")),
+            process_key=str(payload.get("process_key") or ""),
+            source_binding_uid=identity["source_binding_uid"],
+            source_binding_version=identity["source_binding_version"],
+        )
+        result = ConnectorRuntime(registry, store=repository).execute(
+            payload.get("connector_id"), payload.get("version"), operation_request
+        )
+        from dataclasses import asdict
+
+        return jsonify(success(asdict(result), code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs/<idempotency_key>/cancel", methods=["POST"])
+def connector_run_cancel(idempotency_key):
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    try:
+        result = ConnectorRuntime(
+            _connector_registry(),
+            store=ConnectorRepository(
+                db.session, _actor_uid(), run_access="human"
+            ),
+        ).cancel(idempotency_key)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs/<idempotency_key>/resume", methods=["POST"])
+def connector_run_resume(idempotency_key):
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    try:
+        result = ConnectorRuntime(
+            _connector_registry(),
+            store=ConnectorRepository(
+                db.session, _actor_uid(), run_access="human"
+            ),
+        ).resume(idempotency_key)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+def _machine_run_action(idempotency_key, action):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    token = request.headers.get("X-Connector-Credential", "").strip()
+    if not token:
+        return _error_response(
+            ConnectorAuthenticationError("machine credential is required")
+        )
+    try:
+        record = ConnectorRepository(db.session).get(idempotency_key)
+        if not record or not record.get("principal_uid") or record.get("dry_run"):
+            raise ConnectorConfigurationError("machine connector run was not found")
+        identity = ConnectorIdentityRepository(db.session).authenticate(
+            token,
+            connector_id=record["connector_id"],
+            connector_version=record["connector_version"],
+            source_uid=record["source_uid"],
+            business_domain_uid=record["business_domain_uid"],
+            environment=record["environment"],
+            operation=action,
+            scope=record.get("scope") or {},
+        )
+        if (
+            identity["principal_uid"] != record["principal_uid"]
+            or identity.get("source_binding_uid") != record.get("source_binding_uid")
+            or identity.get("source_binding_version")
+            != record.get("source_binding_version")
+        ):
+            raise ConnectorAuthenticationError(
+                "machine credential run binding was rejected"
+            )
+        repository = ConnectorRepository(
+            db.session,
+            identity["principal_uid"],
+            run_access="machine",
+            principal_uid=identity["principal_uid"],
+        )
+        runtime = ConnectorRuntime(_connector_registry(), store=repository)
+        result = getattr(runtime, action)(idempotency_key)
+        if action == "resume":
+            from dataclasses import asdict
+
+            result = asdict(result)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route(
+    "/connectors/machine/runs/<idempotency_key>/cancel", methods=["POST"]
+)
+def connector_machine_run_cancel(idempotency_key):
+    """Cancel one bound machine run with a fresh one-time credential."""
+    return _machine_run_action(idempotency_key, "cancel")
+
+
+@bp.route(
+    "/connectors/machine/runs/<idempotency_key>/resume", methods=["POST"]
+)
+def connector_machine_run_resume(idempotency_key):
+    """Resume one bound machine run with a fresh one-time credential."""
+    return _machine_run_action(idempotency_key, "resume")
+
+
+@bp.route("/connectors/source-bindings", methods=["GET"])
+def connector_source_bindings_list():
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+
+    try:
+        items = ConnectorSourceBindingRepository(db.session).list_public(
+            request.args.get("limit", 100)
+        )
+        return jsonify(success({"source_bindings": items})), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/source-bindings", methods=["POST"])
+def connector_source_binding_approve():
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+    from app.core.connectors.repository import ConnectorRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        registry = _connector_registry()
+        manifest = registry.resolve(
+            payload.get("connector_id"), payload.get("version")
+        ).manifest
+        approved_config = registry.validate(
+            payload.get("connector_id"),
+            payload.get("version"),
+            payload.get("approved_config") or {},
+        )
+        ConnectorRepository(db.session, _actor_uid()).register_manifest(
+            manifest, _actor_uid()
+        )
+        result = ConnectorSourceBindingRepository(db.session).approve(
+            connector_id=payload.get("connector_id"),
+            connector_version=payload.get("version"),
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            environment=payload.get("environment"),
+            approved_config=approved_config,
+            approved_by=_actor_uid(),
+            binding_uid=payload.get("binding_uid"),
+        )
+        return jsonify(success(result, code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/source-bindings/<binding_uid>/revoke", methods=["POST"])
+def connector_source_binding_revoke(binding_uid):
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+
+    try:
+        revoked = ConnectorSourceBindingRepository(db.session).revoke(
+            binding_uid, _actor_uid()
+        )
+        return jsonify(success(revoked)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/principals", methods=["POST"])
+def connector_principal_create():
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        _connector_registry().resolve(
+            payload.get("connector_id"), payload.get("version", "1.0.0")
+        )
+        uid = ConnectorIdentityRepository(db.session).create_principal(
+            connector_id=payload.get("connector_id"),
+            connector_version=payload.get("version", "1.0.0"),
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            environment=payload.get("environment"),
+            operations=payload.get("operations") or (),
+            scopes=payload.get("scopes") or {},
+            actor_uid=_actor_uid(),
+            source_binding_uid=payload.get("source_binding_uid"),
+            source_binding_version=payload.get("source_binding_version"),
+        )
+        return jsonify(success({"principal_uid": uid}, code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/principals/<principal_uid>/credentials", methods=["POST"])
+def connector_credential_issue(principal_uid):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        result = ConnectorIdentityRepository(db.session).issue(
+            principal_uid,
+            ttl_seconds=payload.get("ttl_seconds", 900),
+            actor_uid=_actor_uid(),
+        )
+        response = jsonify(success(result, code=201))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/credentials/<credential_uid>/<action>", methods=["POST"])
+def connector_credential_action(credential_uid, action):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        repository = ConnectorIdentityRepository(db.session)
+        if action == "revoke":
+            result = {"revoked": repository.revoke(credential_uid, _actor_uid())}
+        elif action == "rotate":
+            result = repository.rotate(
+                credential_uid,
+                ttl_seconds=payload.get("ttl_seconds", 900),
+                actor_uid=_actor_uid(),
+            )
+        else:
+            raise ConnectorConfigurationError("credential action is invalid")
+        response = jsonify(success(result))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)

+ 15 - 0
app/core/connectors/__init__.py

@@ -0,0 +1,15 @@
+"""Stable connector SDK public entry point."""
+
+from app.core.connectors import errors as _errors
+from app.core.connectors import sdk as _sdk
+from app.core.connectors.errors import *  # noqa: F401,F403
+from app.core.connectors.registry import ConnectorRegistry
+from app.core.connectors.sdk import *  # noqa: F401,F403
+from app.core.connectors.secrets import EnvironmentSecretResolver
+
+__all__ = [
+    "ConnectorRegistry",
+    "EnvironmentSecretResolver",
+    *_sdk.__all__,
+    *_errors.__all__,
+]

+ 224 - 0
app/core/connectors/bindings.py

@@ -0,0 +1,224 @@
+"""Server-approved connector source and destination bindings."""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import urlsplit
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import ConnectorConfigurationError
+from app.core.connectors.sdk import SECRET_REF_PATTERN, _walk_secrets
+
+ENVIRONMENTS = frozenset({"development", "staging", "production"})
+
+
+def validate_approved_config(connector_id, config):
+    if not isinstance(config, dict):
+        raise ConnectorConfigurationError("approved connector config must be an object")
+    _walk_secrets(config, "approved_config")
+    if connector_id != "rest-catalog":
+        return dict(config)
+    if set(config) != {"base_url", "allowed_host", "credential_ref"}:
+        raise ConnectorConfigurationError("REST approved config is incomplete")
+    parsed = urlsplit(str(config["base_url"]))
+    host = str(config["allowed_host"]).lower().rstrip(".")
+    if (
+        parsed.scheme != "https"
+        or not parsed.hostname
+        or parsed.username
+        or parsed.password
+        or parsed.query
+        or parsed.fragment
+        or parsed.hostname.lower().rstrip(".") != host
+    ):
+        raise ConnectorConfigurationError("REST approved destination is invalid")
+    credential_ref = str(config["credential_ref"])
+    if not SECRET_REF_PATTERN.fullmatch(credential_ref):
+        raise ConnectorConfigurationError("REST approved credential reference is invalid")
+    return {
+        "base_url": str(config["base_url"]).rstrip("/"),
+        "allowed_host": host,
+        "credential_ref": credential_ref,
+    }
+
+
+class ConnectorSourceBindingRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def approve(
+        self,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        approved_config,
+        approved_by,
+        binding_uid=None,
+    ):
+        if environment not in ENVIRONMENTS:
+            raise ConnectorConfigurationError("connector binding environment is invalid")
+        config = validate_approved_config(connector_id, approved_config)
+        uid = binding_uid or new_governance_uid()
+        if binding_uid:
+            self.session.execute(
+                text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
+                {"lock_key": f"connector-binding:{uid}"},
+            )
+            current = self.session.execute(
+                text("""
+                SELECT COALESCE(MAX(binding_version),0)
+                  FROM public.connector_source_bindings
+                 WHERE uid=CAST(:uid AS uuid)
+                """),
+                {"uid": uid},
+            ).scalar_one()
+            self.session.execute(
+                text("""
+                UPDATE public.connector_source_bindings
+                   SET status='revoked',updated_at=CURRENT_TIMESTAMP
+                 WHERE uid=CAST(:uid AS uuid) AND status='approved'
+                """),
+                {"uid": uid},
+            )
+            version = int(current) + 1
+        else:
+            version = 1
+        rest = config if connector_id == "rest-catalog" else {}
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_source_bindings
+              (uid,binding_version,connector_id,connector_version,source_uid,
+               business_domain_uid,environment,approved_base_url,allowed_host,
+               credential_ref,approved_config,status,approved_by)
+            VALUES(CAST(:uid AS uuid),:binding_version,:connector,:version,
+                   CAST(:source AS uuid),CAST(:domain AS uuid),:environment,
+                   :base_url,:allowed_host,:credential_ref,CAST(:config AS jsonb),
+                   'approved',CAST(:approved_by AS uuid))
+            """),
+            {
+                "uid": uid,
+                "binding_version": version,
+                "connector": connector_id,
+                "version": connector_version,
+                "source": source_uid,
+                "domain": business_domain_uid,
+                "environment": environment,
+                "base_url": rest.get("base_url"),
+                "allowed_host": rest.get("allowed_host"),
+                "credential_ref": rest.get("credential_ref"),
+                "config": json.dumps(config, sort_keys=True),
+                "approved_by": approved_by,
+            },
+        )
+        rebound_principals = 0
+        if binding_uid and int(current) > 0:
+            rebound_principals = self.session.execute(
+                text("""
+                    UPDATE public.connector_principals
+                       SET source_binding_version=:binding_version
+                     WHERE source_binding_uid=CAST(:uid AS uuid)
+                       AND source_binding_version=:old_version
+                """),
+                {
+                    "uid": uid,
+                    "old_version": int(current),
+                    "binding_version": version,
+                },
+            ).rowcount
+        self.session.commit()
+        return {
+            "binding_uid": uid,
+            "binding_version": version,
+            "status": "approved",
+            "rebound_principals": rebound_principals,
+        }
+
+    def revoke(self, binding_uid, approved_by):
+        principals = self.session.execute(
+            text("""
+                UPDATE public.connector_principals
+                   SET status='revoked'
+                 WHERE source_binding_uid=CAST(:uid AS uuid) AND status='active'
+                 RETURNING uid
+            """),
+            {"uid": binding_uid},
+        ).all()
+        if principals:
+            principal_uids = [str(row[0]) for row in principals]
+            self.session.execute(
+                text("""
+                    UPDATE public.connector_machine_credentials
+                       SET status='revoked',revoked_at=CURRENT_TIMESTAMP
+                     WHERE principal_uid=ANY(CAST(:principals AS uuid[]))
+                       AND status='active'
+                """),
+                {"principals": principal_uids},
+            )
+        changed = self.session.execute(
+            text("""
+            UPDATE public.connector_source_bindings
+               SET status='revoked',approved_by=CAST(:actor AS uuid),updated_at=CURRENT_TIMESTAMP
+             WHERE uid=CAST(:uid AS uuid) AND status='approved'
+            """),
+            {"uid": binding_uid, "actor": approved_by},
+        ).rowcount
+        self.session.commit()
+        return {"revoked": bool(changed), "principals_deactivated": len(principals)}
+
+    def get_approved(self, binding_uid, binding_version=None):
+        version_clause = (
+            "AND binding_version=:binding_version" if binding_version is not None else ""
+        )
+        row = (
+            self.session.execute(
+                text(f"""
+                SELECT uid::text,binding_version,connector_id,connector_version,
+                       source_uid::text,business_domain_uid::text,environment,
+                       approved_base_url,allowed_host,credential_ref,approved_config,status
+                  FROM public.connector_source_bindings
+                 WHERE uid=CAST(:uid AS uuid) AND status='approved' {version_clause}
+                 ORDER BY binding_version DESC LIMIT 1
+                """),
+                {"uid": binding_uid, "binding_version": binding_version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row is None:
+            raise ConnectorConfigurationError("approved connector binding was not found")
+        result = dict(row)
+        config = dict(result.pop("approved_config") or {})
+        if result["connector_id"] == "rest-catalog":
+            config = {
+                "base_url": result.pop("approved_base_url"),
+                "allowed_host": result.pop("allowed_host"),
+                "credential_ref": result.pop("credential_ref"),
+            }
+        else:
+            result.pop("approved_base_url", None)
+            result.pop("allowed_host", None)
+            result.pop("credential_ref", None)
+        result["approved_config"] = config
+        return result
+
+    def list_public(self, limit=100):
+        rows = self.session.execute(
+            text("""
+            SELECT uid::text,binding_version,connector_id,connector_version,
+                   source_uid::text,business_domain_uid::text,environment,
+                   approved_base_url,allowed_host,status,approved_by::text,
+                   created_at,updated_at
+              FROM public.connector_source_bindings
+             ORDER BY created_at DESC LIMIT :limit
+            """),
+            {"limit": min(max(int(limit), 1), 200)},
+        ).mappings()
+        return [dict(row) for row in rows]
+
+
+__all__ = ["ConnectorSourceBindingRepository", "validate_approved_config"]

+ 29 - 0
app/core/connectors/builtin/__init__.py

@@ -0,0 +1,29 @@
+"""Built-in connectors registered exclusively through the public SDK."""
+
+from app.core.connectors.builtin.oracle import OracleConnector
+from app.core.connectors.builtin.rest_catalog import (
+    RestCatalogConnector,
+    SafeRestTransport,
+)
+from app.core.connectors.builtin.sqlserver import SqlServerConnector
+
+
+def register_builtin_connectors(registry, **dependencies):
+    registry.register(OracleConnector(dependencies.get("connection_provider")))
+    registry.register(SqlServerConnector(dependencies.get("connection_provider")))
+    transport = dependencies.get("rest_transport")
+    if transport is None:
+        transport = SafeRestTransport(
+            secret_resolver=dependencies.get("secret_resolver")
+        )
+    registry.register(RestCatalogConnector(transport))
+    return registry
+
+
+__all__ = [
+    "register_builtin_connectors",
+    "OracleConnector",
+    "SqlServerConnector",
+    "RestCatalogConnector",
+    "SafeRestTransport",
+]

+ 184 - 0
app/core/connectors/builtin/database.py

@@ -0,0 +1,184 @@
+"""Common normalized behavior for read-only relational catalog connectors."""
+
+from __future__ import annotations
+
+import hashlib
+import inspect
+import json
+
+from sqlalchemy import text
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    classify_error,
+)
+from app.core.connectors.sdk import Connector, OperationResult
+
+DATABASE_CONFIG_SCHEMA = {
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["credential_ref"],
+    "properties": {
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+    },
+}
+
+
+class ReadOnlyCatalogConnector(Connector):
+    catalog_sql = ""
+
+    def __init__(self, connection_provider=None):
+        self.connection_provider = connection_provider
+
+    @staticmethod
+    def _check_cancel(request):
+        if request.cancel_probe and request.cancel_probe():
+            raise ConnectorCancelledError()
+
+    @staticmethod
+    def _scope(request):
+        scope = dict(request.scope or {})
+        allowed = {
+            "include_schemas",
+            "exclude_schemas",
+            "include_tables",
+            "exclude_tables",
+        }
+        if set(scope) - allowed:
+            raise ConnectorConfigurationError("catalog scope is invalid")
+        result = {}
+        for name in allowed:
+            value = scope.get(name, ())
+            if not isinstance(value, (list, tuple)) or any(
+                not isinstance(item, str) or not item.strip() for item in value
+            ):
+                raise ConnectorConfigurationError("catalog scope values are invalid")
+            result[name] = frozenset(item.strip() for item in value)
+        return result
+
+    def _collect(self, request):
+        if self.connection_provider is None:
+            raise ConnectorConfigurationError("connection provider is not configured")
+        self._check_cancel(request)
+        scope = self._scope(request)
+        provider_kwargs = {}
+        if self.manifest.connector_id == "sqlserver":
+            parameters = inspect.signature(self.connection_provider).parameters
+            accepts_kwargs = any(
+                item.kind is inspect.Parameter.VAR_KEYWORD
+                for item in parameters.values()
+            )
+            if accepts_kwargs or "environment" in parameters:
+                provider_kwargs = {
+                    "environment": request.environment,
+                    "allow_insecure_development": bool(
+                        request.config.get("allow_insecure_development", False)
+                    ),
+                }
+        try:
+            with self.connection_provider(
+                request.source_uid, "metadata_collection", **provider_kwargs
+            ) as connection:
+                self._check_cancel(request)
+                rows = connection.execute(text(self.catalog_sql), {}).mappings().all()
+                self._check_cancel(request)
+        except Exception as error:
+            raise classify_error(error) from error
+        records = []
+        for raw in rows:
+            self._check_cancel(request)
+            row = {str(key).lower(): value for key, value in dict(raw).items()}
+            schema = str(row["schema_name"])
+            table = str(row["asset_name"])
+            if scope["include_schemas"] and schema not in scope["include_schemas"]:
+                continue
+            if schema in scope["exclude_schemas"] or table in scope["exclude_tables"]:
+                continue
+            if scope["include_tables"] and table not in scope["include_tables"]:
+                continue
+            records.append(
+                {
+                    "asset_key": f"{request.source_uid}:{schema}.{table}",
+                    "schema": schema,
+                    "name": table,
+                    "asset_type": "view"
+                    if "VIEW" in str(row["asset_type"]).upper()
+                    else "table",
+                    "field": str(row["column_name"]),
+                    "ordinal_position": int(row["ordinal_position"]),
+                    "data_type": str(row["data_type"]),
+                    "nullable": str(row.get("is_nullable", "NO")).upper()
+                    in {"YES", "Y", "TRUE", "1"},
+                    "default": row.get("column_default"),
+                    "comment": row.get("column_comment"),
+                }
+            )
+        records.sort(
+            key=lambda item: (item["schema"], item["name"], item["ordinal_position"])
+        )
+        digest = hashlib.sha256(
+            json.dumps(records, sort_keys=True, default=str).encode()
+        ).hexdigest()
+        snapshot_summary = {"sha256": digest, "record_count": len(records)}
+        checkpoint = {"snapshot_summary": snapshot_summary}
+        evidence = {
+            "query_kind": "read_only_metadata",
+            "record_count": len(records),
+            "snapshot_hash": digest,
+        }
+        if request.operation in {"incremental", "resume"}:
+            previous = request.checkpoint.get("snapshot_summary") or {}
+            evidence["diff"] = {
+                "changed": previous.get("sha256") != digest,
+                "previous_record_count": previous.get("record_count"),
+                "current_record_count": len(records),
+                "details_materialized": False,
+            }
+        return OperationResult(
+            records=tuple(records),
+            cursor={"snapshot_hash": digest},
+            checkpoint=checkpoint,
+            evidence=evidence,
+        )
+
+    def discover(self, request):
+        return self._collect(request)
+
+    def snapshot(self, request):
+        return self._collect(request)
+
+    def incremental(self, request):
+        return self._collect(request)
+
+    def lineage(self, request):
+        return OperationResult(
+            evidence={
+                "supported": False,
+                "reason": "database catalog has no portable lineage endpoint",
+            }
+        )
+
+    def profile(self, request):
+        return OperationResult(
+            evidence={"supported": False, "reason": "profile requires explicit opt-in"}
+        )
+
+    def cancel(self, request):
+        return OperationResult(status="cancelled", checkpoint=dict(request.checkpoint))
+
+    def resume(self, request):
+        return self._collect(request)
+
+    def evidence(self, request):
+        return OperationResult(
+            evidence={
+                "connector_id": self.manifest.connector_id,
+                "version": self.manifest.version,
+                "scope": self._scope(request),
+                "secret_material": False,
+            }
+        )

+ 63 - 0
app/core/connectors/builtin/oracle.py

@@ -0,0 +1,63 @@
+"""Oracle read-only catalog connector (driver and connection are optional)."""
+
+import importlib.util
+
+from app.core.connectors.builtin.database import (
+    DATABASE_CONFIG_SCHEMA,
+    ReadOnlyCatalogConnector,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    CompatibilityResult,
+    ConnectorManifest,
+    HealthResult,
+    validate_config,
+)
+
+ORACLE_CATALOG_SQL = """
+SELECT c.owner AS schema_name, c.table_name AS asset_name,
+       CASE WHEN v.view_name IS NULL THEN 'TABLE' ELSE 'VIEW' END AS asset_type,
+       c.column_name, c.column_id AS ordinal_position, c.data_type,
+       CASE WHEN c.nullable = 'Y' THEN 'YES' ELSE 'NO' END AS is_nullable,
+       c.data_default AS column_default, cc.comments AS column_comment
+FROM all_tab_columns c
+LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name
+LEFT JOIN all_col_comments cc ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name
+WHERE c.owner NOT IN ('SYS', 'SYSTEM', 'OUTLN', 'DBSNMP')
+ORDER BY c.owner, c.table_name, c.column_id
+"""
+
+
+class OracleConnector(ReadOnlyCatalogConnector):
+    catalog_sql = ORACLE_CATALOG_SQL
+    manifest = ConnectorManifest(
+        connector_id="oracle",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Oracle Database",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=DATABASE_CONFIG_SCHEMA,
+    )
+
+    def health(self, config):
+        validate_config(self.manifest.config_schema, config)
+        available = importlib.util.find_spec("oracledb") is not None
+        return HealthResult(
+            "available" if available else "degraded",
+            "" if available else "optional driver unavailable",
+        )
+
+    def compatibility(self):
+        available = importlib.util.find_spec("oracledb") is not None
+        return CompatibilityResult(
+            available,
+            self.manifest.version,
+            detail="" if available else "optional driver unavailable",
+        )

+ 381 - 0
app/core/connectors/builtin/rest_catalog.py

@@ -0,0 +1,381 @@
+"""Controlled REST catalog with DNS-pinned, bounded TLS transport."""
+
+from __future__ import annotations
+
+import hashlib
+import ipaddress
+import json
+import socket
+from collections.abc import Callable, Mapping
+from urllib.parse import urlencode, urlsplit
+
+import requests
+import urllib3
+from jsonschema import Draft202012Validator
+from jsonschema.exceptions import ValidationError
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    ConnectorContractError,
+    ConnectorUpstreamError,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    Connector,
+    ConnectorManifest,
+    OperationResult,
+)
+
+MAX_RESPONSE_BYTES = 2 * 1024 * 1024
+MAX_PAGES = 100
+MAX_RECORDS = 10000
+REST_CONFIG_SCHEMA = {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["base_url", "allowed_host", "credential_ref"],
+    "properties": {
+        "base_url": {
+            "type": "string",
+            "pattern": r"^https://[A-Za-z0-9.-]+(?::443)?(?:/[^?#\s]*)?$",
+            "maxLength": 2048,
+        },
+        "allowed_host": {
+            "type": "string",
+            "pattern": r"^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$",
+            "maxLength": 253,
+        },
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+    },
+}
+CATALOG_RESPONSE_SCHEMA = {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["assets"],
+    "properties": {
+        "assets": {
+            "type": "array",
+            "maxItems": 10000,
+            "items": {
+                "type": "object",
+                "additionalProperties": False,
+                "required": ["key", "name", "namespace", "type"],
+                "properties": {
+                    name: {"type": "string", "minLength": 1, "maxLength": 500}
+                    for name in ("key", "name", "namespace", "type")
+                },
+            },
+        },
+        "next_cursor": {
+            "type": "object",
+            "additionalProperties": {
+                "type": ["string", "number", "integer", "boolean", "null"]
+            },
+            "maxProperties": 20,
+        },
+        "evidence": {"type": "object"},
+        "completion_token": {"type": "string", "minLength": 1, "maxLength": 500},
+    },
+}
+
+
+class SafeRestTransport:
+    """Resolve once, reject non-global answers, then connect only to pinned IPs."""
+
+    def __init__(
+        self,
+        *,
+        secret_resolver: Callable[[str], str] | None = None,
+        resolver: Callable[..., object] = socket.getaddrinfo,
+        pool_factory: Callable[..., object] = urllib3.HTTPSConnectionPool,
+        connect_timeout: float = 3.0,
+        read_timeout: float = 10.0,
+        max_response_bytes: int = MAX_RESPONSE_BYTES,
+    ):
+        self.secret_resolver = secret_resolver
+        self.resolver = resolver
+        self.pool_factory = pool_factory
+        self.connect_timeout = float(connect_timeout)
+        self.read_timeout = float(read_timeout)
+        self.max_response_bytes = min(int(max_response_bytes), MAX_RESPONSE_BYTES)
+
+    def _destination(self, url, allowed_host):
+        parsed = urlsplit(url)
+        host = (parsed.hostname or "").lower().rstrip(".")
+        expected = str(allowed_host).lower().rstrip(".")
+        if (
+            parsed.scheme != "https"
+            or parsed.username
+            or parsed.password
+            or parsed.fragment
+            or host != expected
+        ):
+            raise ConnectorConfigurationError("catalog destination is not allowed")
+        if parsed.port not in {None, 443}:
+            raise ConnectorConfigurationError("catalog HTTPS port is not allowed")
+        try:
+            raw = self.resolver(host, 443, type=socket.SOCK_STREAM)
+            addresses = sorted({item[4][0] for item in raw})
+            parsed_addresses = [ipaddress.ip_address(item) for item in addresses]
+        except (OSError, ValueError) as exc:
+            raise ConnectorConfigurationError("catalog host resolution failed") from exc
+        if not parsed_addresses or any(not item.is_global for item in parsed_addresses):
+            raise ConnectorConfigurationError(
+                "catalog host must resolve only to public addresses"
+            )
+        return parsed, tuple(str(item) for item in parsed_addresses)
+
+    def _decode(self, response, cancel_probe=None):
+        status = int(response.status)
+        if 300 <= status < 400:
+            raise ConnectorUpstreamError("catalog redirect was rejected")
+        if status != 200:
+            raise ConnectorUpstreamError("catalog upstream request failed")
+        content_type = str(response.headers.get("Content-Type", "")).lower()
+        if "application/json" not in content_type:
+            raise ConnectorContractError("catalog response must be JSON")
+        length = response.headers.get("Content-Length")
+        if length is not None:
+            try:
+                if int(length) > self.max_response_bytes or int(length) < 0:
+                    raise ConnectorContractError("catalog response exceeds size limit")
+            except ValueError as exc:
+                raise ConnectorContractError(
+                    "catalog content length is invalid"
+                ) from exc
+        body = bytearray()
+        try:
+            for chunk in response.stream(amt=64 * 1024, decode_content=True):
+                if cancel_probe and cancel_probe():
+                    raise ConnectorCancelledError()
+                body.extend(chunk)
+                if len(body) > self.max_response_bytes:
+                    raise ConnectorContractError("catalog response exceeds size limit")
+        except (urllib3.exceptions.HTTPError, OSError) as exc:
+            raise ConnectorUpstreamError("catalog response read failed") from exc
+        try:
+            payload = json.loads(body.decode())
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ConnectorContractError("catalog response is not valid JSON") from exc
+        if not isinstance(payload, Mapping):
+            raise ConnectorContractError("catalog response must be a JSON object")
+        try:
+            Draft202012Validator(CATALOG_RESPONSE_SCHEMA).validate(payload)
+        except ValidationError as exc:
+            raise ConnectorContractError("catalog response shape is invalid") from exc
+        return dict(payload)
+
+    def get_json(self, *, url, allowed_host, credential_ref, cancel_probe=None):
+        if self.secret_resolver is None:
+            raise ConnectorConfigurationError("REST secret resolver is not configured")
+        authorization = self.secret_resolver(credential_ref)
+        if not isinstance(authorization, str) or not authorization.strip():
+            raise ConnectorConfigurationError(
+                "REST authorization secret is unavailable"
+            )
+        parsed, addresses = self._destination(url, allowed_host)
+        hostname = parsed.hostname.lower()
+        target = parsed.path or "/"
+        if parsed.query:
+            target += f"?{parsed.query}"
+        last_error = None
+        for address in addresses:
+            if cancel_probe and cancel_probe():
+                raise ConnectorCancelledError()
+            pool = self.pool_factory(
+                host=address,
+                port=443,
+                server_hostname=hostname,
+                assert_hostname=hostname,
+                cert_reqs="CERT_REQUIRED",
+                ca_certs=requests.certs.where(),
+                timeout=urllib3.Timeout(
+                    connect=self.connect_timeout, read=self.read_timeout
+                ),
+                retries=False,
+                maxsize=1,
+                block=True,
+            )
+            try:
+                response = pool.urlopen(
+                    "GET",
+                    target,
+                    headers={
+                        "Host": hostname,
+                        "Accept": "application/json",
+                        "Authorization": f"Bearer {authorization.strip()}",
+                    },
+                    redirect=False,
+                    retries=False,
+                    assert_same_host=False,
+                    preload_content=False,
+                    decode_content=True,
+                    timeout=urllib3.Timeout(
+                        connect=self.connect_timeout, read=self.read_timeout
+                    ),
+                )
+            except (urllib3.exceptions.HTTPError, OSError) as exc:
+                last_error = exc
+                pool.close()
+                continue
+            try:
+                return self._decode(response, cancel_probe=cancel_probe)
+            finally:
+                response.release_conn()
+                pool.close()
+        raise ConnectorUpstreamError("catalog connection failed") from last_error
+
+
+class RestCatalogConnector(Connector):
+    manifest = ConnectorManifest(
+        connector_id="rest-catalog",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Controlled REST Catalog",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=REST_CONFIG_SCHEMA,
+    )
+
+    def __init__(self, transport=None):
+        self.transport = transport or SafeRestTransport()
+
+    @staticmethod
+    def _check_cancel(request):
+        if request.cancel_probe and request.cancel_probe():
+            raise ConnectorCancelledError()
+
+    def _read(self, request):
+        self._check_cancel(request)
+        cursor = request.cursor or request.checkpoint.get("cursor") or {}
+        if cursor.get("state") == "complete":
+            cursor = {}
+        elif "state" in cursor:
+            raise ConnectorContractError("catalog cursor state is invalid")
+        seen_cursors = set()
+        records = []
+        page_count = 0
+        while True:
+            self._check_cancel(request)
+            marker = json.dumps(cursor, sort_keys=True, separators=(",", ":"))
+            if marker in seen_cursors:
+                raise ConnectorContractError("catalog cursor repeated")
+            seen_cursors.add(marker)
+            query = f"?{urlencode({'cursor': marker})}" if cursor else ""
+            payload = self.transport.get_json(
+                url=request.config["base_url"].rstrip("/") + "/v1/catalog" + query,
+                allowed_host=request.config["allowed_host"],
+                credential_ref=request.config["credential_ref"],
+                cancel_probe=request.cancel_probe,
+            )
+            page_count += 1
+            records.extend(
+                {key: asset[key] for key in sorted(asset)}
+                for asset in payload["assets"]
+            )
+            if len(records) > MAX_RECORDS:
+                raise ConnectorContractError("catalog record limit exceeded")
+            next_cursor = payload.get("next_cursor") or {}
+            if not next_cursor:
+                break
+            if page_count >= MAX_PAGES:
+                raise ConnectorContractError("catalog page limit exceeded")
+            for name, value in next_cursor.items():
+                previous = cursor.get(name)
+                if (
+                    isinstance(previous, (int, float))
+                    and isinstance(value, (int, float))
+                    and value <= previous
+                ):
+                    raise ConnectorContractError("catalog cursor is not monotonic")
+            cursor = next_cursor
+        records = tuple(records)
+        snapshot_hash = hashlib.sha256(
+            json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
+        ).hexdigest()
+        completed_cursor = {
+            "state": "complete",
+            "completion_marker": payload.get("completion_token") or snapshot_hash,
+        }
+        snapshot_summary = {
+            "sha256": snapshot_hash,
+            "record_count": len(records),
+        }
+        checkpoint = {
+            "cursor": completed_cursor,
+            "snapshot_summary": snapshot_summary,
+        }
+        evidence = {
+            "transport": "https-pinned-ip",
+            "redirects": "disabled",
+            "record_count": len(records),
+            "page_count": page_count,
+        }
+        if request.operation in {"incremental", "resume"}:
+            previous = request.checkpoint.get("snapshot_summary") or {}
+            evidence["diff"] = {
+                "changed": previous.get("sha256") != snapshot_hash,
+                "previous_record_count": previous.get("record_count"),
+                "current_record_count": len(records),
+                "details_materialized": False,
+            }
+        return OperationResult(
+            records=records,
+            cursor=completed_cursor,
+            checkpoint=checkpoint,
+            evidence=evidence,
+        )
+
+    def discover(self, request):
+        return self._read(request)
+
+    def snapshot(self, request):
+        return self._read(request)
+
+    def incremental(self, request):
+        return self._read(request)
+
+    def lineage(self, request):
+        return self._read(request)
+
+    def profile(self, request):
+        return self._read(request)
+
+    def cancel(self, request):
+        return OperationResult(
+            status="cancelled", checkpoint=request.checkpoint, cursor=request.cursor
+        )
+
+    def resume(self, request):
+        return self._read(request)
+
+    def evidence(self, request):
+        return OperationResult(
+            evidence={
+                "transport": "https-pinned-ip",
+                "redirects": "disabled",
+                "max_response_bytes": MAX_RESPONSE_BYTES,
+            }
+        )
+
+
+__all__ = [
+    "RestCatalogConnector",
+    "SafeRestTransport",
+    "REST_CONFIG_SCHEMA",
+    "CATALOG_RESPONSE_SCHEMA",
+    "MAX_RESPONSE_BYTES",
+    "MAX_PAGES",
+    "MAX_RECORDS",
+]

+ 78 - 0
app/core/connectors/builtin/sqlserver.py

@@ -0,0 +1,78 @@
+"""SQL Server read-only catalog connector (driver and connection are optional)."""
+
+import importlib.util
+
+from app.core.connectors.builtin.database import (
+    ReadOnlyCatalogConnector,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    CompatibilityResult,
+    ConnectorManifest,
+    HealthResult,
+    validate_config,
+)
+
+SQLSERVER_CATALOG_SQL = """
+SELECT s.name AS schema_name, o.name AS asset_name,
+       CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'TABLE' END AS asset_type,
+       c.name AS column_name, c.column_id AS ordinal_position, t.name AS data_type,
+       CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS is_nullable,
+       OBJECT_DEFINITION(c.default_object_id) AS column_default,
+       CAST(ep.value AS nvarchar(4000)) AS column_comment
+FROM sys.objects o
+JOIN sys.schemas s ON s.schema_id = o.schema_id
+JOIN sys.columns c ON c.object_id = o.object_id
+JOIN sys.types t ON t.user_type_id = c.user_type_id
+LEFT JOIN sys.extended_properties ep ON ep.major_id = o.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
+WHERE o.type IN ('U', 'V') AND o.is_ms_shipped = 0
+ORDER BY s.name, o.name, c.column_id
+"""
+
+SQLSERVER_CONFIG_SCHEMA = {
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["credential_ref"],
+    "properties": {
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+        "allow_insecure_development": {"type": "boolean"},
+    },
+}
+
+
+class SqlServerConnector(ReadOnlyCatalogConnector):
+    catalog_sql = SQLSERVER_CATALOG_SQL
+    manifest = ConnectorManifest(
+        connector_id="sqlserver",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Microsoft SQL Server",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=SQLSERVER_CONFIG_SCHEMA,
+    )
+
+    def health(self, config):
+        validate_config(self.manifest.config_schema, config)
+        available = importlib.util.find_spec("pyodbc") is not None
+        return HealthResult(
+            "available" if available else "degraded",
+            "" if available else "optional driver unavailable",
+        )
+
+    def compatibility(self):
+        available = importlib.util.find_spec("pyodbc") is not None
+        return CompatibilityResult(
+            available,
+            self.manifest.version,
+            detail="" if available else "optional driver unavailable",
+        )

+ 84 - 0
app/core/connectors/errors.py

@@ -0,0 +1,84 @@
+"""Stable, secret-free connector failure taxonomy."""
+
+from __future__ import annotations
+
+
+class ConnectorError(RuntimeError):
+    category = "upstream"
+    retryable = False
+    http_status = 502
+
+    def __init__(self, message="connector operation failed"):
+        super().__init__(message)
+
+
+class ConnectorConfigurationError(ConnectorError):
+    category = "configuration"
+    http_status = 400
+
+
+class ConnectorConflictError(ConnectorError):
+    category = "conflict"
+    http_status = 409
+
+
+class ConnectorAuthenticationError(ConnectorError):
+    category = "authentication"
+    http_status = 401
+
+
+class ConnectorPermissionError(ConnectorError):
+    category = "permission"
+    http_status = 403
+
+
+class ConnectorRateLimitError(ConnectorError):
+    category = "rate_limit"
+    retryable = True
+    http_status = 429
+
+
+class ConnectorTimeoutError(ConnectorError):
+    category = "timeout"
+    retryable = True
+    http_status = 504
+
+
+class ConnectorUpstreamError(ConnectorError):
+    category = "upstream"
+    retryable = True
+
+
+class ConnectorContractError(ConnectorError):
+    category = "contract"
+
+
+class ConnectorCancelledError(ConnectorError):
+    category = "cancelled"
+    http_status = 409
+
+
+class ConnectorDriverUnavailableError(ConnectorError):
+    category = "configuration"
+    http_status = 503
+
+
+def classify_error(error):
+    """Map driver/transport failures without exposing their text."""
+    if isinstance(error, ConnectorError):
+        return error
+    name = type(error).__name__.lower()
+    if "driver" in name and "unavailable" in name:
+        return ConnectorDriverUnavailableError()
+    if "auth" in name or "credential" in name:
+        return ConnectorAuthenticationError()
+    if "permission" in name or "access" in name:
+        return ConnectorPermissionError()
+    if "timeout" in name:
+        return ConnectorTimeoutError()
+    if "rate" in name or "thrott" in name:
+        return ConnectorRateLimitError()
+    return ConnectorUpstreamError()
+
+
+__all__ = [name for name in globals() if name.startswith("Connector")]

+ 379 - 0
app/core/connectors/identity.py

@@ -0,0 +1,379 @@
+"""Short-lived, one-time-issued connector machine credentials."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+from collections.abc import Mapping
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorConfigurationError,
+    ConnectorPermissionError,
+)
+
+ALLOWED_OPERATIONS = frozenset(
+    {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "cancel",
+        "resume",
+        "evidence",
+    }
+)
+ALLOWED_SCOPE_KEYS = frozenset(
+    {"include_schemas", "exclude_schemas", "include_tables", "exclude_tables"}
+)
+
+
+def validate_machine_scope(scope):
+    if not isinstance(scope, Mapping) or set(scope) - ALLOWED_SCOPE_KEYS:
+        raise ConnectorConfigurationError("machine identity scope is invalid")
+    normalized = {}
+    for key, values in scope.items():
+        if not isinstance(values, list) or any(
+            not isinstance(value, str) or not value.strip() for value in values
+        ):
+            raise ConnectorConfigurationError(
+                "machine identity scope values are invalid"
+            )
+        normalized[key] = tuple(value.strip() for value in values)
+    return normalized
+
+
+def _token_hash(token):
+    return hashlib.sha256(str(token).encode()).hexdigest()
+
+
+class ConnectorIdentityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_principal(
+        self,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        operations,
+        scopes,
+        actor_uid,
+        source_binding_uid=None,
+        source_binding_version=None,
+    ):
+        operations = tuple(sorted(set(operations)))
+        if not operations or set(operations) - ALLOWED_OPERATIONS:
+            raise ConnectorConfigurationError("machine identity operations are invalid")
+        scopes = validate_machine_scope(scopes)
+        if environment not in {"development", "staging", "production"}:
+            raise ConnectorConfigurationError("machine identity environment is invalid")
+        if not source_binding_uid or source_binding_version is None:
+            raise ConnectorConfigurationError(
+                "enterprise connector principal requires an approved source binding"
+            )
+        uid = new_governance_uid()
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_principals
+              (uid, connector_id, connector_version, source_uid, business_domain_uid, environment,
+               allowed_operations, allowed_scopes, status, created_by,
+               source_binding_uid,source_binding_version)
+            VALUES (CAST(:uid AS uuid), :connector, :version, CAST(:source AS uuid), CAST(:domain AS uuid), :environment,
+                    CAST(:operations AS text[]), CAST(:scopes AS jsonb), 'active', CAST(:actor AS uuid),
+                    CAST(:binding AS uuid),:binding_version)
+        """),
+            {
+                "uid": uid,
+                "connector": connector_id,
+                "version": connector_version,
+                "source": source_uid,
+                "domain": business_domain_uid,
+                "environment": environment,
+                "operations": list(operations),
+                "scopes": __import__("json").dumps(scopes),
+                "actor": actor_uid,
+                "binding": source_binding_uid,
+                "binding_version": source_binding_version,
+            },
+        )
+        self._audit(uid, None, "principal_created", actor_uid, True)
+        self.session.commit()
+        return uid
+
+    def issue(self, principal_uid, *, ttl_seconds, actor_uid, rotated_from=None):
+        ttl = int(ttl_seconds)
+        if ttl < 60 or ttl > 900:
+            raise ConnectorConfigurationError(
+                "credential TTL must be between 60 and 900 seconds"
+            )
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"connector-principal:{principal_uid}"},
+        )
+        principal = self.session.execute(
+            text("""
+                SELECT p.status
+                  FROM public.connector_principals p
+                  JOIN public.connector_source_bindings b
+                    ON b.uid=p.source_binding_uid
+                   AND b.binding_version=p.source_binding_version
+                   AND b.status='approved'
+                   AND b.connector_id=p.connector_id
+                   AND b.connector_version=p.connector_version
+                   AND b.source_uid=p.source_uid
+                   AND b.business_domain_uid=p.business_domain_uid
+                   AND b.environment=p.environment
+                 WHERE p.uid=CAST(:uid AS uuid)
+                 FOR UPDATE OF p
+            """),
+            {"uid": principal_uid},
+        ).scalar_one_or_none()
+        if principal != "active":
+            raise ConnectorAuthenticationError(
+                "connector principal or approved binding is not active"
+            )
+        token = "dopc_" + secrets.token_urlsafe(32)
+        credential_uid = new_governance_uid()
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_machine_credentials
+              (uid, principal_uid, token_hash, status, expires_at, rotated_from_uid, issued_by)
+            VALUES (CAST(:uid AS uuid), CAST(:principal AS uuid), :hash, 'active',
+                    CURRENT_TIMESTAMP + (:ttl * INTERVAL '1 second'), CAST(:rotated AS uuid), CAST(:actor AS uuid))
+        """),
+            {
+                "uid": credential_uid,
+                "principal": principal_uid,
+                "hash": _token_hash(token),
+                "ttl": ttl,
+                "rotated": rotated_from,
+                "actor": actor_uid,
+            },
+        )
+        self._audit(principal_uid, credential_uid, "credential_issued", actor_uid, True)
+        self.session.commit()
+        return {
+            "credential_uid": credential_uid,
+            "credential": token,
+            "expires_in": ttl,
+            "returned_once": True,
+        }
+
+    def authenticate(
+        self,
+        token,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        operation,
+        scope,
+    ):
+        scope = validate_machine_scope(scope)
+        digest = _token_hash(token)
+        expired = (
+            self.session.execute(
+                text("""
+            SELECT c.uid::text, c.principal_uid::text
+            FROM public.connector_machine_credentials c
+            WHERE c.token_hash=:hash AND c.status='active' AND c.expires_at<=CURRENT_TIMESTAMP
+            FOR UPDATE
+        """),
+                {"hash": digest},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if expired is not None:
+            self.session.execute(
+                text(
+                    "UPDATE public.connector_machine_credentials SET status='expired',revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": expired["uid"]},
+            )
+            self._audit(
+                expired["principal_uid"],
+                expired["uid"],
+                "credential_expired_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorAuthenticationError(
+                "machine credential is invalid or expired"
+            )
+        row = (
+            self.session.execute(
+                text("""
+            SELECT c.uid::text, c.principal_uid::text, c.use_count, p.connector_id,
+                   p.connector_version, p.source_uid::text, p.business_domain_uid::text,
+                   p.environment, p.allowed_operations, p.allowed_scopes,
+                   p.source_binding_uid::text,p.source_binding_version,
+                   b.approved_base_url,b.allowed_host,b.credential_ref,b.approved_config
+              FROM public.connector_machine_credentials c
+              JOIN public.connector_principals p ON c.principal_uid=p.uid
+              JOIN public.connector_source_bindings b
+                ON b.uid=p.source_binding_uid
+               AND b.binding_version=p.source_binding_version
+               AND b.status='approved'
+               AND b.connector_id=p.connector_id
+               AND b.connector_version=p.connector_version
+               AND b.source_uid=p.source_uid
+               AND b.business_domain_uid=p.business_domain_uid
+               AND b.environment=p.environment
+             WHERE c.token_hash=:hash AND c.status='active'
+               AND c.expires_at>CURRENT_TIMESTAMP AND p.status='active'
+             FOR UPDATE OF c
+        """),
+                {"hash": digest},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row is None:
+            self.session.rollback()
+            raise ConnectorAuthenticationError(
+                "machine credential is invalid or expired"
+            )
+        if int(row["use_count"]) > 0:
+            self.session.execute(
+                text(
+                    "UPDATE public.connector_machine_credentials SET status='replayed', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": row["uid"]},
+            )
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_replay_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorAuthenticationError("machine credential replay was rejected")
+        if (
+            not hmac.compare_digest(row["connector_id"], connector_id)
+            or not hmac.compare_digest(row["connector_version"], connector_version)
+            or row["source_uid"] != source_uid
+            or row["business_domain_uid"] != business_domain_uid
+            or row["environment"] != environment
+            or operation not in row["allowed_operations"]
+        ):
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_scope_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorPermissionError("machine credential scope was rejected")
+        allowed_scope = validate_machine_scope(row["allowed_scopes"] or {})
+        if any(
+            set(scope.get(key, ())) - set(allowed_scope.get(key, ())) for key in scope
+        ):
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_scope_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorPermissionError(
+                "machine credential resource scope was rejected"
+            )
+        identity = dict(row)
+        config = dict(identity.pop("approved_config", {}) or {})
+        if identity["connector_id"] == "rest-catalog":
+            if not identity.get("source_binding_uid"):
+                raise ConnectorPermissionError(
+                    "machine credential has no approved source binding"
+                )
+            config = {
+                "base_url": identity.pop("approved_base_url"),
+                "allowed_host": identity.pop("allowed_host"),
+                "credential_ref": identity.pop("credential_ref"),
+            }
+        else:
+            identity.pop("approved_base_url", None)
+            identity.pop("allowed_host", None)
+            identity.pop("credential_ref", None)
+        identity["approved_config"] = config
+        self.session.execute(
+            text("""
+                UPDATE public.connector_machine_credentials
+                   SET first_used_at=CURRENT_TIMESTAMP,use_count=use_count+1
+                 WHERE uid=CAST(:uid AS uuid) AND use_count=0 AND status='active'
+            """),
+            {"uid": row["uid"]},
+        )
+        self._audit(
+            row["principal_uid"], row["uid"], "credential_authenticated", None, True
+        )
+        self.session.commit()
+        return identity
+
+    def revoke(self, credential_uid, actor_uid):
+        principal_uid = self.session.execute(
+            text(
+                "UPDATE public.connector_machine_credentials SET status='revoked', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid) AND status='active' RETURNING principal_uid::text"
+            ),
+            {"uid": credential_uid},
+        ).scalar_one_or_none()
+        if principal_uid:
+            self._audit(
+                principal_uid, credential_uid, "credential_revoked", actor_uid, True
+            )
+        self.session.commit()
+        return principal_uid is not None
+
+    def rotate(self, credential_uid, *, ttl_seconds, actor_uid):
+        row = self.session.execute(
+            text(
+                "UPDATE public.connector_machine_credentials SET status='rotated', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid) AND status='active' RETURNING principal_uid::text"
+            ),
+            {"uid": credential_uid},
+        ).scalar_one_or_none()
+        if row is None:
+            raise ConnectorAuthenticationError("credential cannot be rotated")
+        self._audit(row, credential_uid, "credential_rotated", actor_uid, True)
+        return self.issue(
+            row,
+            ttl_seconds=ttl_seconds,
+            actor_uid=actor_uid,
+            rotated_from=credential_uid,
+        )
+
+    def _audit(self, principal_uid, credential_uid, event_type, actor_uid, success):
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_audit_events
+              (uid, principal_uid, credential_uid, event_type, actor_uid, success, safe_detail)
+            VALUES (CAST(:uid AS uuid), CAST(:principal AS uuid), CAST(:credential AS uuid), :event,
+                    CAST(:actor AS uuid), :success, :detail)
+        """),
+            {
+                "uid": new_governance_uid(),
+                "principal": principal_uid,
+                "credential": credential_uid,
+                "event": event_type,
+                "actor": actor_uid,
+                "success": success,
+                "detail": event_type.replace("_", " "),
+            },
+        )
+
+
+__all__ = ["ConnectorIdentityRepository", "ALLOWED_OPERATIONS"]

+ 52 - 0
app/core/connectors/registry.py

@@ -0,0 +1,52 @@
+"""Thread-safe, deny-by-default connector registry."""
+
+from __future__ import annotations
+
+import threading
+
+from app.core.connectors.errors import ConnectorConfigurationError
+from app.core.connectors.sdk import Connector, validate_config
+
+
+class ConnectorRegistry:
+    def __init__(self):
+        self._lock = threading.RLock()
+        self._connectors = {}
+
+    def register(self, connector: Connector):
+        manifest = connector.manifest
+        key = (manifest.connector_id, manifest.version)
+        with self._lock:
+            if key in self._connectors:
+                raise ConnectorConfigurationError(
+                    "connector version is already registered"
+                )
+            self._connectors[key] = connector
+        return connector
+
+    def resolve(self, connector_id, version, capability=None):
+        with self._lock:
+            connector = self._connectors.get((str(connector_id), str(version)))
+        if connector is None:
+            raise ConnectorConfigurationError("connector or version is not registered")
+        if capability and capability not in connector.manifest.capabilities:
+            raise ConnectorConfigurationError("connector capability is not declared")
+        return connector
+
+    def validate(self, connector_id, version, config):
+        connector = self.resolve(connector_id, version)
+        return validate_config(connector.manifest.config_schema, config)
+
+    def manifests(self):
+        with self._lock:
+            items = tuple(self._connectors.values())
+        return [
+            item.manifest.public_dict()
+            for item in sorted(
+                items,
+                key=lambda item: (item.manifest.connector_id, item.manifest.version),
+            )
+        ]
+
+
+__all__ = ["ConnectorRegistry"]

+ 513 - 0
app/core/connectors/repository.py

@@ -0,0 +1,513 @@
+"""PostgreSQL persistence for connector registrations, runs and graph edges."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import (
+    ConnectorConfigurationError,
+    ConnectorConflictError,
+)
+from app.core.connectors.store import UpdateOutcome
+
+
+class ConnectorRepository:
+    def __init__(self, session, actor_uid=None, *, run_access=None, principal_uid=None):
+        self.session = session
+        self.actor_uid = actor_uid
+        self.run_access = run_access
+        self.principal_uid = principal_uid
+
+    @staticmethod
+    def _control_edges(run):
+        edges = []
+        if run.get("business_domain_uid"):
+            edges.append(
+                (
+                    "business_domain",
+                    run["business_domain_uid"],
+                    "owns",
+                    "source",
+                    run["source_uid"],
+                )
+            )
+        edges.append(("source", run["source_uid"], "executed", "run", run["uid"]))
+        if run.get("process_key"):
+            edges.append(
+                ("process", run["process_key"], "executed_as", "run", run["uid"])
+            )
+        return edges
+
+    def _upsert_graph_edges(self, run, status, edges):
+        for from_type, from_key, relation, to_type, to_key in edges:
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_graph_edges
+                  (uid,source_uid,from_type,from_key,relation_type,to_type,to_key,business_domain_uid,run_uid,run_status,evidence)
+                VALUES(CAST(:uid AS uuid),CAST(:source AS uuid),:from_type,:from_key,:relation,:to_type,:to_key,
+                       CAST(:domain AS uuid),CAST(:run AS uuid),:status,'{}'::jsonb)
+                ON CONFLICT(source_uid,from_type,from_key,relation_type,to_type,to_key)
+                DO UPDATE SET run_uid=EXCLUDED.run_uid,run_status=EXCLUDED.run_status,created_at=CURRENT_TIMESTAMP
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "source": run["source_uid"],
+                    "from_type": from_type,
+                    "from_key": str(from_key),
+                    "relation": relation,
+                    "to_type": to_type,
+                    "to_key": str(to_key),
+                    "domain": run.get("business_domain_uid"),
+                    "run": run["uid"],
+                    "status": status,
+                },
+            )
+
+    def register_manifest(self, manifest, actor_uid):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"connector-manifest:{manifest.connector_id}"},
+        )
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_manifests
+              (uid, connector_id, connector_version, sdk_version, display_name, capabilities, config_schema, status, created_by)
+            VALUES (CAST(:uid AS uuid), :connector_id, :version, :sdk_version, :display_name,
+                    CAST(:capabilities AS jsonb), CAST(:schema AS jsonb), 'active', CAST(:actor AS uuid))
+            ON CONFLICT (connector_id, connector_version) DO UPDATE SET
+              sdk_version=EXCLUDED.sdk_version, display_name=EXCLUDED.display_name,
+              capabilities=EXCLUDED.capabilities, config_schema=EXCLUDED.config_schema,
+              status='active', updated_at=CURRENT_TIMESTAMP
+        """),
+            {
+                "uid": new_governance_uid(),
+                "connector_id": manifest.connector_id,
+                "version": manifest.version,
+                "sdk_version": manifest.sdk_version,
+                "display_name": manifest.display_name,
+                "capabilities": json.dumps(list(manifest.capabilities)),
+                "schema": json.dumps(manifest.config_schema),
+                "actor": actor_uid,
+            },
+        )
+
+    def claim(self, key, record):
+        uid = new_governance_uid()
+        request_hash = record.get("request_hash") or key
+        row = self.session.execute(
+            text("""
+            INSERT INTO public.connector_runs
+              (uid, idempotency_key, connector_id, connector_version, source_uid, operation, status,
+               attempt_count, checkpoint, cursor, dry_run, actor_uid, principal_uid,
+               business_domain_uid, environment, process_key, safe_config, scope,
+               request_hash,client_hint_hash,source_binding_uid,source_binding_version,
+               cancel_requested)
+            VALUES (CAST(:uid AS uuid), :key, :connector_id, :version, CAST(:source AS uuid), :operation,
+                    'running', 0, CAST(:checkpoint AS jsonb), CAST(:cursor AS jsonb), :dry_run, CAST(:actor AS uuid),
+                    CAST(:principal AS uuid), CAST(:domain AS uuid), :environment, :process_key,
+                    CAST(:config AS jsonb), CAST(:scope AS jsonb),:request_hash,:client_hint_hash,
+                    CAST(:binding AS uuid),:binding_version,FALSE)
+            ON CONFLICT DO NOTHING
+            RETURNING uid::text
+        """),
+            {
+                "uid": uid,
+                "key": key,
+                "connector_id": record["connector_id"],
+                "version": record["connector_version"],
+                "source": record["source_uid"],
+                "operation": record["operation"],
+                "checkpoint": json.dumps(record.get("checkpoint", {})),
+                "cursor": json.dumps(record.get("cursor", {})),
+                "dry_run": bool(record.get("dry_run", False)),
+                "actor": record.get("actor_uid") or self.actor_uid,
+                "principal": record.get("principal_uid"),
+                "domain": record.get("business_domain_uid"),
+                "environment": record.get("environment"),
+                "process_key": record.get("process_key"),
+                "config": json.dumps(record.get("config", {}), sort_keys=True),
+                "scope": json.dumps(record.get("scope", {}), sort_keys=True),
+                "request_hash": request_hash,
+                "client_hint_hash": record.get("client_hint_hash"),
+                "binding": record.get("source_binding_uid"),
+                "binding_version": record.get("source_binding_version"),
+            },
+        ).scalar_one_or_none()
+        if row:
+            claimed = {**record, "uid": row, "status": "running"}
+            self._upsert_graph_edges(claimed, "running", self._control_edges(claimed))
+            self.session.commit()
+            return claimed, True
+        self.session.rollback()
+        existing = (
+            self.session.execute(
+                text("""
+                SELECT idempotency_key,request_hash
+                  FROM public.connector_runs
+                 WHERE idempotency_key=:key
+                    OR (:client_hint_hash IS NOT NULL AND client_hint_hash=:client_hint_hash)
+                 LIMIT 1
+                """),
+                {"key": key, "client_hint_hash": record.get("client_hint_hash")},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if existing is None or existing["request_hash"] != request_hash:
+            self.session.rollback()
+            raise ConnectorConflictError("idempotency request binding conflicts")
+        return self.get(existing["idempotency_key"]), False
+
+    def update(self, key, **values):
+        result = values.pop("result", None)
+        allowed = {
+            "status",
+            "attempt_count",
+            "checkpoint",
+            "cursor",
+            "error_category",
+            "error_code",
+            "resumed_from",
+            "lease_token",
+            "expected_attempt",
+            "cancel_requested",
+        }
+        if set(values) - allowed:
+            raise ConnectorConfigurationError("connector run update is invalid")
+        if "attempt_count" in values and not 1 <= int(values["attempt_count"]) <= 5:
+            raise ConnectorConfigurationError(
+                "connector run attempt must be between 1 and 5"
+            )
+        expected_attempt = values.pop("expected_attempt", None)
+        lease_token = values.pop("lease_token", None)
+        assignments, parameters = [], {"key": key}
+        for name, value in values.items():
+            if name in {"checkpoint", "cursor"}:
+                assignments.append(f"{name}=CAST(:{name} AS jsonb)")
+                parameters[name] = json.dumps(value)
+            elif name == "resumed_from":
+                assignments.append("resumed_from_run_uid=CAST(:resumed_from AS uuid)")
+                parameters[name] = value
+            else:
+                assignments.append(f"{name}=:{name}")
+                parameters[name] = value
+        assignments.append("updated_at=CURRENT_TIMESTAMP")
+        requested_status = values.get("status")
+        terminal = requested_status in {"succeeded", "dry_run", "failed", "cancelled"}
+        guarded = terminal or requested_status in {"running", "resumable"}
+        if requested_status == "resumable":
+            status_clause = " AND status IN ('failed','cancelled')"
+        elif requested_status == "cancelled":
+            status_clause = " AND status='running'"
+        elif requested_status == "running" and "attempt_count" in values:
+            status_clause = (
+                " AND status IN ('running','resumable','failed')"
+                " AND attempt_count < :attempt_count"
+                " AND cancel_requested=FALSE"
+            )
+            assignments.append("attempt_lease_token=CAST(:lease_token AS uuid)")
+            parameters["lease_token"] = lease_token
+        elif requested_status in {"succeeded", "dry_run", "failed"}:
+            status_clause = (
+                " AND status='running' AND cancel_requested=FALSE"
+                " AND attempt_count=:expected_attempt"
+                " AND attempt_lease_token=CAST(:lease_token AS uuid)"
+            )
+            parameters["expected_attempt"] = expected_attempt
+            parameters["lease_token"] = lease_token
+        elif guarded:
+            status_clause = " AND status IN ('running','resumable','failed')"
+        else:
+            status_clause = ""
+        if self.run_access == "human":
+            status_clause += " AND principal_uid IS NULL AND dry_run=TRUE"
+        elif self.run_access == "machine":
+            status_clause += " AND principal_uid=CAST(:access_principal AS uuid)"
+            parameters["access_principal"] = self.principal_uid
+        update_result = self.session.execute(
+            text(
+                f"UPDATE public.connector_runs SET {', '.join(assignments)} WHERE idempotency_key=:key{status_clause}"
+            ),
+            parameters,
+        )
+        if guarded and update_result.rowcount != 1:
+            self.session.rollback()
+            return UpdateOutcome(self.get(key) or {}, False)
+        run_uid = self.session.execute(
+            text(
+                "SELECT uid::text FROM public.connector_runs WHERE idempotency_key=:key"
+            ),
+            {"key": key},
+        ).scalar_one()
+        if "attempt_count" in values:
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_run_attempts(uid,run_uid,attempt_number,status,lease_token)
+                VALUES(CAST(:uid AS uuid),CAST(:run AS uuid),:attempt,'running',CAST(:lease AS uuid))
+                ON CONFLICT(run_uid,attempt_number) DO NOTHING
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "attempt": values["attempt_count"],
+                    "lease": lease_token,
+                },
+            )
+        if values.get("status") in {"succeeded", "dry_run", "failed", "cancelled"}:
+            self.session.execute(
+                text("""
+                    UPDATE public.connector_run_attempts
+                       SET status=:status, finished_at=CURRENT_TIMESTAMP,
+                           error_category=:error_category
+                     WHERE run_uid=CAST(:run AS uuid)
+                       AND attempt_number=COALESCE(
+                         :attempt,
+                         (SELECT attempt_count FROM public.connector_runs WHERE uid=CAST(:run AS uuid))
+                       )
+                       AND lease_token=COALESCE(
+                         CAST(:lease AS uuid),
+                         (SELECT attempt_lease_token FROM public.connector_runs WHERE uid=CAST(:run AS uuid))
+                       )
+                """),
+                {
+                    "run": run_uid,
+                    "status": values["status"],
+                    "error_category": values.get("error_category"),
+                    "attempt": expected_attempt,
+                    "lease": lease_token,
+                },
+            )
+        if result is not None:
+            payload = dict(result.evidence)
+            encoded = json.dumps(payload, sort_keys=True, default=str).encode()
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_evidence
+                  (uid,run_uid,evidence_type,payload,content_hash,byte_size,redacted)
+                VALUES (CAST(:uid AS uuid),CAST(:run AS uuid),'operation',CAST(:payload AS jsonb),:hash,:size,TRUE)
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "payload": encoded.decode(),
+                    "hash": hashlib.sha256(encoded).hexdigest(),
+                    "size": len(encoded),
+                },
+            )
+            checkpoint_payload = json.dumps(
+                dict(result.checkpoint), sort_keys=True, default=str
+            )
+            cursor_payload = json.dumps(
+                dict(result.cursor), sort_keys=True, default=str
+            )
+            checkpoint_hash = hashlib.sha256(
+                (cursor_payload + checkpoint_payload).encode()
+            ).hexdigest()
+            sequence = self.session.execute(
+                text(
+                    "SELECT COALESCE(MAX(sequence_number),0)+1 FROM public.connector_checkpoints WHERE run_uid=CAST(:run AS uuid)"
+                ),
+                {"run": run_uid},
+            ).scalar_one()
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_checkpoints(uid,run_uid,sequence_number,cursor,checkpoint,content_hash)
+                VALUES(CAST(:uid AS uuid),CAST(:run AS uuid),:sequence,CAST(:cursor AS jsonb),CAST(:checkpoint AS jsonb),:hash)
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "sequence": sequence,
+                    "cursor": cursor_payload,
+                    "checkpoint": checkpoint_payload,
+                    "hash": checkpoint_hash,
+                },
+            )
+            run = self.get(key)
+            edge_values = []
+            for record in result.records:
+                asset_key = record.get("asset_key") or record.get("key")
+                if not asset_key:
+                    continue
+                edge_values.extend(
+                    [
+                        (
+                            "source",
+                            run["source_uid"],
+                            "contains",
+                            "asset",
+                            str(asset_key),
+                        ),
+                        ("run", run_uid, "observed", "asset", str(asset_key)),
+                    ]
+                )
+            self._upsert_graph_edges(
+                run, values.get("status", "succeeded"), edge_values
+            )
+        if requested_status:
+            self.session.execute(
+                text("""
+                UPDATE public.connector_graph_edges
+                   SET run_status=:status,created_at=CURRENT_TIMESTAMP
+                 WHERE run_uid=CAST(:run AS uuid)
+            """),
+                {"status": requested_status, "run": run_uid},
+            )
+        self.session.commit()
+        return UpdateOutcome(self.get(key) or {}, True)
+
+    def get(self, key):
+        row = (
+            self.session.execute(
+                text("""
+            SELECT uid::text, idempotency_key, connector_id, connector_version, source_uid::text,
+                   operation, status, attempt_count, checkpoint, cursor, error_category, error_code,
+                   resumed_from_run_uid::text, dry_run, created_at, updated_at
+                   ,principal_uid::text,business_domain_uid::text,environment,process_key,safe_config AS config,scope,
+                   request_hash,source_binding_uid::text,source_binding_version,
+                   attempt_lease_token::text,cancel_requested
+            FROM public.connector_runs
+            WHERE idempotency_key=:key OR client_hint_hash=:hint
+            ORDER BY (idempotency_key=:key) DESC LIMIT 1
+        """),
+                {
+                    "key": key,
+                    "hint": hashlib.sha256(str(key).encode()).hexdigest(),
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        result = dict(row) if row else None
+        if result and self.run_access == "human" and (
+            result.get("principal_uid") is not None or not result.get("dry_run")
+        ):
+            return None
+        if result and self.run_access == "machine" and result.get("principal_uid") != self.principal_uid:
+            return None
+        return result
+
+    def list(self, limit=100):
+        rows = (
+            self.session.execute(
+                text("""
+            SELECT uid::text, idempotency_key, connector_id, connector_version, source_uid::text,
+                   operation, status, attempt_count, checkpoint, cursor, error_category, dry_run, created_at, updated_at
+                   ,principal_uid::text,business_domain_uid::text,environment,process_key
+            FROM public.connector_runs ORDER BY created_at DESC LIMIT :limit
+        """),
+                {"limit": min(max(int(limit), 1), 200)},
+            )
+            .mappings()
+            .all()
+        )
+        result = []
+        for row in rows:
+            item = dict(row)
+            for name in ("checkpoint", "cursor"):
+                value = item.pop(name) or {}
+                encoded = json.dumps(value, sort_keys=True, default=str).encode()
+                item[f"{name}_summary"] = {
+                    "sha256": hashlib.sha256(encoded).hexdigest(),
+                    "item_count": len(value) if isinstance(value, (dict, list)) else 0,
+                    "byte_size": len(encoded),
+                }
+            result.append(item)
+        return result
+
+    def acquire_rate_limit(self, key, limit=30):
+        row = self.session.execute(
+            text("""
+            INSERT INTO public.connector_rate_limits(limit_key,window_started_at,request_count)
+            VALUES(:key,date_trunc('minute',CURRENT_TIMESTAMP),1)
+            ON CONFLICT(limit_key,window_started_at) DO UPDATE
+              SET request_count=public.connector_rate_limits.request_count+1,updated_at=CURRENT_TIMESTAMP
+              WHERE public.connector_rate_limits.request_count < :limit
+            RETURNING request_count
+        """),
+            {"key": key, "limit": min(int(limit), 30)},
+        ).scalar_one_or_none()
+        self.session.commit()
+        if row is None:
+            from app.core.connectors.errors import ConnectorRateLimitError
+
+            raise ConnectorRateLimitError("connector runtime rate limit exceeded")
+
+    def cancel(self, key):
+        outcome = self.update(
+            key,
+            status="cancelled",
+            cancel_requested=True,
+        )
+        if not outcome.acquired:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        return outcome.record
+
+    def is_cancel_requested(self, key):
+        value = self.session.execute(
+            text(
+                "SELECT cancel_requested FROM public.connector_runs WHERE idempotency_key=:key"
+            ),
+            {"key": key},
+        ).scalar_one_or_none()
+        return bool(value)
+
+    def graph(
+        self,
+        source_uid=None,
+        business_domain_uid=None,
+        process_key=None,
+        run_uid=None,
+        limit=500,
+    ):
+        filters = []
+        parameters = {"limit": min(max(int(limit), 1), 1000)}
+        if source_uid:
+            filters.append("source_uid=CAST(:source AS uuid)")
+            parameters["source"] = source_uid
+        if business_domain_uid:
+            filters.append("business_domain_uid=CAST(:domain AS uuid)")
+            parameters["domain"] = business_domain_uid
+        if process_key:
+            filters.append("(from_key=:process OR to_key=:process)")
+            parameters["process"] = process_key
+        if run_uid:
+            filters.append("run_uid=CAST(:run AS uuid)")
+            parameters["run"] = run_uid
+        clauses = "WHERE " + " AND ".join(filters) if filters else ""
+        rows = (
+            self.session.execute(
+                text(f"""
+            SELECT uid::text, source_uid::text, from_type, from_key, relation_type, to_type, to_key,
+                   business_domain_uid::text, run_uid::text, run_status, evidence, created_at
+            FROM public.connector_graph_edges {clauses}
+            ORDER BY created_at DESC LIMIT :limit
+        """),
+                parameters,
+            )
+            .mappings()
+            .all()
+        )
+        edges = [dict(row) for row in rows]
+        node_map = {}
+        for edge in edges:
+            node_map[(edge["from_type"], edge["from_key"])] = {
+                "type": edge["from_type"],
+                "key": edge["from_key"],
+            }
+            node_map[(edge["to_type"], edge["to_key"])] = {
+                "type": edge["to_type"],
+                "key": edge["to_key"],
+            }
+        return {
+            "nodes": list(node_map.values()),
+            "edges": edges,
+            "summary": {"node_count": len(node_map), "edge_count": len(edges)},
+        }
+
+
+__all__ = ["ConnectorRepository"]

+ 573 - 0
app/core/connectors/runtime.py

@@ -0,0 +1,573 @@
+"""Connector execution controls: idempotency, bounded retry, cancellation and evidence."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import threading
+import time
+import uuid
+from collections import deque
+from dataclasses import replace
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    ConnectorConflictError,
+    ConnectorRateLimitError,
+    classify_error,
+)
+from app.core.connectors.sdk import OperationResult
+from app.core.connectors.store import UpdateOutcome
+
+MAX_EVIDENCE_BYTES = 32768
+MAX_CURSOR_BYTES = 32768
+MAX_CHECKPOINT_BYTES = 262144
+MAX_RECORDS_BYTES = 1048576
+MAX_RECORDS = 10000
+MAX_TOTAL_ATTEMPTS = 5
+SENSITIVE_KEYS = {
+    "password",
+    "passwd",
+    "secret",
+    "token",
+    "api_key",
+    "authorization",
+    "credential",
+}
+
+
+def deterministic_idempotency_key(connector_id, version, request):
+    payload = {
+        "connector_id": connector_id,
+        "version": version,
+        "source_uid": request.source_uid,
+        "principal_uid": request.principal_uid,
+        "business_domain_uid": request.business_domain_uid,
+        "environment": request.environment,
+        "process_key": request.process_key,
+        "source_binding_uid": request.source_binding_uid,
+        "source_binding_version": request.source_binding_version,
+        "operation": request.operation,
+        "config": request.config,
+        "scope": request.scope,
+        "cursor": request.cursor,
+        "checkpoint": request.checkpoint,
+        "dry_run": request.dry_run,
+        "client_idempotency_hint": request.idempotency_key,
+    }
+    encoded = json.dumps(
+        payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
+    ).encode()
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def redact_evidence(
+    value, *, max_bytes=MAX_EVIDENCE_BYTES, summarize=True, truncate_lists=True
+):
+    def visit(item):
+        if isinstance(item, dict):
+            return {
+                str(key): "[REDACTED]"
+                if any(marker in str(key).lower() for marker in SENSITIVE_KEYS)
+                else visit(child)
+                for key, child in item.items()
+            }
+        if isinstance(item, (list, tuple)):
+            values = item[:1000] if truncate_lists else item
+            return [visit(child) for child in values]
+        if isinstance(item, str):
+            text_value = item[:4096]
+            text_value = re.sub(
+                r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*", "Bearer [REDACTED]", text_value
+            )
+            text_value = re.sub(r"\bdopc_[A-Za-z0-9_-]+", "[REDACTED]", text_value)
+            text_value = re.sub(
+                r"(?i)(password|token|secret|api[_-]?key)=([^&\s]+)",
+                r"\1=[REDACTED]",
+                text_value,
+            )
+            text_value = re.sub(
+                r"(?i)(https?://)[^/@\s]+@", r"\1[REDACTED]@", text_value
+            )
+            return text_value
+        return item
+
+    safe = visit(value)
+    encoded = json.dumps(safe, sort_keys=True, default=str).encode()
+    if summarize and len(encoded) > max_bytes:
+        return {
+            "truncated": True,
+            "sha256": hashlib.sha256(encoded).hexdigest(),
+            "original_bytes": len(encoded),
+        }
+    return safe
+
+
+def _bounded_channel(value, *, max_bytes, channel):
+    raw = json.dumps(
+        value, sort_keys=True, separators=(",", ":"), default=str
+    ).encode()
+    if len(raw) > max_bytes:
+        raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
+    safe = redact_evidence(
+        value, max_bytes=max_bytes, summarize=False, truncate_lists=False
+    )
+    encoded = json.dumps(
+        safe, sort_keys=True, separators=(",", ":"), default=str
+    ).encode()
+    if len(encoded) > max_bytes:
+        raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
+    return safe
+
+
+def sanitize_operation_result(result):
+    if not isinstance(result.records, (list, tuple)) or len(result.records) > MAX_RECORDS:
+        raise ConnectorConfigurationError("connector records exceed safe limit")
+    records = _bounded_channel(
+        list(result.records), max_bytes=MAX_RECORDS_BYTES, channel="records"
+    )
+    if any(not isinstance(item, dict) for item in records):
+        raise ConnectorConfigurationError("connector records must be objects")
+    cursor = _bounded_channel(
+        dict(result.cursor), max_bytes=MAX_CURSOR_BYTES, channel="cursor"
+    )
+    checkpoint = _bounded_channel(
+        dict(result.checkpoint), max_bytes=MAX_CHECKPOINT_BYTES, channel="checkpoint"
+    )
+    evidence = _bounded_channel(
+        dict(result.evidence), max_bytes=MAX_EVIDENCE_BYTES, channel="evidence"
+    )
+    return replace(
+        result,
+        records=tuple(records),
+        cursor=cursor,
+        checkpoint=checkpoint,
+        evidence=evidence,
+    )
+
+
+def snapshot_diff(previous, current):
+    def identity(item):
+        if isinstance(item, dict):
+            return (
+                str(item.get("asset_key") or item.get("key") or "")
+                + ":"
+                + str(item.get("field") or "")
+            )
+        return json.dumps(item, sort_keys=True, default=str)
+
+    before = {identity(item): item for item in previous}
+    after = {identity(item): item for item in current}
+    shared = before.keys() & after.keys()
+    return {
+        "added": tuple(after[key] for key in sorted(after.keys() - before.keys())),
+        "removed": tuple(before[key] for key in sorted(before.keys() - after.keys())),
+        "changed": tuple(
+            {"before": before[key], "after": after[key]}
+            for key in sorted(shared)
+            if before[key] != after[key]
+        ),
+    }
+
+
+class InMemoryRunStore:
+    """Test/reference store. Production API injects the PostgreSQL repository."""
+
+    def __init__(self):
+        self._lock = threading.RLock()
+        self._runs = {}
+        self._hints = {}
+
+    def claim(self, key, record):
+        with self._lock:
+            hint = record.get("client_hint_hash")
+            if hint and hint in self._hints and self._hints[hint] != key:
+                raise ConnectorConflictError("idempotency hint conflicts with another request")
+            if key in self._runs:
+                if self._runs[key].get("request_hash") != record.get("request_hash"):
+                    raise ConnectorConflictError("idempotency request binding conflicts")
+                return self._runs[key], False
+            self._runs[key] = dict(record)
+            if hint:
+                self._hints[hint] = key
+            return self._runs[key], True
+
+    def update(self, key, **values):
+        with self._lock:
+            record = self._runs[key]
+            current = record.get("status")
+            requested = values.get("status")
+            expected_attempt = values.pop("expected_attempt", None)
+            lease_token = values.pop("lease_token", None)
+            acquired = True
+            if requested == "resumable":
+                acquired = current in {"failed", "cancelled"}
+            elif requested == "cancelled":
+                acquired = current == "running"
+            elif requested == "running":
+                attempt = int(values.get("attempt_count", -1))
+                acquired = (
+                    current in {"running", "resumable", "failed"}
+                    and int(record.get("attempt_count", 0)) < attempt
+                )
+            elif requested in {"succeeded", "dry_run", "failed"}:
+                acquired = (
+                    current == "running"
+                    and not record.get("cancel_requested", False)
+                    and int(record.get("attempt_count", -1)) == int(expected_attempt or -1)
+                    and record.get("attempt_lease_token") == lease_token
+                )
+            if not acquired:
+                return UpdateOutcome(dict(record), False)
+            self._runs[key].update(values)
+            if requested == "running":
+                self._runs[key]["attempt_lease_token"] = lease_token
+            return UpdateOutcome(dict(self._runs[key]), True)
+
+    def cancel(self, key):
+        outcome = self.update(key, status="cancelled", cancel_requested=True)
+        if not outcome.acquired:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        return outcome.record
+
+    def is_cancel_requested(self, key):
+        with self._lock:
+            return bool(self._runs.get(key, {}).get("cancel_requested"))
+
+    def get(self, key):
+        with self._lock:
+            value = self._runs.get(key)
+            if value is None:
+                canonical = self._hints.get(hashlib.sha256(str(key).encode()).hexdigest())
+                value = self._runs.get(canonical) if canonical else None
+            return dict(value) if value else None
+
+    def list(self):
+        with self._lock:
+            return [dict(item) for item in self._runs.values()]
+
+
+class SlidingWindowLimiter:
+    def __init__(self, limit=30, window_seconds=60, clock=None):
+        self.limit = int(limit)
+        self.window_seconds = float(window_seconds)
+        self.clock = clock or time.monotonic
+        self._lock = threading.Lock()
+        self._events = {}
+
+    def acquire(self, key):
+        now = self.clock()
+        with self._lock:
+            events = self._events.setdefault(key, deque())
+            while events and events[0] <= now - self.window_seconds:
+                events.popleft()
+            if len(events) >= self.limit:
+                raise ConnectorRateLimitError("connector runtime rate limit exceeded")
+            events.append(now)
+
+
+class ConnectorRuntime:
+    def __init__(
+        self, registry, store=None, limiter=None, max_attempts=3, sleeper=None
+    ):
+        if max_attempts < 1 or max_attempts > 5:
+            raise ConnectorConfigurationError("max_attempts must be between 1 and 5")
+        self.registry = registry
+        self.store = store or InMemoryRunStore()
+        self.limiter = limiter or SlidingWindowLimiter()
+        self.max_attempts = max_attempts
+        self.sleeper = sleeper or time.sleep
+
+    def _acquire_rate_limit(self, connector_id, source_uid):
+        key = f"{connector_id}:{source_uid}"
+        if hasattr(self.store, "acquire_rate_limit"):
+            self.store.acquire_rate_limit(key)
+        else:
+            self.limiter.acquire(key)
+
+    def _run_operation(self, key, record, request, operation):
+        first_attempt = int(record.get("attempt_count", 0)) + 1
+        remaining = MAX_TOTAL_ATTEMPTS - first_attempt + 1
+        invocation_attempts = min(self.max_attempts, remaining)
+        if invocation_attempts <= 0:
+            raise ConnectorConfigurationError(
+                "connector run attempt budget is exhausted"
+            )
+        for attempt in range(first_attempt, first_attempt + invocation_attempts):
+            lease_token = str(uuid.uuid4())
+            started = self.store.update(
+                key,
+                attempt_count=attempt,
+                status="running",
+                error_category=None,
+                error_code=None,
+                lease_token=lease_token,
+            )
+            if not started.acquired:
+                if started.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError()
+                raise ConnectorConfigurationError(
+                    "connector run attempt is already owned"
+                )
+            started_record = started.record
+            if started_record.get("status") == "cancelled":
+                raise ConnectorCancelledError()
+            if (
+                started_record.get("status") != "running"
+                or int(started_record.get("attempt_count", -1)) != attempt
+            ):
+                raise ConnectorConfigurationError(
+                    "connector run attempt could not be claimed"
+                )
+            try:
+                def cancel_probe():
+                    return bool(
+                        hasattr(self.store, "is_cancel_requested")
+                        and self.store.is_cancel_requested(key)
+                    )
+                attempt_request = replace(
+                    request,
+                    run_key=key,
+                    lease_token=lease_token,
+                    cancel_probe=cancel_probe,
+                )
+                if cancel_probe():
+                    raise ConnectorCancelledError()
+                result = operation(attempt_request)
+                if not isinstance(result, OperationResult):
+                    raise ConnectorConfigurationError(
+                        "connector returned an invalid result"
+                    )
+                if result.status != "succeeded":
+                    raise ConnectorConfigurationError(
+                        "connector operation returned an invalid status"
+                    )
+                if cancel_probe():
+                    raise ConnectorCancelledError()
+                safe_result = sanitize_operation_result(result)
+                status = "dry_run" if request.dry_run else "succeeded"
+                safe_result = replace(safe_result, status=status)
+                updated = self.store.update(
+                    key,
+                    status=status,
+                    result=safe_result,
+                    checkpoint=dict(safe_result.checkpoint),
+                    cursor=dict(safe_result.cursor),
+                    error_category=None,
+                    error_code=None,
+                    expected_attempt=attempt,
+                    lease_token=lease_token,
+                )
+                if not updated.acquired and updated.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError()
+                if not updated.acquired:
+                    raise ConnectorConfigurationError(
+                        "connector run completion ownership was lost"
+                    )
+                return safe_result
+            except Exception as error:
+                classified = classify_error(error)
+                updated = self.store.update(
+                    key,
+                    status="failed",
+                    error_category=classified.category,
+                    error_code=type(classified).__name__,
+                    expected_attempt=attempt,
+                    lease_token=lease_token,
+                )
+                if not updated.acquired and updated.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError() from error
+                if not updated.acquired:
+                    raise ConnectorConfigurationError(
+                        "connector run failure ownership was lost"
+                    ) from error
+                final_attempt = attempt == first_attempt + invocation_attempts - 1
+                if not classified.retryable or final_attempt:
+                    raise classified from error
+                self.sleeper(min(0.1 * (2 ** (attempt - 1)), 1.0))
+        raise ConnectorConfigurationError("connector run did not complete")
+
+    def execute(self, connector_id, version, request):
+        connector = self.registry.resolve(connector_id, version, request.operation)
+        config = self.registry.validate(connector_id, version, request.config)
+        request = replace(request, config=config)
+        key = deterministic_idempotency_key(connector_id, version, request)
+        client_hint_hash = (
+            hashlib.sha256(str(request.idempotency_key).encode()).hexdigest()
+            if request.idempotency_key
+            else None
+        )
+        self._acquire_rate_limit(connector_id, request.source_uid)
+        record, created = self.store.claim(
+            key,
+            {
+                "idempotency_key": key,
+                "request_hash": key,
+                "client_hint_hash": client_hint_hash,
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": request.source_uid,
+                "operation": request.operation,
+                "config": dict(request.config),
+                "scope": dict(request.scope),
+                "status": "running",
+                "attempt_count": 0,
+                "checkpoint": dict(request.checkpoint),
+                "cursor": dict(request.cursor),
+                "dry_run": request.dry_run,
+                "principal_uid": request.principal_uid,
+                "business_domain_uid": request.business_domain_uid,
+                "environment": request.environment,
+                "process_key": request.process_key,
+                "source_binding_uid": request.source_binding_uid,
+                "source_binding_version": request.source_binding_version,
+                "cancel_requested": False,
+            },
+        )
+        if not created:
+            if record["status"] in {"succeeded", "dry_run"}:
+                return record.get("result") or OperationResult(
+                    cursor=record.get("cursor") or {},
+                    checkpoint=record.get("checkpoint") or {},
+                    evidence={"idempotent_replay": True},
+                    status=record["status"],
+                )
+            if record["status"] == "running":
+                raise ConnectorConfigurationError(
+                    "idempotent operation is already running"
+                )
+        if record.get("status") == "cancelled":
+            raise ConnectorCancelledError()
+        operation = getattr(connector, request.operation)
+        if request.dry_run:
+            health = connector.health(config)
+
+            def validation_only(_request):
+                return OperationResult(
+                    evidence={
+                        "validation_only": True,
+                        "health_status": health.status,
+                        "network_requested": False,
+                    },
+                )
+
+            operation = validation_only
+        return self._run_operation(key, record, request, operation)
+
+    def cancel(self, key):
+        record = self.store.get(key)
+        if not record:
+            raise ConnectorConfigurationError("connector run was not found")
+        if record["status"] in {"succeeded", "failed", "cancelled"}:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        key = record.get("idempotency_key", key)
+        connector = self.registry.resolve(
+            record["connector_id"], record["connector_version"], "cancel"
+        )
+        from app.core.connectors.sdk import OperationRequest
+
+        request = OperationRequest(
+            source_uid=record["source_uid"],
+            operation="cancel",
+            config=record.get("config") or {},
+            scope=record.get("scope") or {},
+            cursor=record.get("cursor") or {},
+            checkpoint=record.get("checkpoint") or {},
+            idempotency_key=key,
+            dry_run=bool(record.get("dry_run")),
+            principal_uid=record.get("principal_uid"),
+            business_domain_uid=record.get("business_domain_uid"),
+            environment=record.get("environment"),
+            process_key=record.get("process_key"),
+            source_binding_uid=record.get("source_binding_uid"),
+            source_binding_version=record.get("source_binding_version"),
+            run_key=key,
+        )
+        if hasattr(self.store, "cancel"):
+            cancelled = self.store.cancel(key)
+        else:
+            outcome = self.store.update(
+                key, status="cancelled", cancel_requested=True
+            )
+            if not outcome.acquired:
+                raise ConnectorConfigurationError("connector run cannot be cancelled")
+            cancelled = outcome.record
+        def cancel_probe():
+            return bool(
+                hasattr(self.store, "is_cancel_requested")
+                and self.store.is_cancel_requested(key)
+            )
+        request = replace(request, cancel_probe=cancel_probe)
+        if not request.dry_run:
+            connector.cancel(request)
+        return cancelled
+
+    def resume(self, key):
+        record = self.store.get(key)
+        if not record or record["status"] not in {"failed", "cancelled"}:
+            raise ConnectorConfigurationError("connector run cannot be resumed")
+        key = record.get("idempotency_key", key)
+        connector = self.registry.resolve(
+            record["connector_id"], record["connector_version"], "resume"
+        )
+        config = self.registry.validate(
+            record["connector_id"],
+            record["connector_version"],
+            record.get("config") or {},
+        )
+        self._acquire_rate_limit(record["connector_id"], record["source_uid"])
+        from app.core.connectors.sdk import OperationRequest
+
+        request = OperationRequest(
+            source_uid=record["source_uid"],
+            operation="resume",
+            config=config,
+            scope=record.get("scope") or {},
+            cursor=record.get("cursor") or {},
+            checkpoint=record.get("checkpoint") or {},
+            idempotency_key=key,
+            dry_run=bool(record.get("dry_run")),
+            principal_uid=record.get("principal_uid"),
+            business_domain_uid=record.get("business_domain_uid"),
+            environment=record.get("environment"),
+            process_key=record.get("process_key"),
+            source_binding_uid=record.get("source_binding_uid"),
+            source_binding_version=record.get("source_binding_version"),
+        )
+        resumable = self.store.update(
+            key,
+            status="resumable",
+            resumed_from=record.get("uid"),
+            cancel_requested=False,
+        )
+        if not resumable.acquired:
+            raise ConnectorConfigurationError("connector run cannot be resumed")
+        operation = connector.resume
+        if request.dry_run:
+            health = connector.health(config)
+
+            def validation_only(_request):
+                return OperationResult(
+                    evidence={
+                        "validation_only": True,
+                        "health_status": health.status,
+                        "network_requested": False,
+                        "resumed": True,
+                    }
+                )
+
+            operation = validation_only
+        return self._run_operation(key, resumable.record, request, operation)
+
+
+__all__ = [
+    "ConnectorRuntime",
+    "InMemoryRunStore",
+    "SlidingWindowLimiter",
+    "MAX_TOTAL_ATTEMPTS",
+    "deterministic_idempotency_key",
+    "snapshot_diff",
+    "redact_evidence",
+]

+ 233 - 0
app/core/connectors/sdk.py

@@ -0,0 +1,233 @@
+"""Versioned public connector SDK.
+
+Only symbols exported here and from ``app.core.connectors`` are stable. Connector
+packages register against the registry; the collection runtime never branches on
+connector names.
+"""
+
+from __future__ import annotations
+
+import re
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Mapping
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+from jsonschema import Draft202012Validator
+from jsonschema.exceptions import SchemaError, ValidationError
+
+from app.core.connectors.errors import ConnectorConfigurationError
+
+SDK_VERSION = "1.0"
+CAPABILITIES = frozenset(
+    {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "cancel",
+        "resume",
+        "evidence",
+    }
+)
+SECRET_KEY_PATTERN = re.compile(
+    r"(?:password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key)", re.I
+)
+SECRET_REF_PATTERN = re.compile(
+    r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$"
+)
+SEMVER_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
+
+
+@dataclass(frozen=True)
+class ConnectorManifest:
+    connector_id: str
+    version: str
+    sdk_version: str
+    display_name: str
+    capabilities: tuple[str, ...]
+    config_schema: Mapping[str, Any] = field(repr=False)
+
+    def __post_init__(self):
+        if not re.fullmatch(r"[a-z][a-z0-9_-]{2,63}", self.connector_id):
+            raise ConnectorConfigurationError("connector_id is invalid")
+        if not SEMVER_PATTERN.fullmatch(self.version):
+            raise ConnectorConfigurationError("connector version is invalid")
+        if self.sdk_version != SDK_VERSION:
+            raise ConnectorConfigurationError("SDK version is incompatible")
+        capabilities = tuple(dict.fromkeys(self.capabilities))
+        if not capabilities or set(capabilities) - CAPABILITIES:
+            raise ConnectorConfigurationError("connector capability is unsupported")
+        object.__setattr__(self, "capabilities", capabilities)
+        validate_schema(self.config_schema)
+
+    def public_dict(self):
+        return asdict(self)
+
+
+@dataclass(frozen=True)
+class OperationRequest:
+    source_uid: str
+    operation: str
+    config: Mapping[str, Any]
+    scope: Mapping[str, Any] = field(default_factory=dict)
+    cursor: Mapping[str, Any] = field(default_factory=dict)
+    checkpoint: Mapping[str, Any] = field(default_factory=dict)
+    idempotency_key: str | None = None
+    dry_run: bool = False
+    principal_uid: str | None = None
+    business_domain_uid: str | None = None
+    environment: str | None = None
+    process_key: str | None = None
+    source_binding_uid: str | None = None
+    source_binding_version: int | None = None
+    run_key: str | None = None
+    lease_token: str | None = None
+    cancel_probe: Callable[[], bool] | None = field(
+        default=None, compare=False, repr=False
+    )
+
+
+@dataclass(frozen=True)
+class OperationResult:
+    records: tuple[Mapping[str, Any], ...] = ()
+    cursor: Mapping[str, Any] = field(default_factory=dict)
+    checkpoint: Mapping[str, Any] = field(default_factory=dict)
+    evidence: Mapping[str, Any] = field(default_factory=dict)
+    status: str = "succeeded"
+
+
+@dataclass(frozen=True)
+class HealthResult:
+    status: str
+    detail: str = ""
+
+
+@dataclass(frozen=True)
+class CompatibilityResult:
+    compatible: bool
+    connector_version: str
+    sdk_version: str = SDK_VERSION
+    detail: str = ""
+
+
+def _walk_secrets(value, path="config"):
+    if isinstance(value, Mapping):
+        for key, item in value.items():
+            current = f"{path}.{key}"
+            if SECRET_KEY_PATTERN.search(str(key)) and (
+                not str(key).endswith("_ref")
+                or not isinstance(item, str)
+                or not SECRET_REF_PATTERN.fullmatch(item)
+            ):
+                raise ConnectorConfigurationError(
+                    f"{current} must be a secret reference"
+                )
+            _walk_secrets(item, current)
+    elif isinstance(value, (list, tuple)):
+        for index, item in enumerate(value):
+            _walk_secrets(item, f"{path}[{index}]")
+
+
+def validate_schema(schema):
+    if not isinstance(schema, Mapping) or schema.get("type") != "object":
+        raise ConnectorConfigurationError("config schema must describe an object")
+    if schema.get("additionalProperties") is not False:
+        raise ConnectorConfigurationError(
+            "config schema must reject unknown properties"
+        )
+    properties = schema.get("properties")
+    if not isinstance(properties, Mapping):
+        raise ConnectorConfigurationError("config schema properties are required")
+
+    def inspect_definition(definition):
+        if not isinstance(definition, Mapping):
+            return
+        for key, child in definition.get("properties", {}).items():
+            if SECRET_KEY_PATTERN.search(str(key)) and not str(key).endswith("_ref"):
+                raise ConnectorConfigurationError(
+                    "config schema cannot define plaintext secrets"
+                )
+            if (
+                str(key).endswith("_ref")
+                and child.get("pattern") != SECRET_REF_PATTERN.pattern
+            ):
+                raise ConnectorConfigurationError(
+                    "secret reference schema pattern is required"
+                )
+            inspect_definition(child)
+        inspect_definition(definition.get("items"))
+        for keyword in ("allOf", "anyOf", "oneOf"):
+            for child in definition.get(keyword, ()):
+                inspect_definition(child)
+        for child in definition.get("$defs", {}).values():
+            inspect_definition(child)
+
+    inspect_definition(schema)
+    try:
+        Draft202012Validator.check_schema(dict(schema))
+    except SchemaError as exc:
+        raise ConnectorConfigurationError("config JSON Schema is invalid") from exc
+
+
+def validate_config(schema, config):
+    """Validate recursively with JSON Schema Draft 2020-12."""
+    if not isinstance(config, Mapping):
+        raise ConnectorConfigurationError("connector config must be an object")
+    _walk_secrets(config)
+    try:
+        Draft202012Validator(dict(schema)).validate(dict(config))
+    except ValidationError as exc:
+        raise ConnectorConfigurationError("connector config shape is invalid") from exc
+    return dict(config)
+
+
+class Connector(ABC):
+    manifest: ConnectorManifest
+
+    @abstractmethod
+    def discover(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def snapshot(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def incremental(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def lineage(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def profile(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def cancel(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def resume(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def evidence(self, request: OperationRequest) -> OperationResult: ...
+
+    def health(self, config: Mapping[str, Any]) -> HealthResult:
+        validate_config(self.manifest.config_schema, config)
+        return HealthResult("available")
+
+    def compatibility(self) -> CompatibilityResult:
+        return CompatibilityResult(True, self.manifest.version)
+
+
+__all__ = [
+    "SDK_VERSION",
+    "CAPABILITIES",
+    "SECRET_REF_PATTERN",
+    "ConnectorManifest",
+    "OperationRequest",
+    "OperationResult",
+    "HealthResult",
+    "CompatibilityResult",
+    "Connector",
+    "validate_config",
+    "validate_schema",
+]

+ 39 - 0
app/core/connectors/secrets.py

@@ -0,0 +1,39 @@
+"""Deployable, fail-closed connector secret reference resolution."""
+
+from __future__ import annotations
+
+import os
+import re
+from collections.abc import Mapping
+
+from app.core.connectors.errors import ConnectorConfigurationError
+
+ENV_REFERENCE = re.compile(r"^env:(DATAOPS_CONNECTOR_[A-Z0-9_]{1,200})$")
+
+
+class EnvironmentSecretResolver:
+    """Resolve only the dedicated DATAOPS_CONNECTOR_* environment namespace."""
+
+    def __init__(self, environ: Mapping[str, str] | None = None):
+        self.environ = os.environ if environ is None else environ
+
+    def __call__(self, reference: str) -> str:
+        value = str(reference or "")
+        match = ENV_REFERENCE.fullmatch(value)
+        if match:
+            secret = self.environ.get(match.group(1))
+            if not isinstance(secret, str) or not secret.strip():
+                raise ConnectorConfigurationError(
+                    "connector environment secret is unavailable"
+                )
+            return secret
+        if value.startswith(("vault:", "secret:")):
+            raise ConnectorConfigurationError(
+                "connector secret backend is not configured"
+            )
+        raise ConnectorConfigurationError(
+            "connector environment secret reference is not allowed"
+        )
+
+
+__all__ = ["EnvironmentSecretResolver", "ENV_REFERENCE"]

+ 16 - 0
app/core/connectors/store.py

@@ -0,0 +1,16 @@
+"""Explicit persistence outcomes for connector state CAS operations."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class UpdateOutcome:
+    record: Mapping[str, Any]
+    acquired: bool
+
+
+__all__ = ["UpdateOutcome"]

+ 7 - 1
app/core/data_source/adapters/__init__.py

@@ -3,7 +3,9 @@
 from pathlib import Path
 
 from app.core.data_source.adapters.mysql import MySQLAdapter
+from app.core.data_source.adapters.oracle import OracleAdapter
 from app.core.data_source.adapters.postgresql import PostgreSQLAdapter
+from app.core.data_source.adapters.sqlserver import SqlServerAdapter
 from app.core.data_source.errors import DataSourceTypeUnsupported
 
 
@@ -16,7 +18,11 @@ def adapter_for(database_type: str, certificate_dir=None):
         return PostgreSQLAdapter(**options)
     if normalized == "mysql":
         return MySQLAdapter(**options)
+    if normalized == "oracle":
+        return OracleAdapter(**options)
+    if normalized in {"sqlserver", "mssql"}:
+        return SqlServerAdapter(**options)
     raise DataSourceTypeUnsupported()
 
 
-__all__ = ["adapter_for", "PostgreSQLAdapter", "MySQLAdapter"]
+__all__ = ["adapter_for", "PostgreSQLAdapter", "MySQLAdapter", "OracleAdapter", "SqlServerAdapter"]

+ 31 - 4
app/core/data_source/adapters/base.py

@@ -11,7 +11,6 @@ from app.core.data_source.errors import (
     DataSourceConnectionFailed,
 )
 
-
 READ_ONLY_PURPOSES = {
     "metadata_collection",
     "metadata_preview",
@@ -92,6 +91,16 @@ class BaseDataSourceAdapter:
             database=definition.database,
         )
 
+    def build_url_for_environment(
+        self,
+        definition,
+        credential,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
+        return self.build_url(definition, credential)
+
     def connect_args(self, definition, credential, query_timeout):
         raise NotImplementedError
 
@@ -114,7 +123,12 @@ class BaseDataSourceAdapter:
         query_timeout,
     ):
         engine = create_engine(
-            self.build_url(definition, credential),
+            self.build_url_for_environment(
+                definition,
+                credential,
+                trusted_environment="production",
+                allow_insecure_development=False,
+            ),
             poolclass=NullPool,
             connect_args=self.connect_args(
                 definition,
@@ -130,9 +144,22 @@ class BaseDataSourceAdapter:
         finally:
             engine.dispose()
 
-    def create_pooled_engine(self, definition, credential, settings):
+    def create_pooled_engine(
+        self,
+        definition,
+        credential,
+        settings,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
         return create_engine(
-            self.build_url(definition, credential),
+            self.build_url_for_environment(
+                definition,
+                credential,
+                trusted_environment=trusted_environment,
+                allow_insecure_development=allow_insecure_development,
+            ),
             pool_size=int(settings["pool_size"]),
             max_overflow=int(settings["max_overflow"]),
             pool_timeout=int(settings["pool_timeout"]),

+ 25 - 0
app/core/data_source/adapters/oracle.py

@@ -0,0 +1,25 @@
+"""Oracle adapter; the optional python-oracledb driver is checked lazily."""
+
+import importlib.util
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+from app.core.data_source.errors import DataSourceDriverUnavailable
+
+
+class OracleAdapter(BaseDataSourceAdapter):
+    database_type = "oracle"
+    drivername = "oracle+oracledb"
+    allowed_tls_options = frozenset()
+
+    def build_url(self, definition, credential):
+        if importlib.util.find_spec("oracledb") is None:
+            raise DataSourceDriverUnavailable()
+        return super().build_url(definition, credential)
+
+    def connect_args(self, definition, credential, query_timeout):
+        return {"tcp_connect_timeout": 5}
+
+    def configure_transaction(self, connection, purpose):
+        super().configure_transaction(connection, purpose)
+        if purpose != "dataflow_write":
+            connection.exec_driver_sql("SET TRANSACTION READ ONLY")

+ 92 - 0
app/core/data_source/adapters/sqlserver.py

@@ -0,0 +1,92 @@
+"""SQL Server adapter; the optional pyodbc driver is checked lazily."""
+
+import importlib.util
+import logging
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+from app.core.data_source.errors import DataSourceDriverUnavailable
+
+
+class SqlServerAdapter(BaseDataSourceAdapter):
+    database_type = "sqlserver"
+    drivername = "mssql+pyodbc"
+    allowed_tls_options = frozenset(
+        {
+            "Encrypt",
+            "TrustServerCertificate",
+            "ServerCertificate",
+        }
+    )
+    certificate_options = frozenset({"ServerCertificate"})
+
+    def build_url(self, definition, credential):
+        return self.build_url_for_environment(
+            definition,
+            credential,
+            trusted_environment="production",
+            allow_insecure_development=False,
+        )
+
+    def build_url_for_environment(
+        self,
+        definition,
+        credential,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
+        if importlib.util.find_spec("pyodbc") is None:
+            raise DataSourceDriverUnavailable()
+        options = self.validate_options(definition.tls_options)
+        environment = str(trusted_environment)
+        if environment not in {"development", "staging", "production"}:
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "trusted SQL Server environment is invalid"
+            )
+        allow_insecure = bool(allow_insecure_development)
+        encrypt = options.setdefault("Encrypt", "yes")
+        trust = options.setdefault("TrustServerCertificate", "no")
+        if environment in {"staging", "production"} and (
+            encrypt != "yes" or trust != "no"
+        ):
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "SQL Server staging/production requires certificate-verified TLS"
+            )
+        if (encrypt != "yes" or trust != "no") and not (
+            environment == "development" and allow_insecure
+        ):
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "SQL Server insecure TLS requires explicit development policy"
+            )
+        if allow_insecure and (encrypt != "yes" or trust != "no"):
+            logging.getLogger(__name__).warning(
+                "SQL Server approved insecure development TLS policy enabled"
+            )
+        query = {"driver": "ODBC Driver 18 for SQL Server", **options}
+        return super().build_url(definition, credential).update_query_dict(query)
+
+    def _validate_scalar_option(self, key, value):
+        normalized = str(value).strip().lower()
+        if normalized not in {"yes", "no"}:
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid("SQL Server TLS option is invalid")
+        return normalized
+
+    def connect_args(self, definition, credential, query_timeout):
+        return {"timeout": min(int(query_timeout), 30)}
+
+    def configure_transaction(self, connection, purpose):
+        from app.core.data_source.adapters.base import PURPOSES, READ_ONLY_PURPOSES
+        from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+        if purpose not in PURPOSES:
+            raise DataSourceConfigurationInvalid("unsupported data source purpose")
+        if purpose in READ_ONLY_PURPOSES:
+            connection.exec_driver_sql("SET TRANSACTION ISOLATION LEVEL SNAPSHOT")

+ 6 - 0
app/core/data_source/errors.py

@@ -40,6 +40,12 @@ class DataSourceConnectionFailed(DataSourceError):
     default_message = "data source connection failed"
 
 
+class DataSourceDriverUnavailable(DataSourceError):
+    code = "DATASOURCE_DRIVER_UNAVAILABLE"
+    http_status = 503
+    default_message = "optional data source driver is unavailable"
+
+
 class DataSourcePoolTimeout(DataSourceError):
     code = "DATASOURCE_POOL_TIMEOUT"
     http_status = 503

+ 32 - 5
app/core/data_source/manager.py

@@ -1,7 +1,8 @@
 """Public context-managed access to external business data sources."""
 
+import inspect
 import time
-from contextlib import contextmanager
+from contextlib import contextmanager, suppress
 
 from sqlalchemy.exc import DBAPIError, OperationalError, TimeoutError
 
@@ -83,11 +84,22 @@ class DataSourceConnectionManager:
         self._clock = clock or time.monotonic
 
     @contextmanager
-    def connect(self, data_source_uid, purpose):
+    def connect(
+        self,
+        data_source_uid,
+        purpose,
+        *,
+        environment="production",
+        allow_insecure_development=False,
+    ):
         if purpose not in PURPOSES or purpose == "connection_test":
             raise DataSourceConfigurationInvalid(
                 "unsupported pooled connection purpose"
             )
+        if environment not in {"development", "staging", "production"}:
+            raise DataSourceConfigurationInvalid(
+                "trusted connector environment is invalid"
+            )
         definition = self.definitions.get(data_source_uid)
         if not definition:
             raise DataSourceNotFound()
@@ -103,6 +115,22 @@ class DataSourceConnectionManager:
             credential_version=int(definition.credential_version),
             config_fingerprint=definition.connection_fingerprint(),
         )
+        create_parameters = inspect.signature(adapter.create_pooled_engine).parameters
+        supports_security_context = (
+            "trusted_environment" in create_parameters
+            or any(
+                item.kind is inspect.Parameter.VAR_KEYWORD
+                for item in create_parameters.values()
+            )
+        )
+        security_context = (
+            {
+                "trusted_environment": environment,
+                "allow_insecure_development": bool(allow_insecure_development),
+            }
+            if supports_security_context
+            else {}
+        )
 
         with self.registry.lease(
             key,
@@ -110,6 +138,7 @@ class DataSourceConnectionManager:
                 definition,
                 credential,
                 settings,
+                **security_context,
             ),
         ) as engine:
             breaker = self.registry.breaker_for(key)
@@ -148,10 +177,8 @@ class DataSourceConnectionManager:
                     else:
                         transaction.rollback()
                 except Exception:
-                    try:
+                    with suppress(Exception):
                         transaction.rollback()
-                    except Exception:
-                        pass
                     raise
 
     def invalidate(self, data_source_uid, reason):

+ 4 - 1
app/core/data_source/service.py

@@ -19,6 +19,9 @@ TYPE_ALIASES = {
     "postgres": "postgresql",
     "postgresql": "postgresql",
     "mysql": "mysql",
+    "oracle": "oracle",
+    "sqlserver": "sqlserver",
+    "mssql": "sqlserver",
 }
 POOL_INVALIDATION_REASONS = {
     "admin_reset",
@@ -53,7 +56,7 @@ class DataSourceService:
         database_type = TYPE_ALIASES.get(requested)
         if database_type is None:
             raise DataSourceConfigurationInvalid(
-                "only PostgreSQL and MySQL data sources are supported"
+                "only registered PostgreSQL, MySQL, Oracle and SQL Server data sources are supported"
             )
         return database_type
 

+ 26 - 0
app/core/system/permissions.py

@@ -73,6 +73,9 @@ SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
 IDENTITY_READ = "identity:read"
 IDENTITY_MANAGE = "identity:manage"
 IDENTITY_OPERATE = "identity:operate"
+CONNECTORS_READ = "connectors:read"
+CONNECTORS_OPERATE = "connectors:operate"
+CONNECTORS_MANAGE = "connectors:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -88,6 +91,7 @@ ROLE_PERMISSIONS = {
             AGENTS_READ,
             SECURITY_GOVERNANCE_READ,
             IDENTITY_READ,
+            CONNECTORS_READ,
         }
     ),
     "editor": frozenset(
@@ -127,6 +131,8 @@ ROLE_PERMISSIONS = {
             SECURITY_GOVERNANCE_OPERATE,
             IDENTITY_READ,
             IDENTITY_OPERATE,
+            CONNECTORS_READ,
+            CONNECTORS_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -197,6 +203,9 @@ ROLE_PERMISSIONS = {
             IDENTITY_READ,
             IDENTITY_OPERATE,
             IDENTITY_MANAGE,
+            CONNECTORS_READ,
+            CONNECTORS_OPERATE,
+            CONNECTORS_MANAGE,
         }
     ),
 }
@@ -215,9 +224,16 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         ("/api/system/identity/callback", "GET"),
         ("/api/system/identity/exchange", "POST"),
         ("/api/system/identity/refresh", "POST"),
+        ("/api/datasource/connectors/machine/runs", "POST"),
     }
     if (path, method) in public_methods:
         return (PUBLIC,)
+    if (
+        path.startswith("/api/datasource/connectors/machine/runs/")
+        and path.rsplit("/", 1)[-1] in {"cancel", "resume"}
+        and method == "POST"
+    ):
+        return (PUBLIC,)
     if path.startswith("/api/system/identity"):
         if path == "/api/system/identity/logout" and method == "POST":
             return (IDENTITY_READ,)
@@ -230,6 +246,16 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if any(marker in path for marker in ("/idp-versions", "/emergency/")):
             return (IDENTITY_MANAGE,)
         return (IDENTITY_OPERATE,)
+    if path.startswith("/api/datasource/connectors"):
+        if "/source-bindings" in path:
+            return (CONNECTORS_MANAGE,)
+        if method == "GET":
+            return (CONNECTORS_READ,)
+        if any(marker in path for marker in ("/principals", "/credentials/")):
+            return (CONNECTORS_MANAGE,)
+        return (CONNECTORS_OPERATE,)
+    if path == "/api/datasource/graph":
+        return (CONNECTORS_READ,)
     if path.startswith("/api/system/responsibilities/"):
         if method == "GET":
             return (RESPONSIBILITIES_READ,)

+ 10 - 0
app/models/__init__.py

@@ -11,6 +11,12 @@ from app.models.active_metadata import (
     ActiveMetadataPlan,
     ActiveMetadataRun,
 )
+from app.models.connectors import (
+    ConnectorManifestRecord,
+    ConnectorPrincipal,
+    ConnectorRun,
+    ConnectorSourceBinding,
+)
 from app.models.data_product import DataOrder, DataProduct
 from app.models.data_research import (
     CandidateDecisionRecord,
@@ -55,6 +61,10 @@ __all__ = [
     "ActiveMetadataCorrectionAudit",
     "DataOrder",
     "DataProduct",
+    "ConnectorManifestRecord",
+    "ConnectorPrincipal",
+    "ConnectorRun",
+    "ConnectorSourceBinding",
     "MetadataReviewRecord",
     "MetadataVersionHistory",
     "SemanticAsset",

+ 83 - 0
app/models/connectors.py

@@ -0,0 +1,83 @@
+"""Read models for the enterprise connector control plane."""
+
+from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class ConnectorManifestRecord(db.Model):
+    __tablename__ = "connector_manifests"
+    __table_args__ = (
+        db.UniqueConstraint("connector_id", "connector_version"),
+        {"schema": "public"},
+    )
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    sdk_version = db.Column(db.String(20), nullable=False)
+    display_name = db.Column(db.String(200), nullable=False)
+    capabilities = db.Column(JSONB, nullable=False)
+    config_schema = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+
+
+class ConnectorPrincipal(db.Model):
+    __tablename__ = "connector_principals"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    environment = db.Column(db.String(20), nullable=False)
+    allowed_operations = db.Column(ARRAY(db.Text), nullable=False)
+    allowed_scopes = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    source_binding_uid = db.Column(UUID(as_uuid=False))
+    source_binding_version = db.Column(db.Integer)
+
+
+class ConnectorSourceBinding(db.Model):
+    __tablename__ = "connector_source_bindings"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    binding_version = db.Column(db.Integer, primary_key=True)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    environment = db.Column(db.String(20), nullable=False)
+    approved_base_url = db.Column(db.String(2048))
+    allowed_host = db.Column(db.String(253))
+    credential_ref = db.Column(db.String(300))
+    approved_config = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    approved_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ConnectorRun(db.Model):
+    __tablename__ = "connector_runs"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    idempotency_key = db.Column(db.String(64), nullable=False, unique=True)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    principal_uid = db.Column(UUID(as_uuid=False))
+    business_domain_uid = db.Column(UUID(as_uuid=False))
+    environment = db.Column(db.String(20))
+    process_key = db.Column(db.String(300))
+    operation = db.Column(db.String(30), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    attempt_count = db.Column(db.Integer, nullable=False)
+    checkpoint = db.Column(JSONB, nullable=False)
+    cursor = db.Column(JSONB, nullable=False)
+    safe_config = db.Column(JSONB, nullable=False)
+    scope = db.Column(JSONB, nullable=False)
+    request_hash = db.Column(db.String(64), nullable=False)
+    client_hint_hash = db.Column(db.String(64))
+    source_binding_uid = db.Column(UUID(as_uuid=False))
+    source_binding_version = db.Column(db.Integer)
+    attempt_lease_token = db.Column(UUID(as_uuid=False))
+    cancel_requested = db.Column(db.Boolean, nullable=False, default=False)

+ 507 - 15
deployment/app/api/data_source/routes.py

@@ -5,6 +5,11 @@ import logging
 from flask import g, jsonify, request
 
 from app.api.data_source import bp
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorConfigurationError,
+    ConnectorError,
+)
 from app.core.data_source.errors import DataSourceError
 from app.core.data_source.redaction import (
     redact_mapping,
@@ -12,7 +17,6 @@ from app.core.data_source.redaction import (
 )
 from app.models.result import failed, success
 
-
 logger = logging.getLogger(__name__)
 
 
@@ -29,6 +33,19 @@ def _actor_uid():
 
 
 def _error_response(error):
+    if isinstance(error, ConnectorError):
+        logger.warning("连接器操作失败: category=%s", error.category)
+        return jsonify(
+            failed(
+                "连接器操作失败",
+                code=error.http_status,
+                error={
+                    "code": "CONNECTOR_ERROR",
+                    "category": error.category,
+                    "retryable": error.retryable,
+                },
+            )
+        ), error.http_status
     if isinstance(error, DataSourceError):
         logger.warning(
             "数据源操作失败: code=%s message=%s",
@@ -84,9 +101,7 @@ def data_source_list():
         service = get_data_source_service()
         definitions = service.list(payload)
         items = [service.serialize(item) for item in definitions]
-        return jsonify(
-            success({"data_source": items, "total": len(items)})
-        ), 200
+        return jsonify(success({"data_source": items, "total": len(items)})), 200
     except Exception as error:
         return _error_response(error)
 
@@ -146,8 +161,7 @@ def data_source_pool_list():
     try:
         service = get_data_source_service()
         items = [
-            service.serialize_pool_status(status)
-            for status in service.pool_statuses()
+            service.serialize_pool_status(status) for status in service.pool_statuses()
         ]
         return jsonify(success({"pools": items, "total": len(items)})), 200
     except Exception as error:
@@ -215,13 +229,491 @@ def data_source_pool_invalidate(data_source_uid):
 
 @bp.route("/graph", methods=["POST"])
 def data_source_graph_relationship():
-    return (
-        jsonify(
-            failed(
-                "该功能尚未实现",
-                code=501,
-                error={"code": "DATASOURCE_GRAPH_NOT_IMPLEMENTED"},
-            )
-        ),
-        501,
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        graph = ConnectorRepository(db.session).graph(
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            process_key=payload.get("process_key"),
+            run_uid=payload.get("run_uid"),
+            limit=payload.get("limit", 500),
+        )
+        return jsonify(success(graph)), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+def _connector_registry():
+    from flask import current_app
+
+    from app.core.connectors.builtin import register_builtin_connectors
+    from app.core.connectors.registry import ConnectorRegistry
+    from app.core.connectors.secrets import EnvironmentSecretResolver
+    from app.core.data_source.runtime import get_data_source_manager
+
+    registry = current_app.extensions.get("connector_registry")
+    if registry is not None:
+        return registry
+    registry = ConnectorRegistry()
+    register_builtin_connectors(
+        registry,
+        connection_provider=get_data_source_manager().connect,
+        secret_resolver=current_app.config.get("CONNECTOR_SECRET_RESOLVER")
+        or EnvironmentSecretResolver(),
     )
+    current_app.extensions["connector_registry"] = registry
+    return registry
+
+
+@bp.route("/connectors/manifests", methods=["GET"])
+def connector_manifests():
+    try:
+        return jsonify(success({"manifests": _connector_registry().manifests()})), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/config/validate", methods=["POST"])
+def connector_config_validate():
+    payload = request.get_json(silent=True) or {}
+    try:
+        config = _connector_registry().validate(
+            payload.get("connector_id"), payload.get("version"), payload.get("config")
+        )
+        return jsonify(success({"valid": True, "config_keys": sorted(config)})), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/<connector_id>/<version>/health", methods=["POST"])
+def connector_health(connector_id, version):
+    payload = request.get_json(silent=True) or {}
+    try:
+        connector = _connector_registry().resolve(connector_id, version)
+        return jsonify(
+            success(vars(connector.health(payload.get("config") or {})))
+        ), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/<connector_id>/<version>/compatibility", methods=["GET"])
+def connector_compatibility(connector_id, version):
+    try:
+        connector = _connector_registry().resolve(connector_id, version)
+        return jsonify(success(vars(connector.compatibility()))), 200
+    except Exception as error:
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs", methods=["GET"])
+def connector_runs_list():
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    try:
+        repository = ConnectorRepository(db.session, _actor_uid())
+        return jsonify(
+            success({"runs": repository.list(request.args.get("limit", 100))})
+        ), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+def _require_human_dry_run(payload):
+    if payload.get("dry_run") is not True:
+        raise ConnectorConfigurationError("human connector runs must use dry_run=true")
+
+
+def _require_collection_operation(payload):
+    if payload.get("operation") not in {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "evidence",
+    }:
+        raise ConnectorConfigurationError("connector collection operation is invalid")
+
+
+@bp.route("/connectors/runs", methods=["POST"])
+def connector_run_create():
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+
+    try:
+        repository = ConnectorRepository(
+            db.session, _actor_uid(), run_access="human"
+        )
+        from dataclasses import asdict
+
+        from app.core.connectors.runtime import ConnectorRuntime
+        from app.core.connectors.sdk import OperationRequest
+
+        payload = request.get_json(silent=True) or {}
+        _require_human_dry_run(payload)
+        _require_collection_operation(payload)
+        registry = _connector_registry()
+        for manifest in (
+            registry.resolve(
+                payload.get("connector_id"), payload.get("version")
+            ).manifest,
+        ):
+            repository.register_manifest(manifest, _actor_uid())
+        db.session.commit()
+        operation_request = OperationRequest(
+            source_uid=str(payload.get("source_uid") or ""),
+            operation=str(payload.get("operation") or ""),
+            config=payload.get("config") or {},
+            scope=payload.get("scope") or {},
+            cursor=payload.get("cursor") or {},
+            checkpoint=payload.get("checkpoint") or {},
+            idempotency_key=payload.get("idempotency_key"),
+            dry_run=bool(payload.get("dry_run", False)),
+            process_key=str(payload.get("process_key") or "dry-run"),
+        )
+        result = ConnectorRuntime(registry, store=repository).execute(
+            payload.get("connector_id"), payload.get("version"), operation_request
+        )
+        return jsonify(success(asdict(result), code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/machine/runs", methods=["POST"])
+def connector_machine_run():
+    """Machine-only run boundary; a human bearer token is never accepted here."""
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+    from app.core.connectors.sdk import OperationRequest
+
+    payload = request.get_json(silent=True) or {}
+    token = request.headers.get("X-Connector-Credential", "").strip()
+    if not token:
+        return _error_response(
+            ConnectorAuthenticationError("machine credential is required")
+        )
+    try:
+        for field in (
+            "connector_id",
+            "version",
+            "source_uid",
+            "business_domain_uid",
+            "environment",
+            "process_key",
+            "operation",
+        ):
+            if not str(payload.get(field) or "").strip():
+                raise ConnectorConfigurationError(
+                    "machine connector binding is incomplete"
+                )
+        _require_collection_operation(payload)
+        if "config" in payload:
+            raise ConnectorConfigurationError(
+                "machine connector config is supplied by the approved source binding"
+            )
+        identity = ConnectorIdentityRepository(db.session).authenticate(
+            token,
+            connector_id=str(payload.get("connector_id") or ""),
+            connector_version=str(payload.get("version") or ""),
+            source_uid=str(payload.get("source_uid") or ""),
+            business_domain_uid=str(payload.get("business_domain_uid") or ""),
+            environment=str(payload.get("environment") or ""),
+            operation=str(payload.get("operation") or ""),
+            scope=payload.get("scope") or {},
+        )
+        registry = _connector_registry()
+        manifest = registry.resolve(
+            payload.get("connector_id"), payload.get("version")
+        ).manifest
+        if not identity.get("source_binding_uid"):
+            raise ConnectorConfigurationError(
+                "machine connector principal has no approved source binding"
+            )
+        repository = ConnectorRepository(
+            db.session,
+            identity["principal_uid"],
+            run_access="machine",
+            principal_uid=identity["principal_uid"],
+        )
+        repository.register_manifest(manifest, identity["principal_uid"])
+        db.session.commit()
+        operation_request = OperationRequest(
+            source_uid=str(payload.get("source_uid")),
+            operation=str(payload.get("operation")),
+            config=identity["approved_config"],
+            scope=payload.get("scope") or {},
+            cursor=payload.get("cursor") or {},
+            checkpoint=payload.get("checkpoint") or {},
+            idempotency_key=payload.get("idempotency_key"),
+            dry_run=bool(payload.get("dry_run", False)),
+            principal_uid=identity["principal_uid"],
+            business_domain_uid=str(payload.get("business_domain_uid")),
+            environment=str(payload.get("environment")),
+            process_key=str(payload.get("process_key") or ""),
+            source_binding_uid=identity["source_binding_uid"],
+            source_binding_version=identity["source_binding_version"],
+        )
+        result = ConnectorRuntime(registry, store=repository).execute(
+            payload.get("connector_id"), payload.get("version"), operation_request
+        )
+        from dataclasses import asdict
+
+        return jsonify(success(asdict(result), code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs/<idempotency_key>/cancel", methods=["POST"])
+def connector_run_cancel(idempotency_key):
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    try:
+        result = ConnectorRuntime(
+            _connector_registry(),
+            store=ConnectorRepository(
+                db.session, _actor_uid(), run_access="human"
+            ),
+        ).cancel(idempotency_key)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/runs/<idempotency_key>/resume", methods=["POST"])
+def connector_run_resume(idempotency_key):
+    from app import db
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    try:
+        result = ConnectorRuntime(
+            _connector_registry(),
+            store=ConnectorRepository(
+                db.session, _actor_uid(), run_access="human"
+            ),
+        ).resume(idempotency_key)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+def _machine_run_action(idempotency_key, action):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    token = request.headers.get("X-Connector-Credential", "").strip()
+    if not token:
+        return _error_response(
+            ConnectorAuthenticationError("machine credential is required")
+        )
+    try:
+        record = ConnectorRepository(db.session).get(idempotency_key)
+        if not record or not record.get("principal_uid") or record.get("dry_run"):
+            raise ConnectorConfigurationError("machine connector run was not found")
+        identity = ConnectorIdentityRepository(db.session).authenticate(
+            token,
+            connector_id=record["connector_id"],
+            connector_version=record["connector_version"],
+            source_uid=record["source_uid"],
+            business_domain_uid=record["business_domain_uid"],
+            environment=record["environment"],
+            operation=action,
+            scope=record.get("scope") or {},
+        )
+        if (
+            identity["principal_uid"] != record["principal_uid"]
+            or identity.get("source_binding_uid") != record.get("source_binding_uid")
+            or identity.get("source_binding_version")
+            != record.get("source_binding_version")
+        ):
+            raise ConnectorAuthenticationError(
+                "machine credential run binding was rejected"
+            )
+        repository = ConnectorRepository(
+            db.session,
+            identity["principal_uid"],
+            run_access="machine",
+            principal_uid=identity["principal_uid"],
+        )
+        runtime = ConnectorRuntime(_connector_registry(), store=repository)
+        result = getattr(runtime, action)(idempotency_key)
+        if action == "resume":
+            from dataclasses import asdict
+
+            result = asdict(result)
+        return jsonify(success(result)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route(
+    "/connectors/machine/runs/<idempotency_key>/cancel", methods=["POST"]
+)
+def connector_machine_run_cancel(idempotency_key):
+    """Cancel one bound machine run with a fresh one-time credential."""
+    return _machine_run_action(idempotency_key, "cancel")
+
+
+@bp.route(
+    "/connectors/machine/runs/<idempotency_key>/resume", methods=["POST"]
+)
+def connector_machine_run_resume(idempotency_key):
+    """Resume one bound machine run with a fresh one-time credential."""
+    return _machine_run_action(idempotency_key, "resume")
+
+
+@bp.route("/connectors/source-bindings", methods=["GET"])
+def connector_source_bindings_list():
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+
+    try:
+        items = ConnectorSourceBindingRepository(db.session).list_public(
+            request.args.get("limit", 100)
+        )
+        return jsonify(success({"source_bindings": items})), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/source-bindings", methods=["POST"])
+def connector_source_binding_approve():
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+    from app.core.connectors.repository import ConnectorRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        registry = _connector_registry()
+        manifest = registry.resolve(
+            payload.get("connector_id"), payload.get("version")
+        ).manifest
+        approved_config = registry.validate(
+            payload.get("connector_id"),
+            payload.get("version"),
+            payload.get("approved_config") or {},
+        )
+        ConnectorRepository(db.session, _actor_uid()).register_manifest(
+            manifest, _actor_uid()
+        )
+        result = ConnectorSourceBindingRepository(db.session).approve(
+            connector_id=payload.get("connector_id"),
+            connector_version=payload.get("version"),
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            environment=payload.get("environment"),
+            approved_config=approved_config,
+            approved_by=_actor_uid(),
+            binding_uid=payload.get("binding_uid"),
+        )
+        return jsonify(success(result, code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/source-bindings/<binding_uid>/revoke", methods=["POST"])
+def connector_source_binding_revoke(binding_uid):
+    from app import db
+    from app.core.connectors.bindings import ConnectorSourceBindingRepository
+
+    try:
+        revoked = ConnectorSourceBindingRepository(db.session).revoke(
+            binding_uid, _actor_uid()
+        )
+        return jsonify(success(revoked)), 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/principals", methods=["POST"])
+def connector_principal_create():
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        _connector_registry().resolve(
+            payload.get("connector_id"), payload.get("version", "1.0.0")
+        )
+        uid = ConnectorIdentityRepository(db.session).create_principal(
+            connector_id=payload.get("connector_id"),
+            connector_version=payload.get("version", "1.0.0"),
+            source_uid=payload.get("source_uid"),
+            business_domain_uid=payload.get("business_domain_uid"),
+            environment=payload.get("environment"),
+            operations=payload.get("operations") or (),
+            scopes=payload.get("scopes") or {},
+            actor_uid=_actor_uid(),
+            source_binding_uid=payload.get("source_binding_uid"),
+            source_binding_version=payload.get("source_binding_version"),
+        )
+        return jsonify(success({"principal_uid": uid}, code=201)), 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/principals/<principal_uid>/credentials", methods=["POST"])
+def connector_credential_issue(principal_uid):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        result = ConnectorIdentityRepository(db.session).issue(
+            principal_uid,
+            ttl_seconds=payload.get("ttl_seconds", 900),
+            actor_uid=_actor_uid(),
+        )
+        response = jsonify(success(result, code=201))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 201
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)
+
+
+@bp.route("/connectors/credentials/<credential_uid>/<action>", methods=["POST"])
+def connector_credential_action(credential_uid, action):
+    from app import db
+    from app.core.connectors.identity import ConnectorIdentityRepository
+
+    payload = request.get_json(silent=True) or {}
+    try:
+        repository = ConnectorIdentityRepository(db.session)
+        if action == "revoke":
+            result = {"revoked": repository.revoke(credential_uid, _actor_uid())}
+        elif action == "rotate":
+            result = repository.rotate(
+                credential_uid,
+                ttl_seconds=payload.get("ttl_seconds", 900),
+                actor_uid=_actor_uid(),
+            )
+        else:
+            raise ConnectorConfigurationError("credential action is invalid")
+        response = jsonify(success(result))
+        response.headers["Cache-Control"] = "no-store"
+        return response, 200
+    except Exception as error:
+        db.session.rollback()
+        return _error_response(error)

+ 15 - 0
deployment/app/core/connectors/__init__.py

@@ -0,0 +1,15 @@
+"""Stable connector SDK public entry point."""
+
+from app.core.connectors import errors as _errors
+from app.core.connectors import sdk as _sdk
+from app.core.connectors.errors import *  # noqa: F401,F403
+from app.core.connectors.registry import ConnectorRegistry
+from app.core.connectors.sdk import *  # noqa: F401,F403
+from app.core.connectors.secrets import EnvironmentSecretResolver
+
+__all__ = [
+    "ConnectorRegistry",
+    "EnvironmentSecretResolver",
+    *_sdk.__all__,
+    *_errors.__all__,
+]

+ 224 - 0
deployment/app/core/connectors/bindings.py

@@ -0,0 +1,224 @@
+"""Server-approved connector source and destination bindings."""
+
+from __future__ import annotations
+
+import json
+from urllib.parse import urlsplit
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import ConnectorConfigurationError
+from app.core.connectors.sdk import SECRET_REF_PATTERN, _walk_secrets
+
+ENVIRONMENTS = frozenset({"development", "staging", "production"})
+
+
+def validate_approved_config(connector_id, config):
+    if not isinstance(config, dict):
+        raise ConnectorConfigurationError("approved connector config must be an object")
+    _walk_secrets(config, "approved_config")
+    if connector_id != "rest-catalog":
+        return dict(config)
+    if set(config) != {"base_url", "allowed_host", "credential_ref"}:
+        raise ConnectorConfigurationError("REST approved config is incomplete")
+    parsed = urlsplit(str(config["base_url"]))
+    host = str(config["allowed_host"]).lower().rstrip(".")
+    if (
+        parsed.scheme != "https"
+        or not parsed.hostname
+        or parsed.username
+        or parsed.password
+        or parsed.query
+        or parsed.fragment
+        or parsed.hostname.lower().rstrip(".") != host
+    ):
+        raise ConnectorConfigurationError("REST approved destination is invalid")
+    credential_ref = str(config["credential_ref"])
+    if not SECRET_REF_PATTERN.fullmatch(credential_ref):
+        raise ConnectorConfigurationError("REST approved credential reference is invalid")
+    return {
+        "base_url": str(config["base_url"]).rstrip("/"),
+        "allowed_host": host,
+        "credential_ref": credential_ref,
+    }
+
+
+class ConnectorSourceBindingRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def approve(
+        self,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        approved_config,
+        approved_by,
+        binding_uid=None,
+    ):
+        if environment not in ENVIRONMENTS:
+            raise ConnectorConfigurationError("connector binding environment is invalid")
+        config = validate_approved_config(connector_id, approved_config)
+        uid = binding_uid or new_governance_uid()
+        if binding_uid:
+            self.session.execute(
+                text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"),
+                {"lock_key": f"connector-binding:{uid}"},
+            )
+            current = self.session.execute(
+                text("""
+                SELECT COALESCE(MAX(binding_version),0)
+                  FROM public.connector_source_bindings
+                 WHERE uid=CAST(:uid AS uuid)
+                """),
+                {"uid": uid},
+            ).scalar_one()
+            self.session.execute(
+                text("""
+                UPDATE public.connector_source_bindings
+                   SET status='revoked',updated_at=CURRENT_TIMESTAMP
+                 WHERE uid=CAST(:uid AS uuid) AND status='approved'
+                """),
+                {"uid": uid},
+            )
+            version = int(current) + 1
+        else:
+            version = 1
+        rest = config if connector_id == "rest-catalog" else {}
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_source_bindings
+              (uid,binding_version,connector_id,connector_version,source_uid,
+               business_domain_uid,environment,approved_base_url,allowed_host,
+               credential_ref,approved_config,status,approved_by)
+            VALUES(CAST(:uid AS uuid),:binding_version,:connector,:version,
+                   CAST(:source AS uuid),CAST(:domain AS uuid),:environment,
+                   :base_url,:allowed_host,:credential_ref,CAST(:config AS jsonb),
+                   'approved',CAST(:approved_by AS uuid))
+            """),
+            {
+                "uid": uid,
+                "binding_version": version,
+                "connector": connector_id,
+                "version": connector_version,
+                "source": source_uid,
+                "domain": business_domain_uid,
+                "environment": environment,
+                "base_url": rest.get("base_url"),
+                "allowed_host": rest.get("allowed_host"),
+                "credential_ref": rest.get("credential_ref"),
+                "config": json.dumps(config, sort_keys=True),
+                "approved_by": approved_by,
+            },
+        )
+        rebound_principals = 0
+        if binding_uid and int(current) > 0:
+            rebound_principals = self.session.execute(
+                text("""
+                    UPDATE public.connector_principals
+                       SET source_binding_version=:binding_version
+                     WHERE source_binding_uid=CAST(:uid AS uuid)
+                       AND source_binding_version=:old_version
+                """),
+                {
+                    "uid": uid,
+                    "old_version": int(current),
+                    "binding_version": version,
+                },
+            ).rowcount
+        self.session.commit()
+        return {
+            "binding_uid": uid,
+            "binding_version": version,
+            "status": "approved",
+            "rebound_principals": rebound_principals,
+        }
+
+    def revoke(self, binding_uid, approved_by):
+        principals = self.session.execute(
+            text("""
+                UPDATE public.connector_principals
+                   SET status='revoked'
+                 WHERE source_binding_uid=CAST(:uid AS uuid) AND status='active'
+                 RETURNING uid
+            """),
+            {"uid": binding_uid},
+        ).all()
+        if principals:
+            principal_uids = [str(row[0]) for row in principals]
+            self.session.execute(
+                text("""
+                    UPDATE public.connector_machine_credentials
+                       SET status='revoked',revoked_at=CURRENT_TIMESTAMP
+                     WHERE principal_uid=ANY(CAST(:principals AS uuid[]))
+                       AND status='active'
+                """),
+                {"principals": principal_uids},
+            )
+        changed = self.session.execute(
+            text("""
+            UPDATE public.connector_source_bindings
+               SET status='revoked',approved_by=CAST(:actor AS uuid),updated_at=CURRENT_TIMESTAMP
+             WHERE uid=CAST(:uid AS uuid) AND status='approved'
+            """),
+            {"uid": binding_uid, "actor": approved_by},
+        ).rowcount
+        self.session.commit()
+        return {"revoked": bool(changed), "principals_deactivated": len(principals)}
+
+    def get_approved(self, binding_uid, binding_version=None):
+        version_clause = (
+            "AND binding_version=:binding_version" if binding_version is not None else ""
+        )
+        row = (
+            self.session.execute(
+                text(f"""
+                SELECT uid::text,binding_version,connector_id,connector_version,
+                       source_uid::text,business_domain_uid::text,environment,
+                       approved_base_url,allowed_host,credential_ref,approved_config,status
+                  FROM public.connector_source_bindings
+                 WHERE uid=CAST(:uid AS uuid) AND status='approved' {version_clause}
+                 ORDER BY binding_version DESC LIMIT 1
+                """),
+                {"uid": binding_uid, "binding_version": binding_version},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row is None:
+            raise ConnectorConfigurationError("approved connector binding was not found")
+        result = dict(row)
+        config = dict(result.pop("approved_config") or {})
+        if result["connector_id"] == "rest-catalog":
+            config = {
+                "base_url": result.pop("approved_base_url"),
+                "allowed_host": result.pop("allowed_host"),
+                "credential_ref": result.pop("credential_ref"),
+            }
+        else:
+            result.pop("approved_base_url", None)
+            result.pop("allowed_host", None)
+            result.pop("credential_ref", None)
+        result["approved_config"] = config
+        return result
+
+    def list_public(self, limit=100):
+        rows = self.session.execute(
+            text("""
+            SELECT uid::text,binding_version,connector_id,connector_version,
+                   source_uid::text,business_domain_uid::text,environment,
+                   approved_base_url,allowed_host,status,approved_by::text,
+                   created_at,updated_at
+              FROM public.connector_source_bindings
+             ORDER BY created_at DESC LIMIT :limit
+            """),
+            {"limit": min(max(int(limit), 1), 200)},
+        ).mappings()
+        return [dict(row) for row in rows]
+
+
+__all__ = ["ConnectorSourceBindingRepository", "validate_approved_config"]

+ 29 - 0
deployment/app/core/connectors/builtin/__init__.py

@@ -0,0 +1,29 @@
+"""Built-in connectors registered exclusively through the public SDK."""
+
+from app.core.connectors.builtin.oracle import OracleConnector
+from app.core.connectors.builtin.rest_catalog import (
+    RestCatalogConnector,
+    SafeRestTransport,
+)
+from app.core.connectors.builtin.sqlserver import SqlServerConnector
+
+
+def register_builtin_connectors(registry, **dependencies):
+    registry.register(OracleConnector(dependencies.get("connection_provider")))
+    registry.register(SqlServerConnector(dependencies.get("connection_provider")))
+    transport = dependencies.get("rest_transport")
+    if transport is None:
+        transport = SafeRestTransport(
+            secret_resolver=dependencies.get("secret_resolver")
+        )
+    registry.register(RestCatalogConnector(transport))
+    return registry
+
+
+__all__ = [
+    "register_builtin_connectors",
+    "OracleConnector",
+    "SqlServerConnector",
+    "RestCatalogConnector",
+    "SafeRestTransport",
+]

+ 184 - 0
deployment/app/core/connectors/builtin/database.py

@@ -0,0 +1,184 @@
+"""Common normalized behavior for read-only relational catalog connectors."""
+
+from __future__ import annotations
+
+import hashlib
+import inspect
+import json
+
+from sqlalchemy import text
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    classify_error,
+)
+from app.core.connectors.sdk import Connector, OperationResult
+
+DATABASE_CONFIG_SCHEMA = {
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["credential_ref"],
+    "properties": {
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+    },
+}
+
+
+class ReadOnlyCatalogConnector(Connector):
+    catalog_sql = ""
+
+    def __init__(self, connection_provider=None):
+        self.connection_provider = connection_provider
+
+    @staticmethod
+    def _check_cancel(request):
+        if request.cancel_probe and request.cancel_probe():
+            raise ConnectorCancelledError()
+
+    @staticmethod
+    def _scope(request):
+        scope = dict(request.scope or {})
+        allowed = {
+            "include_schemas",
+            "exclude_schemas",
+            "include_tables",
+            "exclude_tables",
+        }
+        if set(scope) - allowed:
+            raise ConnectorConfigurationError("catalog scope is invalid")
+        result = {}
+        for name in allowed:
+            value = scope.get(name, ())
+            if not isinstance(value, (list, tuple)) or any(
+                not isinstance(item, str) or not item.strip() for item in value
+            ):
+                raise ConnectorConfigurationError("catalog scope values are invalid")
+            result[name] = frozenset(item.strip() for item in value)
+        return result
+
+    def _collect(self, request):
+        if self.connection_provider is None:
+            raise ConnectorConfigurationError("connection provider is not configured")
+        self._check_cancel(request)
+        scope = self._scope(request)
+        provider_kwargs = {}
+        if self.manifest.connector_id == "sqlserver":
+            parameters = inspect.signature(self.connection_provider).parameters
+            accepts_kwargs = any(
+                item.kind is inspect.Parameter.VAR_KEYWORD
+                for item in parameters.values()
+            )
+            if accepts_kwargs or "environment" in parameters:
+                provider_kwargs = {
+                    "environment": request.environment,
+                    "allow_insecure_development": bool(
+                        request.config.get("allow_insecure_development", False)
+                    ),
+                }
+        try:
+            with self.connection_provider(
+                request.source_uid, "metadata_collection", **provider_kwargs
+            ) as connection:
+                self._check_cancel(request)
+                rows = connection.execute(text(self.catalog_sql), {}).mappings().all()
+                self._check_cancel(request)
+        except Exception as error:
+            raise classify_error(error) from error
+        records = []
+        for raw in rows:
+            self._check_cancel(request)
+            row = {str(key).lower(): value for key, value in dict(raw).items()}
+            schema = str(row["schema_name"])
+            table = str(row["asset_name"])
+            if scope["include_schemas"] and schema not in scope["include_schemas"]:
+                continue
+            if schema in scope["exclude_schemas"] or table in scope["exclude_tables"]:
+                continue
+            if scope["include_tables"] and table not in scope["include_tables"]:
+                continue
+            records.append(
+                {
+                    "asset_key": f"{request.source_uid}:{schema}.{table}",
+                    "schema": schema,
+                    "name": table,
+                    "asset_type": "view"
+                    if "VIEW" in str(row["asset_type"]).upper()
+                    else "table",
+                    "field": str(row["column_name"]),
+                    "ordinal_position": int(row["ordinal_position"]),
+                    "data_type": str(row["data_type"]),
+                    "nullable": str(row.get("is_nullable", "NO")).upper()
+                    in {"YES", "Y", "TRUE", "1"},
+                    "default": row.get("column_default"),
+                    "comment": row.get("column_comment"),
+                }
+            )
+        records.sort(
+            key=lambda item: (item["schema"], item["name"], item["ordinal_position"])
+        )
+        digest = hashlib.sha256(
+            json.dumps(records, sort_keys=True, default=str).encode()
+        ).hexdigest()
+        snapshot_summary = {"sha256": digest, "record_count": len(records)}
+        checkpoint = {"snapshot_summary": snapshot_summary}
+        evidence = {
+            "query_kind": "read_only_metadata",
+            "record_count": len(records),
+            "snapshot_hash": digest,
+        }
+        if request.operation in {"incremental", "resume"}:
+            previous = request.checkpoint.get("snapshot_summary") or {}
+            evidence["diff"] = {
+                "changed": previous.get("sha256") != digest,
+                "previous_record_count": previous.get("record_count"),
+                "current_record_count": len(records),
+                "details_materialized": False,
+            }
+        return OperationResult(
+            records=tuple(records),
+            cursor={"snapshot_hash": digest},
+            checkpoint=checkpoint,
+            evidence=evidence,
+        )
+
+    def discover(self, request):
+        return self._collect(request)
+
+    def snapshot(self, request):
+        return self._collect(request)
+
+    def incremental(self, request):
+        return self._collect(request)
+
+    def lineage(self, request):
+        return OperationResult(
+            evidence={
+                "supported": False,
+                "reason": "database catalog has no portable lineage endpoint",
+            }
+        )
+
+    def profile(self, request):
+        return OperationResult(
+            evidence={"supported": False, "reason": "profile requires explicit opt-in"}
+        )
+
+    def cancel(self, request):
+        return OperationResult(status="cancelled", checkpoint=dict(request.checkpoint))
+
+    def resume(self, request):
+        return self._collect(request)
+
+    def evidence(self, request):
+        return OperationResult(
+            evidence={
+                "connector_id": self.manifest.connector_id,
+                "version": self.manifest.version,
+                "scope": self._scope(request),
+                "secret_material": False,
+            }
+        )

+ 63 - 0
deployment/app/core/connectors/builtin/oracle.py

@@ -0,0 +1,63 @@
+"""Oracle read-only catalog connector (driver and connection are optional)."""
+
+import importlib.util
+
+from app.core.connectors.builtin.database import (
+    DATABASE_CONFIG_SCHEMA,
+    ReadOnlyCatalogConnector,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    CompatibilityResult,
+    ConnectorManifest,
+    HealthResult,
+    validate_config,
+)
+
+ORACLE_CATALOG_SQL = """
+SELECT c.owner AS schema_name, c.table_name AS asset_name,
+       CASE WHEN v.view_name IS NULL THEN 'TABLE' ELSE 'VIEW' END AS asset_type,
+       c.column_name, c.column_id AS ordinal_position, c.data_type,
+       CASE WHEN c.nullable = 'Y' THEN 'YES' ELSE 'NO' END AS is_nullable,
+       c.data_default AS column_default, cc.comments AS column_comment
+FROM all_tab_columns c
+LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name
+LEFT JOIN all_col_comments cc ON cc.owner = c.owner AND cc.table_name = c.table_name AND cc.column_name = c.column_name
+WHERE c.owner NOT IN ('SYS', 'SYSTEM', 'OUTLN', 'DBSNMP')
+ORDER BY c.owner, c.table_name, c.column_id
+"""
+
+
+class OracleConnector(ReadOnlyCatalogConnector):
+    catalog_sql = ORACLE_CATALOG_SQL
+    manifest = ConnectorManifest(
+        connector_id="oracle",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Oracle Database",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=DATABASE_CONFIG_SCHEMA,
+    )
+
+    def health(self, config):
+        validate_config(self.manifest.config_schema, config)
+        available = importlib.util.find_spec("oracledb") is not None
+        return HealthResult(
+            "available" if available else "degraded",
+            "" if available else "optional driver unavailable",
+        )
+
+    def compatibility(self):
+        available = importlib.util.find_spec("oracledb") is not None
+        return CompatibilityResult(
+            available,
+            self.manifest.version,
+            detail="" if available else "optional driver unavailable",
+        )

+ 381 - 0
deployment/app/core/connectors/builtin/rest_catalog.py

@@ -0,0 +1,381 @@
+"""Controlled REST catalog with DNS-pinned, bounded TLS transport."""
+
+from __future__ import annotations
+
+import hashlib
+import ipaddress
+import json
+import socket
+from collections.abc import Callable, Mapping
+from urllib.parse import urlencode, urlsplit
+
+import requests
+import urllib3
+from jsonschema import Draft202012Validator
+from jsonschema.exceptions import ValidationError
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    ConnectorContractError,
+    ConnectorUpstreamError,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    Connector,
+    ConnectorManifest,
+    OperationResult,
+)
+
+MAX_RESPONSE_BYTES = 2 * 1024 * 1024
+MAX_PAGES = 100
+MAX_RECORDS = 10000
+REST_CONFIG_SCHEMA = {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["base_url", "allowed_host", "credential_ref"],
+    "properties": {
+        "base_url": {
+            "type": "string",
+            "pattern": r"^https://[A-Za-z0-9.-]+(?::443)?(?:/[^?#\s]*)?$",
+            "maxLength": 2048,
+        },
+        "allowed_host": {
+            "type": "string",
+            "pattern": r"^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?$",
+            "maxLength": 253,
+        },
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+    },
+}
+CATALOG_RESPONSE_SCHEMA = {
+    "$schema": "https://json-schema.org/draft/2020-12/schema",
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["assets"],
+    "properties": {
+        "assets": {
+            "type": "array",
+            "maxItems": 10000,
+            "items": {
+                "type": "object",
+                "additionalProperties": False,
+                "required": ["key", "name", "namespace", "type"],
+                "properties": {
+                    name: {"type": "string", "minLength": 1, "maxLength": 500}
+                    for name in ("key", "name", "namespace", "type")
+                },
+            },
+        },
+        "next_cursor": {
+            "type": "object",
+            "additionalProperties": {
+                "type": ["string", "number", "integer", "boolean", "null"]
+            },
+            "maxProperties": 20,
+        },
+        "evidence": {"type": "object"},
+        "completion_token": {"type": "string", "minLength": 1, "maxLength": 500},
+    },
+}
+
+
+class SafeRestTransport:
+    """Resolve once, reject non-global answers, then connect only to pinned IPs."""
+
+    def __init__(
+        self,
+        *,
+        secret_resolver: Callable[[str], str] | None = None,
+        resolver: Callable[..., object] = socket.getaddrinfo,
+        pool_factory: Callable[..., object] = urllib3.HTTPSConnectionPool,
+        connect_timeout: float = 3.0,
+        read_timeout: float = 10.0,
+        max_response_bytes: int = MAX_RESPONSE_BYTES,
+    ):
+        self.secret_resolver = secret_resolver
+        self.resolver = resolver
+        self.pool_factory = pool_factory
+        self.connect_timeout = float(connect_timeout)
+        self.read_timeout = float(read_timeout)
+        self.max_response_bytes = min(int(max_response_bytes), MAX_RESPONSE_BYTES)
+
+    def _destination(self, url, allowed_host):
+        parsed = urlsplit(url)
+        host = (parsed.hostname or "").lower().rstrip(".")
+        expected = str(allowed_host).lower().rstrip(".")
+        if (
+            parsed.scheme != "https"
+            or parsed.username
+            or parsed.password
+            or parsed.fragment
+            or host != expected
+        ):
+            raise ConnectorConfigurationError("catalog destination is not allowed")
+        if parsed.port not in {None, 443}:
+            raise ConnectorConfigurationError("catalog HTTPS port is not allowed")
+        try:
+            raw = self.resolver(host, 443, type=socket.SOCK_STREAM)
+            addresses = sorted({item[4][0] for item in raw})
+            parsed_addresses = [ipaddress.ip_address(item) for item in addresses]
+        except (OSError, ValueError) as exc:
+            raise ConnectorConfigurationError("catalog host resolution failed") from exc
+        if not parsed_addresses or any(not item.is_global for item in parsed_addresses):
+            raise ConnectorConfigurationError(
+                "catalog host must resolve only to public addresses"
+            )
+        return parsed, tuple(str(item) for item in parsed_addresses)
+
+    def _decode(self, response, cancel_probe=None):
+        status = int(response.status)
+        if 300 <= status < 400:
+            raise ConnectorUpstreamError("catalog redirect was rejected")
+        if status != 200:
+            raise ConnectorUpstreamError("catalog upstream request failed")
+        content_type = str(response.headers.get("Content-Type", "")).lower()
+        if "application/json" not in content_type:
+            raise ConnectorContractError("catalog response must be JSON")
+        length = response.headers.get("Content-Length")
+        if length is not None:
+            try:
+                if int(length) > self.max_response_bytes or int(length) < 0:
+                    raise ConnectorContractError("catalog response exceeds size limit")
+            except ValueError as exc:
+                raise ConnectorContractError(
+                    "catalog content length is invalid"
+                ) from exc
+        body = bytearray()
+        try:
+            for chunk in response.stream(amt=64 * 1024, decode_content=True):
+                if cancel_probe and cancel_probe():
+                    raise ConnectorCancelledError()
+                body.extend(chunk)
+                if len(body) > self.max_response_bytes:
+                    raise ConnectorContractError("catalog response exceeds size limit")
+        except (urllib3.exceptions.HTTPError, OSError) as exc:
+            raise ConnectorUpstreamError("catalog response read failed") from exc
+        try:
+            payload = json.loads(body.decode())
+        except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+            raise ConnectorContractError("catalog response is not valid JSON") from exc
+        if not isinstance(payload, Mapping):
+            raise ConnectorContractError("catalog response must be a JSON object")
+        try:
+            Draft202012Validator(CATALOG_RESPONSE_SCHEMA).validate(payload)
+        except ValidationError as exc:
+            raise ConnectorContractError("catalog response shape is invalid") from exc
+        return dict(payload)
+
+    def get_json(self, *, url, allowed_host, credential_ref, cancel_probe=None):
+        if self.secret_resolver is None:
+            raise ConnectorConfigurationError("REST secret resolver is not configured")
+        authorization = self.secret_resolver(credential_ref)
+        if not isinstance(authorization, str) or not authorization.strip():
+            raise ConnectorConfigurationError(
+                "REST authorization secret is unavailable"
+            )
+        parsed, addresses = self._destination(url, allowed_host)
+        hostname = parsed.hostname.lower()
+        target = parsed.path or "/"
+        if parsed.query:
+            target += f"?{parsed.query}"
+        last_error = None
+        for address in addresses:
+            if cancel_probe and cancel_probe():
+                raise ConnectorCancelledError()
+            pool = self.pool_factory(
+                host=address,
+                port=443,
+                server_hostname=hostname,
+                assert_hostname=hostname,
+                cert_reqs="CERT_REQUIRED",
+                ca_certs=requests.certs.where(),
+                timeout=urllib3.Timeout(
+                    connect=self.connect_timeout, read=self.read_timeout
+                ),
+                retries=False,
+                maxsize=1,
+                block=True,
+            )
+            try:
+                response = pool.urlopen(
+                    "GET",
+                    target,
+                    headers={
+                        "Host": hostname,
+                        "Accept": "application/json",
+                        "Authorization": f"Bearer {authorization.strip()}",
+                    },
+                    redirect=False,
+                    retries=False,
+                    assert_same_host=False,
+                    preload_content=False,
+                    decode_content=True,
+                    timeout=urllib3.Timeout(
+                        connect=self.connect_timeout, read=self.read_timeout
+                    ),
+                )
+            except (urllib3.exceptions.HTTPError, OSError) as exc:
+                last_error = exc
+                pool.close()
+                continue
+            try:
+                return self._decode(response, cancel_probe=cancel_probe)
+            finally:
+                response.release_conn()
+                pool.close()
+        raise ConnectorUpstreamError("catalog connection failed") from last_error
+
+
+class RestCatalogConnector(Connector):
+    manifest = ConnectorManifest(
+        connector_id="rest-catalog",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Controlled REST Catalog",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=REST_CONFIG_SCHEMA,
+    )
+
+    def __init__(self, transport=None):
+        self.transport = transport or SafeRestTransport()
+
+    @staticmethod
+    def _check_cancel(request):
+        if request.cancel_probe and request.cancel_probe():
+            raise ConnectorCancelledError()
+
+    def _read(self, request):
+        self._check_cancel(request)
+        cursor = request.cursor or request.checkpoint.get("cursor") or {}
+        if cursor.get("state") == "complete":
+            cursor = {}
+        elif "state" in cursor:
+            raise ConnectorContractError("catalog cursor state is invalid")
+        seen_cursors = set()
+        records = []
+        page_count = 0
+        while True:
+            self._check_cancel(request)
+            marker = json.dumps(cursor, sort_keys=True, separators=(",", ":"))
+            if marker in seen_cursors:
+                raise ConnectorContractError("catalog cursor repeated")
+            seen_cursors.add(marker)
+            query = f"?{urlencode({'cursor': marker})}" if cursor else ""
+            payload = self.transport.get_json(
+                url=request.config["base_url"].rstrip("/") + "/v1/catalog" + query,
+                allowed_host=request.config["allowed_host"],
+                credential_ref=request.config["credential_ref"],
+                cancel_probe=request.cancel_probe,
+            )
+            page_count += 1
+            records.extend(
+                {key: asset[key] for key in sorted(asset)}
+                for asset in payload["assets"]
+            )
+            if len(records) > MAX_RECORDS:
+                raise ConnectorContractError("catalog record limit exceeded")
+            next_cursor = payload.get("next_cursor") or {}
+            if not next_cursor:
+                break
+            if page_count >= MAX_PAGES:
+                raise ConnectorContractError("catalog page limit exceeded")
+            for name, value in next_cursor.items():
+                previous = cursor.get(name)
+                if (
+                    isinstance(previous, (int, float))
+                    and isinstance(value, (int, float))
+                    and value <= previous
+                ):
+                    raise ConnectorContractError("catalog cursor is not monotonic")
+            cursor = next_cursor
+        records = tuple(records)
+        snapshot_hash = hashlib.sha256(
+            json.dumps(records, sort_keys=True, separators=(",", ":")).encode()
+        ).hexdigest()
+        completed_cursor = {
+            "state": "complete",
+            "completion_marker": payload.get("completion_token") or snapshot_hash,
+        }
+        snapshot_summary = {
+            "sha256": snapshot_hash,
+            "record_count": len(records),
+        }
+        checkpoint = {
+            "cursor": completed_cursor,
+            "snapshot_summary": snapshot_summary,
+        }
+        evidence = {
+            "transport": "https-pinned-ip",
+            "redirects": "disabled",
+            "record_count": len(records),
+            "page_count": page_count,
+        }
+        if request.operation in {"incremental", "resume"}:
+            previous = request.checkpoint.get("snapshot_summary") or {}
+            evidence["diff"] = {
+                "changed": previous.get("sha256") != snapshot_hash,
+                "previous_record_count": previous.get("record_count"),
+                "current_record_count": len(records),
+                "details_materialized": False,
+            }
+        return OperationResult(
+            records=records,
+            cursor=completed_cursor,
+            checkpoint=checkpoint,
+            evidence=evidence,
+        )
+
+    def discover(self, request):
+        return self._read(request)
+
+    def snapshot(self, request):
+        return self._read(request)
+
+    def incremental(self, request):
+        return self._read(request)
+
+    def lineage(self, request):
+        return self._read(request)
+
+    def profile(self, request):
+        return self._read(request)
+
+    def cancel(self, request):
+        return OperationResult(
+            status="cancelled", checkpoint=request.checkpoint, cursor=request.cursor
+        )
+
+    def resume(self, request):
+        return self._read(request)
+
+    def evidence(self, request):
+        return OperationResult(
+            evidence={
+                "transport": "https-pinned-ip",
+                "redirects": "disabled",
+                "max_response_bytes": MAX_RESPONSE_BYTES,
+            }
+        )
+
+
+__all__ = [
+    "RestCatalogConnector",
+    "SafeRestTransport",
+    "REST_CONFIG_SCHEMA",
+    "CATALOG_RESPONSE_SCHEMA",
+    "MAX_RESPONSE_BYTES",
+    "MAX_PAGES",
+    "MAX_RECORDS",
+]

+ 78 - 0
deployment/app/core/connectors/builtin/sqlserver.py

@@ -0,0 +1,78 @@
+"""SQL Server read-only catalog connector (driver and connection are optional)."""
+
+import importlib.util
+
+from app.core.connectors.builtin.database import (
+    ReadOnlyCatalogConnector,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    CompatibilityResult,
+    ConnectorManifest,
+    HealthResult,
+    validate_config,
+)
+
+SQLSERVER_CATALOG_SQL = """
+SELECT s.name AS schema_name, o.name AS asset_name,
+       CASE WHEN o.type = 'V' THEN 'VIEW' ELSE 'TABLE' END AS asset_type,
+       c.name AS column_name, c.column_id AS ordinal_position, t.name AS data_type,
+       CASE WHEN c.is_nullable = 1 THEN 'YES' ELSE 'NO' END AS is_nullable,
+       OBJECT_DEFINITION(c.default_object_id) AS column_default,
+       CAST(ep.value AS nvarchar(4000)) AS column_comment
+FROM sys.objects o
+JOIN sys.schemas s ON s.schema_id = o.schema_id
+JOIN sys.columns c ON c.object_id = o.object_id
+JOIN sys.types t ON t.user_type_id = c.user_type_id
+LEFT JOIN sys.extended_properties ep ON ep.major_id = o.object_id AND ep.minor_id = c.column_id AND ep.name = 'MS_Description'
+WHERE o.type IN ('U', 'V') AND o.is_ms_shipped = 0
+ORDER BY s.name, o.name, c.column_id
+"""
+
+SQLSERVER_CONFIG_SCHEMA = {
+    "type": "object",
+    "additionalProperties": False,
+    "required": ["credential_ref"],
+    "properties": {
+        "credential_ref": {
+            "type": "string",
+            "pattern": r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$",
+        },
+        "allow_insecure_development": {"type": "boolean"},
+    },
+}
+
+
+class SqlServerConnector(ReadOnlyCatalogConnector):
+    catalog_sql = SQLSERVER_CATALOG_SQL
+    manifest = ConnectorManifest(
+        connector_id="sqlserver",
+        version="1.0.0",
+        sdk_version=SDK_VERSION,
+        display_name="Microsoft SQL Server",
+        capabilities=(
+            "discover",
+            "snapshot",
+            "incremental",
+            "cancel",
+            "resume",
+            "evidence",
+        ),
+        config_schema=SQLSERVER_CONFIG_SCHEMA,
+    )
+
+    def health(self, config):
+        validate_config(self.manifest.config_schema, config)
+        available = importlib.util.find_spec("pyodbc") is not None
+        return HealthResult(
+            "available" if available else "degraded",
+            "" if available else "optional driver unavailable",
+        )
+
+    def compatibility(self):
+        available = importlib.util.find_spec("pyodbc") is not None
+        return CompatibilityResult(
+            available,
+            self.manifest.version,
+            detail="" if available else "optional driver unavailable",
+        )

+ 84 - 0
deployment/app/core/connectors/errors.py

@@ -0,0 +1,84 @@
+"""Stable, secret-free connector failure taxonomy."""
+
+from __future__ import annotations
+
+
+class ConnectorError(RuntimeError):
+    category = "upstream"
+    retryable = False
+    http_status = 502
+
+    def __init__(self, message="connector operation failed"):
+        super().__init__(message)
+
+
+class ConnectorConfigurationError(ConnectorError):
+    category = "configuration"
+    http_status = 400
+
+
+class ConnectorConflictError(ConnectorError):
+    category = "conflict"
+    http_status = 409
+
+
+class ConnectorAuthenticationError(ConnectorError):
+    category = "authentication"
+    http_status = 401
+
+
+class ConnectorPermissionError(ConnectorError):
+    category = "permission"
+    http_status = 403
+
+
+class ConnectorRateLimitError(ConnectorError):
+    category = "rate_limit"
+    retryable = True
+    http_status = 429
+
+
+class ConnectorTimeoutError(ConnectorError):
+    category = "timeout"
+    retryable = True
+    http_status = 504
+
+
+class ConnectorUpstreamError(ConnectorError):
+    category = "upstream"
+    retryable = True
+
+
+class ConnectorContractError(ConnectorError):
+    category = "contract"
+
+
+class ConnectorCancelledError(ConnectorError):
+    category = "cancelled"
+    http_status = 409
+
+
+class ConnectorDriverUnavailableError(ConnectorError):
+    category = "configuration"
+    http_status = 503
+
+
+def classify_error(error):
+    """Map driver/transport failures without exposing their text."""
+    if isinstance(error, ConnectorError):
+        return error
+    name = type(error).__name__.lower()
+    if "driver" in name and "unavailable" in name:
+        return ConnectorDriverUnavailableError()
+    if "auth" in name or "credential" in name:
+        return ConnectorAuthenticationError()
+    if "permission" in name or "access" in name:
+        return ConnectorPermissionError()
+    if "timeout" in name:
+        return ConnectorTimeoutError()
+    if "rate" in name or "thrott" in name:
+        return ConnectorRateLimitError()
+    return ConnectorUpstreamError()
+
+
+__all__ = [name for name in globals() if name.startswith("Connector")]

+ 379 - 0
deployment/app/core/connectors/identity.py

@@ -0,0 +1,379 @@
+"""Short-lived, one-time-issued connector machine credentials."""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+from collections.abc import Mapping
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorConfigurationError,
+    ConnectorPermissionError,
+)
+
+ALLOWED_OPERATIONS = frozenset(
+    {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "cancel",
+        "resume",
+        "evidence",
+    }
+)
+ALLOWED_SCOPE_KEYS = frozenset(
+    {"include_schemas", "exclude_schemas", "include_tables", "exclude_tables"}
+)
+
+
+def validate_machine_scope(scope):
+    if not isinstance(scope, Mapping) or set(scope) - ALLOWED_SCOPE_KEYS:
+        raise ConnectorConfigurationError("machine identity scope is invalid")
+    normalized = {}
+    for key, values in scope.items():
+        if not isinstance(values, list) or any(
+            not isinstance(value, str) or not value.strip() for value in values
+        ):
+            raise ConnectorConfigurationError(
+                "machine identity scope values are invalid"
+            )
+        normalized[key] = tuple(value.strip() for value in values)
+    return normalized
+
+
+def _token_hash(token):
+    return hashlib.sha256(str(token).encode()).hexdigest()
+
+
+class ConnectorIdentityRepository:
+    def __init__(self, session):
+        self.session = session
+
+    def create_principal(
+        self,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        operations,
+        scopes,
+        actor_uid,
+        source_binding_uid=None,
+        source_binding_version=None,
+    ):
+        operations = tuple(sorted(set(operations)))
+        if not operations or set(operations) - ALLOWED_OPERATIONS:
+            raise ConnectorConfigurationError("machine identity operations are invalid")
+        scopes = validate_machine_scope(scopes)
+        if environment not in {"development", "staging", "production"}:
+            raise ConnectorConfigurationError("machine identity environment is invalid")
+        if not source_binding_uid or source_binding_version is None:
+            raise ConnectorConfigurationError(
+                "enterprise connector principal requires an approved source binding"
+            )
+        uid = new_governance_uid()
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_principals
+              (uid, connector_id, connector_version, source_uid, business_domain_uid, environment,
+               allowed_operations, allowed_scopes, status, created_by,
+               source_binding_uid,source_binding_version)
+            VALUES (CAST(:uid AS uuid), :connector, :version, CAST(:source AS uuid), CAST(:domain AS uuid), :environment,
+                    CAST(:operations AS text[]), CAST(:scopes AS jsonb), 'active', CAST(:actor AS uuid),
+                    CAST(:binding AS uuid),:binding_version)
+        """),
+            {
+                "uid": uid,
+                "connector": connector_id,
+                "version": connector_version,
+                "source": source_uid,
+                "domain": business_domain_uid,
+                "environment": environment,
+                "operations": list(operations),
+                "scopes": __import__("json").dumps(scopes),
+                "actor": actor_uid,
+                "binding": source_binding_uid,
+                "binding_version": source_binding_version,
+            },
+        )
+        self._audit(uid, None, "principal_created", actor_uid, True)
+        self.session.commit()
+        return uid
+
+    def issue(self, principal_uid, *, ttl_seconds, actor_uid, rotated_from=None):
+        ttl = int(ttl_seconds)
+        if ttl < 60 or ttl > 900:
+            raise ConnectorConfigurationError(
+                "credential TTL must be between 60 and 900 seconds"
+            )
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"connector-principal:{principal_uid}"},
+        )
+        principal = self.session.execute(
+            text("""
+                SELECT p.status
+                  FROM public.connector_principals p
+                  JOIN public.connector_source_bindings b
+                    ON b.uid=p.source_binding_uid
+                   AND b.binding_version=p.source_binding_version
+                   AND b.status='approved'
+                   AND b.connector_id=p.connector_id
+                   AND b.connector_version=p.connector_version
+                   AND b.source_uid=p.source_uid
+                   AND b.business_domain_uid=p.business_domain_uid
+                   AND b.environment=p.environment
+                 WHERE p.uid=CAST(:uid AS uuid)
+                 FOR UPDATE OF p
+            """),
+            {"uid": principal_uid},
+        ).scalar_one_or_none()
+        if principal != "active":
+            raise ConnectorAuthenticationError(
+                "connector principal or approved binding is not active"
+            )
+        token = "dopc_" + secrets.token_urlsafe(32)
+        credential_uid = new_governance_uid()
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_machine_credentials
+              (uid, principal_uid, token_hash, status, expires_at, rotated_from_uid, issued_by)
+            VALUES (CAST(:uid AS uuid), CAST(:principal AS uuid), :hash, 'active',
+                    CURRENT_TIMESTAMP + (:ttl * INTERVAL '1 second'), CAST(:rotated AS uuid), CAST(:actor AS uuid))
+        """),
+            {
+                "uid": credential_uid,
+                "principal": principal_uid,
+                "hash": _token_hash(token),
+                "ttl": ttl,
+                "rotated": rotated_from,
+                "actor": actor_uid,
+            },
+        )
+        self._audit(principal_uid, credential_uid, "credential_issued", actor_uid, True)
+        self.session.commit()
+        return {
+            "credential_uid": credential_uid,
+            "credential": token,
+            "expires_in": ttl,
+            "returned_once": True,
+        }
+
+    def authenticate(
+        self,
+        token,
+        *,
+        connector_id,
+        connector_version,
+        source_uid,
+        business_domain_uid,
+        environment,
+        operation,
+        scope,
+    ):
+        scope = validate_machine_scope(scope)
+        digest = _token_hash(token)
+        expired = (
+            self.session.execute(
+                text("""
+            SELECT c.uid::text, c.principal_uid::text
+            FROM public.connector_machine_credentials c
+            WHERE c.token_hash=:hash AND c.status='active' AND c.expires_at<=CURRENT_TIMESTAMP
+            FOR UPDATE
+        """),
+                {"hash": digest},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if expired is not None:
+            self.session.execute(
+                text(
+                    "UPDATE public.connector_machine_credentials SET status='expired',revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": expired["uid"]},
+            )
+            self._audit(
+                expired["principal_uid"],
+                expired["uid"],
+                "credential_expired_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorAuthenticationError(
+                "machine credential is invalid or expired"
+            )
+        row = (
+            self.session.execute(
+                text("""
+            SELECT c.uid::text, c.principal_uid::text, c.use_count, p.connector_id,
+                   p.connector_version, p.source_uid::text, p.business_domain_uid::text,
+                   p.environment, p.allowed_operations, p.allowed_scopes,
+                   p.source_binding_uid::text,p.source_binding_version,
+                   b.approved_base_url,b.allowed_host,b.credential_ref,b.approved_config
+              FROM public.connector_machine_credentials c
+              JOIN public.connector_principals p ON c.principal_uid=p.uid
+              JOIN public.connector_source_bindings b
+                ON b.uid=p.source_binding_uid
+               AND b.binding_version=p.source_binding_version
+               AND b.status='approved'
+               AND b.connector_id=p.connector_id
+               AND b.connector_version=p.connector_version
+               AND b.source_uid=p.source_uid
+               AND b.business_domain_uid=p.business_domain_uid
+               AND b.environment=p.environment
+             WHERE c.token_hash=:hash AND c.status='active'
+               AND c.expires_at>CURRENT_TIMESTAMP AND p.status='active'
+             FOR UPDATE OF c
+        """),
+                {"hash": digest},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if row is None:
+            self.session.rollback()
+            raise ConnectorAuthenticationError(
+                "machine credential is invalid or expired"
+            )
+        if int(row["use_count"]) > 0:
+            self.session.execute(
+                text(
+                    "UPDATE public.connector_machine_credentials SET status='replayed', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": row["uid"]},
+            )
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_replay_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorAuthenticationError("machine credential replay was rejected")
+        if (
+            not hmac.compare_digest(row["connector_id"], connector_id)
+            or not hmac.compare_digest(row["connector_version"], connector_version)
+            or row["source_uid"] != source_uid
+            or row["business_domain_uid"] != business_domain_uid
+            or row["environment"] != environment
+            or operation not in row["allowed_operations"]
+        ):
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_scope_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorPermissionError("machine credential scope was rejected")
+        allowed_scope = validate_machine_scope(row["allowed_scopes"] or {})
+        if any(
+            set(scope.get(key, ())) - set(allowed_scope.get(key, ())) for key in scope
+        ):
+            self._audit(
+                row["principal_uid"],
+                row["uid"],
+                "credential_scope_rejected",
+                None,
+                False,
+            )
+            self.session.commit()
+            raise ConnectorPermissionError(
+                "machine credential resource scope was rejected"
+            )
+        identity = dict(row)
+        config = dict(identity.pop("approved_config", {}) or {})
+        if identity["connector_id"] == "rest-catalog":
+            if not identity.get("source_binding_uid"):
+                raise ConnectorPermissionError(
+                    "machine credential has no approved source binding"
+                )
+            config = {
+                "base_url": identity.pop("approved_base_url"),
+                "allowed_host": identity.pop("allowed_host"),
+                "credential_ref": identity.pop("credential_ref"),
+            }
+        else:
+            identity.pop("approved_base_url", None)
+            identity.pop("allowed_host", None)
+            identity.pop("credential_ref", None)
+        identity["approved_config"] = config
+        self.session.execute(
+            text("""
+                UPDATE public.connector_machine_credentials
+                   SET first_used_at=CURRENT_TIMESTAMP,use_count=use_count+1
+                 WHERE uid=CAST(:uid AS uuid) AND use_count=0 AND status='active'
+            """),
+            {"uid": row["uid"]},
+        )
+        self._audit(
+            row["principal_uid"], row["uid"], "credential_authenticated", None, True
+        )
+        self.session.commit()
+        return identity
+
+    def revoke(self, credential_uid, actor_uid):
+        principal_uid = self.session.execute(
+            text(
+                "UPDATE public.connector_machine_credentials SET status='revoked', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid) AND status='active' RETURNING principal_uid::text"
+            ),
+            {"uid": credential_uid},
+        ).scalar_one_or_none()
+        if principal_uid:
+            self._audit(
+                principal_uid, credential_uid, "credential_revoked", actor_uid, True
+            )
+        self.session.commit()
+        return principal_uid is not None
+
+    def rotate(self, credential_uid, *, ttl_seconds, actor_uid):
+        row = self.session.execute(
+            text(
+                "UPDATE public.connector_machine_credentials SET status='rotated', revoked_at=CURRENT_TIMESTAMP WHERE uid=CAST(:uid AS uuid) AND status='active' RETURNING principal_uid::text"
+            ),
+            {"uid": credential_uid},
+        ).scalar_one_or_none()
+        if row is None:
+            raise ConnectorAuthenticationError("credential cannot be rotated")
+        self._audit(row, credential_uid, "credential_rotated", actor_uid, True)
+        return self.issue(
+            row,
+            ttl_seconds=ttl_seconds,
+            actor_uid=actor_uid,
+            rotated_from=credential_uid,
+        )
+
+    def _audit(self, principal_uid, credential_uid, event_type, actor_uid, success):
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_audit_events
+              (uid, principal_uid, credential_uid, event_type, actor_uid, success, safe_detail)
+            VALUES (CAST(:uid AS uuid), CAST(:principal AS uuid), CAST(:credential AS uuid), :event,
+                    CAST(:actor AS uuid), :success, :detail)
+        """),
+            {
+                "uid": new_governance_uid(),
+                "principal": principal_uid,
+                "credential": credential_uid,
+                "event": event_type,
+                "actor": actor_uid,
+                "success": success,
+                "detail": event_type.replace("_", " "),
+            },
+        )
+
+
+__all__ = ["ConnectorIdentityRepository", "ALLOWED_OPERATIONS"]

+ 52 - 0
deployment/app/core/connectors/registry.py

@@ -0,0 +1,52 @@
+"""Thread-safe, deny-by-default connector registry."""
+
+from __future__ import annotations
+
+import threading
+
+from app.core.connectors.errors import ConnectorConfigurationError
+from app.core.connectors.sdk import Connector, validate_config
+
+
+class ConnectorRegistry:
+    def __init__(self):
+        self._lock = threading.RLock()
+        self._connectors = {}
+
+    def register(self, connector: Connector):
+        manifest = connector.manifest
+        key = (manifest.connector_id, manifest.version)
+        with self._lock:
+            if key in self._connectors:
+                raise ConnectorConfigurationError(
+                    "connector version is already registered"
+                )
+            self._connectors[key] = connector
+        return connector
+
+    def resolve(self, connector_id, version, capability=None):
+        with self._lock:
+            connector = self._connectors.get((str(connector_id), str(version)))
+        if connector is None:
+            raise ConnectorConfigurationError("connector or version is not registered")
+        if capability and capability not in connector.manifest.capabilities:
+            raise ConnectorConfigurationError("connector capability is not declared")
+        return connector
+
+    def validate(self, connector_id, version, config):
+        connector = self.resolve(connector_id, version)
+        return validate_config(connector.manifest.config_schema, config)
+
+    def manifests(self):
+        with self._lock:
+            items = tuple(self._connectors.values())
+        return [
+            item.manifest.public_dict()
+            for item in sorted(
+                items,
+                key=lambda item: (item.manifest.connector_id, item.manifest.version),
+            )
+        ]
+
+
+__all__ = ["ConnectorRegistry"]

+ 513 - 0
deployment/app/core/connectors/repository.py

@@ -0,0 +1,513 @@
+"""PostgreSQL persistence for connector registrations, runs and graph edges."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+
+from sqlalchemy import text
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.connectors.errors import (
+    ConnectorConfigurationError,
+    ConnectorConflictError,
+)
+from app.core.connectors.store import UpdateOutcome
+
+
+class ConnectorRepository:
+    def __init__(self, session, actor_uid=None, *, run_access=None, principal_uid=None):
+        self.session = session
+        self.actor_uid = actor_uid
+        self.run_access = run_access
+        self.principal_uid = principal_uid
+
+    @staticmethod
+    def _control_edges(run):
+        edges = []
+        if run.get("business_domain_uid"):
+            edges.append(
+                (
+                    "business_domain",
+                    run["business_domain_uid"],
+                    "owns",
+                    "source",
+                    run["source_uid"],
+                )
+            )
+        edges.append(("source", run["source_uid"], "executed", "run", run["uid"]))
+        if run.get("process_key"):
+            edges.append(
+                ("process", run["process_key"], "executed_as", "run", run["uid"])
+            )
+        return edges
+
+    def _upsert_graph_edges(self, run, status, edges):
+        for from_type, from_key, relation, to_type, to_key in edges:
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_graph_edges
+                  (uid,source_uid,from_type,from_key,relation_type,to_type,to_key,business_domain_uid,run_uid,run_status,evidence)
+                VALUES(CAST(:uid AS uuid),CAST(:source AS uuid),:from_type,:from_key,:relation,:to_type,:to_key,
+                       CAST(:domain AS uuid),CAST(:run AS uuid),:status,'{}'::jsonb)
+                ON CONFLICT(source_uid,from_type,from_key,relation_type,to_type,to_key)
+                DO UPDATE SET run_uid=EXCLUDED.run_uid,run_status=EXCLUDED.run_status,created_at=CURRENT_TIMESTAMP
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "source": run["source_uid"],
+                    "from_type": from_type,
+                    "from_key": str(from_key),
+                    "relation": relation,
+                    "to_type": to_type,
+                    "to_key": str(to_key),
+                    "domain": run.get("business_domain_uid"),
+                    "run": run["uid"],
+                    "status": status,
+                },
+            )
+
+    def register_manifest(self, manifest, actor_uid):
+        self.session.execute(
+            text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
+            {"key": f"connector-manifest:{manifest.connector_id}"},
+        )
+        self.session.execute(
+            text("""
+            INSERT INTO public.connector_manifests
+              (uid, connector_id, connector_version, sdk_version, display_name, capabilities, config_schema, status, created_by)
+            VALUES (CAST(:uid AS uuid), :connector_id, :version, :sdk_version, :display_name,
+                    CAST(:capabilities AS jsonb), CAST(:schema AS jsonb), 'active', CAST(:actor AS uuid))
+            ON CONFLICT (connector_id, connector_version) DO UPDATE SET
+              sdk_version=EXCLUDED.sdk_version, display_name=EXCLUDED.display_name,
+              capabilities=EXCLUDED.capabilities, config_schema=EXCLUDED.config_schema,
+              status='active', updated_at=CURRENT_TIMESTAMP
+        """),
+            {
+                "uid": new_governance_uid(),
+                "connector_id": manifest.connector_id,
+                "version": manifest.version,
+                "sdk_version": manifest.sdk_version,
+                "display_name": manifest.display_name,
+                "capabilities": json.dumps(list(manifest.capabilities)),
+                "schema": json.dumps(manifest.config_schema),
+                "actor": actor_uid,
+            },
+        )
+
+    def claim(self, key, record):
+        uid = new_governance_uid()
+        request_hash = record.get("request_hash") or key
+        row = self.session.execute(
+            text("""
+            INSERT INTO public.connector_runs
+              (uid, idempotency_key, connector_id, connector_version, source_uid, operation, status,
+               attempt_count, checkpoint, cursor, dry_run, actor_uid, principal_uid,
+               business_domain_uid, environment, process_key, safe_config, scope,
+               request_hash,client_hint_hash,source_binding_uid,source_binding_version,
+               cancel_requested)
+            VALUES (CAST(:uid AS uuid), :key, :connector_id, :version, CAST(:source AS uuid), :operation,
+                    'running', 0, CAST(:checkpoint AS jsonb), CAST(:cursor AS jsonb), :dry_run, CAST(:actor AS uuid),
+                    CAST(:principal AS uuid), CAST(:domain AS uuid), :environment, :process_key,
+                    CAST(:config AS jsonb), CAST(:scope AS jsonb),:request_hash,:client_hint_hash,
+                    CAST(:binding AS uuid),:binding_version,FALSE)
+            ON CONFLICT DO NOTHING
+            RETURNING uid::text
+        """),
+            {
+                "uid": uid,
+                "key": key,
+                "connector_id": record["connector_id"],
+                "version": record["connector_version"],
+                "source": record["source_uid"],
+                "operation": record["operation"],
+                "checkpoint": json.dumps(record.get("checkpoint", {})),
+                "cursor": json.dumps(record.get("cursor", {})),
+                "dry_run": bool(record.get("dry_run", False)),
+                "actor": record.get("actor_uid") or self.actor_uid,
+                "principal": record.get("principal_uid"),
+                "domain": record.get("business_domain_uid"),
+                "environment": record.get("environment"),
+                "process_key": record.get("process_key"),
+                "config": json.dumps(record.get("config", {}), sort_keys=True),
+                "scope": json.dumps(record.get("scope", {}), sort_keys=True),
+                "request_hash": request_hash,
+                "client_hint_hash": record.get("client_hint_hash"),
+                "binding": record.get("source_binding_uid"),
+                "binding_version": record.get("source_binding_version"),
+            },
+        ).scalar_one_or_none()
+        if row:
+            claimed = {**record, "uid": row, "status": "running"}
+            self._upsert_graph_edges(claimed, "running", self._control_edges(claimed))
+            self.session.commit()
+            return claimed, True
+        self.session.rollback()
+        existing = (
+            self.session.execute(
+                text("""
+                SELECT idempotency_key,request_hash
+                  FROM public.connector_runs
+                 WHERE idempotency_key=:key
+                    OR (:client_hint_hash IS NOT NULL AND client_hint_hash=:client_hint_hash)
+                 LIMIT 1
+                """),
+                {"key": key, "client_hint_hash": record.get("client_hint_hash")},
+            )
+            .mappings()
+            .one_or_none()
+        )
+        if existing is None or existing["request_hash"] != request_hash:
+            self.session.rollback()
+            raise ConnectorConflictError("idempotency request binding conflicts")
+        return self.get(existing["idempotency_key"]), False
+
+    def update(self, key, **values):
+        result = values.pop("result", None)
+        allowed = {
+            "status",
+            "attempt_count",
+            "checkpoint",
+            "cursor",
+            "error_category",
+            "error_code",
+            "resumed_from",
+            "lease_token",
+            "expected_attempt",
+            "cancel_requested",
+        }
+        if set(values) - allowed:
+            raise ConnectorConfigurationError("connector run update is invalid")
+        if "attempt_count" in values and not 1 <= int(values["attempt_count"]) <= 5:
+            raise ConnectorConfigurationError(
+                "connector run attempt must be between 1 and 5"
+            )
+        expected_attempt = values.pop("expected_attempt", None)
+        lease_token = values.pop("lease_token", None)
+        assignments, parameters = [], {"key": key}
+        for name, value in values.items():
+            if name in {"checkpoint", "cursor"}:
+                assignments.append(f"{name}=CAST(:{name} AS jsonb)")
+                parameters[name] = json.dumps(value)
+            elif name == "resumed_from":
+                assignments.append("resumed_from_run_uid=CAST(:resumed_from AS uuid)")
+                parameters[name] = value
+            else:
+                assignments.append(f"{name}=:{name}")
+                parameters[name] = value
+        assignments.append("updated_at=CURRENT_TIMESTAMP")
+        requested_status = values.get("status")
+        terminal = requested_status in {"succeeded", "dry_run", "failed", "cancelled"}
+        guarded = terminal or requested_status in {"running", "resumable"}
+        if requested_status == "resumable":
+            status_clause = " AND status IN ('failed','cancelled')"
+        elif requested_status == "cancelled":
+            status_clause = " AND status='running'"
+        elif requested_status == "running" and "attempt_count" in values:
+            status_clause = (
+                " AND status IN ('running','resumable','failed')"
+                " AND attempt_count < :attempt_count"
+                " AND cancel_requested=FALSE"
+            )
+            assignments.append("attempt_lease_token=CAST(:lease_token AS uuid)")
+            parameters["lease_token"] = lease_token
+        elif requested_status in {"succeeded", "dry_run", "failed"}:
+            status_clause = (
+                " AND status='running' AND cancel_requested=FALSE"
+                " AND attempt_count=:expected_attempt"
+                " AND attempt_lease_token=CAST(:lease_token AS uuid)"
+            )
+            parameters["expected_attempt"] = expected_attempt
+            parameters["lease_token"] = lease_token
+        elif guarded:
+            status_clause = " AND status IN ('running','resumable','failed')"
+        else:
+            status_clause = ""
+        if self.run_access == "human":
+            status_clause += " AND principal_uid IS NULL AND dry_run=TRUE"
+        elif self.run_access == "machine":
+            status_clause += " AND principal_uid=CAST(:access_principal AS uuid)"
+            parameters["access_principal"] = self.principal_uid
+        update_result = self.session.execute(
+            text(
+                f"UPDATE public.connector_runs SET {', '.join(assignments)} WHERE idempotency_key=:key{status_clause}"
+            ),
+            parameters,
+        )
+        if guarded and update_result.rowcount != 1:
+            self.session.rollback()
+            return UpdateOutcome(self.get(key) or {}, False)
+        run_uid = self.session.execute(
+            text(
+                "SELECT uid::text FROM public.connector_runs WHERE idempotency_key=:key"
+            ),
+            {"key": key},
+        ).scalar_one()
+        if "attempt_count" in values:
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_run_attempts(uid,run_uid,attempt_number,status,lease_token)
+                VALUES(CAST(:uid AS uuid),CAST(:run AS uuid),:attempt,'running',CAST(:lease AS uuid))
+                ON CONFLICT(run_uid,attempt_number) DO NOTHING
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "attempt": values["attempt_count"],
+                    "lease": lease_token,
+                },
+            )
+        if values.get("status") in {"succeeded", "dry_run", "failed", "cancelled"}:
+            self.session.execute(
+                text("""
+                    UPDATE public.connector_run_attempts
+                       SET status=:status, finished_at=CURRENT_TIMESTAMP,
+                           error_category=:error_category
+                     WHERE run_uid=CAST(:run AS uuid)
+                       AND attempt_number=COALESCE(
+                         :attempt,
+                         (SELECT attempt_count FROM public.connector_runs WHERE uid=CAST(:run AS uuid))
+                       )
+                       AND lease_token=COALESCE(
+                         CAST(:lease AS uuid),
+                         (SELECT attempt_lease_token FROM public.connector_runs WHERE uid=CAST(:run AS uuid))
+                       )
+                """),
+                {
+                    "run": run_uid,
+                    "status": values["status"],
+                    "error_category": values.get("error_category"),
+                    "attempt": expected_attempt,
+                    "lease": lease_token,
+                },
+            )
+        if result is not None:
+            payload = dict(result.evidence)
+            encoded = json.dumps(payload, sort_keys=True, default=str).encode()
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_evidence
+                  (uid,run_uid,evidence_type,payload,content_hash,byte_size,redacted)
+                VALUES (CAST(:uid AS uuid),CAST(:run AS uuid),'operation',CAST(:payload AS jsonb),:hash,:size,TRUE)
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "payload": encoded.decode(),
+                    "hash": hashlib.sha256(encoded).hexdigest(),
+                    "size": len(encoded),
+                },
+            )
+            checkpoint_payload = json.dumps(
+                dict(result.checkpoint), sort_keys=True, default=str
+            )
+            cursor_payload = json.dumps(
+                dict(result.cursor), sort_keys=True, default=str
+            )
+            checkpoint_hash = hashlib.sha256(
+                (cursor_payload + checkpoint_payload).encode()
+            ).hexdigest()
+            sequence = self.session.execute(
+                text(
+                    "SELECT COALESCE(MAX(sequence_number),0)+1 FROM public.connector_checkpoints WHERE run_uid=CAST(:run AS uuid)"
+                ),
+                {"run": run_uid},
+            ).scalar_one()
+            self.session.execute(
+                text("""
+                INSERT INTO public.connector_checkpoints(uid,run_uid,sequence_number,cursor,checkpoint,content_hash)
+                VALUES(CAST(:uid AS uuid),CAST(:run AS uuid),:sequence,CAST(:cursor AS jsonb),CAST(:checkpoint AS jsonb),:hash)
+            """),
+                {
+                    "uid": new_governance_uid(),
+                    "run": run_uid,
+                    "sequence": sequence,
+                    "cursor": cursor_payload,
+                    "checkpoint": checkpoint_payload,
+                    "hash": checkpoint_hash,
+                },
+            )
+            run = self.get(key)
+            edge_values = []
+            for record in result.records:
+                asset_key = record.get("asset_key") or record.get("key")
+                if not asset_key:
+                    continue
+                edge_values.extend(
+                    [
+                        (
+                            "source",
+                            run["source_uid"],
+                            "contains",
+                            "asset",
+                            str(asset_key),
+                        ),
+                        ("run", run_uid, "observed", "asset", str(asset_key)),
+                    ]
+                )
+            self._upsert_graph_edges(
+                run, values.get("status", "succeeded"), edge_values
+            )
+        if requested_status:
+            self.session.execute(
+                text("""
+                UPDATE public.connector_graph_edges
+                   SET run_status=:status,created_at=CURRENT_TIMESTAMP
+                 WHERE run_uid=CAST(:run AS uuid)
+            """),
+                {"status": requested_status, "run": run_uid},
+            )
+        self.session.commit()
+        return UpdateOutcome(self.get(key) or {}, True)
+
+    def get(self, key):
+        row = (
+            self.session.execute(
+                text("""
+            SELECT uid::text, idempotency_key, connector_id, connector_version, source_uid::text,
+                   operation, status, attempt_count, checkpoint, cursor, error_category, error_code,
+                   resumed_from_run_uid::text, dry_run, created_at, updated_at
+                   ,principal_uid::text,business_domain_uid::text,environment,process_key,safe_config AS config,scope,
+                   request_hash,source_binding_uid::text,source_binding_version,
+                   attempt_lease_token::text,cancel_requested
+            FROM public.connector_runs
+            WHERE idempotency_key=:key OR client_hint_hash=:hint
+            ORDER BY (idempotency_key=:key) DESC LIMIT 1
+        """),
+                {
+                    "key": key,
+                    "hint": hashlib.sha256(str(key).encode()).hexdigest(),
+                },
+            )
+            .mappings()
+            .one_or_none()
+        )
+        result = dict(row) if row else None
+        if result and self.run_access == "human" and (
+            result.get("principal_uid") is not None or not result.get("dry_run")
+        ):
+            return None
+        if result and self.run_access == "machine" and result.get("principal_uid") != self.principal_uid:
+            return None
+        return result
+
+    def list(self, limit=100):
+        rows = (
+            self.session.execute(
+                text("""
+            SELECT uid::text, idempotency_key, connector_id, connector_version, source_uid::text,
+                   operation, status, attempt_count, checkpoint, cursor, error_category, dry_run, created_at, updated_at
+                   ,principal_uid::text,business_domain_uid::text,environment,process_key
+            FROM public.connector_runs ORDER BY created_at DESC LIMIT :limit
+        """),
+                {"limit": min(max(int(limit), 1), 200)},
+            )
+            .mappings()
+            .all()
+        )
+        result = []
+        for row in rows:
+            item = dict(row)
+            for name in ("checkpoint", "cursor"):
+                value = item.pop(name) or {}
+                encoded = json.dumps(value, sort_keys=True, default=str).encode()
+                item[f"{name}_summary"] = {
+                    "sha256": hashlib.sha256(encoded).hexdigest(),
+                    "item_count": len(value) if isinstance(value, (dict, list)) else 0,
+                    "byte_size": len(encoded),
+                }
+            result.append(item)
+        return result
+
+    def acquire_rate_limit(self, key, limit=30):
+        row = self.session.execute(
+            text("""
+            INSERT INTO public.connector_rate_limits(limit_key,window_started_at,request_count)
+            VALUES(:key,date_trunc('minute',CURRENT_TIMESTAMP),1)
+            ON CONFLICT(limit_key,window_started_at) DO UPDATE
+              SET request_count=public.connector_rate_limits.request_count+1,updated_at=CURRENT_TIMESTAMP
+              WHERE public.connector_rate_limits.request_count < :limit
+            RETURNING request_count
+        """),
+            {"key": key, "limit": min(int(limit), 30)},
+        ).scalar_one_or_none()
+        self.session.commit()
+        if row is None:
+            from app.core.connectors.errors import ConnectorRateLimitError
+
+            raise ConnectorRateLimitError("connector runtime rate limit exceeded")
+
+    def cancel(self, key):
+        outcome = self.update(
+            key,
+            status="cancelled",
+            cancel_requested=True,
+        )
+        if not outcome.acquired:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        return outcome.record
+
+    def is_cancel_requested(self, key):
+        value = self.session.execute(
+            text(
+                "SELECT cancel_requested FROM public.connector_runs WHERE idempotency_key=:key"
+            ),
+            {"key": key},
+        ).scalar_one_or_none()
+        return bool(value)
+
+    def graph(
+        self,
+        source_uid=None,
+        business_domain_uid=None,
+        process_key=None,
+        run_uid=None,
+        limit=500,
+    ):
+        filters = []
+        parameters = {"limit": min(max(int(limit), 1), 1000)}
+        if source_uid:
+            filters.append("source_uid=CAST(:source AS uuid)")
+            parameters["source"] = source_uid
+        if business_domain_uid:
+            filters.append("business_domain_uid=CAST(:domain AS uuid)")
+            parameters["domain"] = business_domain_uid
+        if process_key:
+            filters.append("(from_key=:process OR to_key=:process)")
+            parameters["process"] = process_key
+        if run_uid:
+            filters.append("run_uid=CAST(:run AS uuid)")
+            parameters["run"] = run_uid
+        clauses = "WHERE " + " AND ".join(filters) if filters else ""
+        rows = (
+            self.session.execute(
+                text(f"""
+            SELECT uid::text, source_uid::text, from_type, from_key, relation_type, to_type, to_key,
+                   business_domain_uid::text, run_uid::text, run_status, evidence, created_at
+            FROM public.connector_graph_edges {clauses}
+            ORDER BY created_at DESC LIMIT :limit
+        """),
+                parameters,
+            )
+            .mappings()
+            .all()
+        )
+        edges = [dict(row) for row in rows]
+        node_map = {}
+        for edge in edges:
+            node_map[(edge["from_type"], edge["from_key"])] = {
+                "type": edge["from_type"],
+                "key": edge["from_key"],
+            }
+            node_map[(edge["to_type"], edge["to_key"])] = {
+                "type": edge["to_type"],
+                "key": edge["to_key"],
+            }
+        return {
+            "nodes": list(node_map.values()),
+            "edges": edges,
+            "summary": {"node_count": len(node_map), "edge_count": len(edges)},
+        }
+
+
+__all__ = ["ConnectorRepository"]

+ 573 - 0
deployment/app/core/connectors/runtime.py

@@ -0,0 +1,573 @@
+"""Connector execution controls: idempotency, bounded retry, cancellation and evidence."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+import threading
+import time
+import uuid
+from collections import deque
+from dataclasses import replace
+
+from app.core.connectors.errors import (
+    ConnectorCancelledError,
+    ConnectorConfigurationError,
+    ConnectorConflictError,
+    ConnectorRateLimitError,
+    classify_error,
+)
+from app.core.connectors.sdk import OperationResult
+from app.core.connectors.store import UpdateOutcome
+
+MAX_EVIDENCE_BYTES = 32768
+MAX_CURSOR_BYTES = 32768
+MAX_CHECKPOINT_BYTES = 262144
+MAX_RECORDS_BYTES = 1048576
+MAX_RECORDS = 10000
+MAX_TOTAL_ATTEMPTS = 5
+SENSITIVE_KEYS = {
+    "password",
+    "passwd",
+    "secret",
+    "token",
+    "api_key",
+    "authorization",
+    "credential",
+}
+
+
+def deterministic_idempotency_key(connector_id, version, request):
+    payload = {
+        "connector_id": connector_id,
+        "version": version,
+        "source_uid": request.source_uid,
+        "principal_uid": request.principal_uid,
+        "business_domain_uid": request.business_domain_uid,
+        "environment": request.environment,
+        "process_key": request.process_key,
+        "source_binding_uid": request.source_binding_uid,
+        "source_binding_version": request.source_binding_version,
+        "operation": request.operation,
+        "config": request.config,
+        "scope": request.scope,
+        "cursor": request.cursor,
+        "checkpoint": request.checkpoint,
+        "dry_run": request.dry_run,
+        "client_idempotency_hint": request.idempotency_key,
+    }
+    encoded = json.dumps(
+        payload, sort_keys=True, separators=(",", ":"), ensure_ascii=True
+    ).encode()
+    return hashlib.sha256(encoded).hexdigest()
+
+
+def redact_evidence(
+    value, *, max_bytes=MAX_EVIDENCE_BYTES, summarize=True, truncate_lists=True
+):
+    def visit(item):
+        if isinstance(item, dict):
+            return {
+                str(key): "[REDACTED]"
+                if any(marker in str(key).lower() for marker in SENSITIVE_KEYS)
+                else visit(child)
+                for key, child in item.items()
+            }
+        if isinstance(item, (list, tuple)):
+            values = item[:1000] if truncate_lists else item
+            return [visit(child) for child in values]
+        if isinstance(item, str):
+            text_value = item[:4096]
+            text_value = re.sub(
+                r"(?i)\bBearer\s+[A-Za-z0-9._~+/-]+=*", "Bearer [REDACTED]", text_value
+            )
+            text_value = re.sub(r"\bdopc_[A-Za-z0-9_-]+", "[REDACTED]", text_value)
+            text_value = re.sub(
+                r"(?i)(password|token|secret|api[_-]?key)=([^&\s]+)",
+                r"\1=[REDACTED]",
+                text_value,
+            )
+            text_value = re.sub(
+                r"(?i)(https?://)[^/@\s]+@", r"\1[REDACTED]@", text_value
+            )
+            return text_value
+        return item
+
+    safe = visit(value)
+    encoded = json.dumps(safe, sort_keys=True, default=str).encode()
+    if summarize and len(encoded) > max_bytes:
+        return {
+            "truncated": True,
+            "sha256": hashlib.sha256(encoded).hexdigest(),
+            "original_bytes": len(encoded),
+        }
+    return safe
+
+
+def _bounded_channel(value, *, max_bytes, channel):
+    raw = json.dumps(
+        value, sort_keys=True, separators=(",", ":"), default=str
+    ).encode()
+    if len(raw) > max_bytes:
+        raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
+    safe = redact_evidence(
+        value, max_bytes=max_bytes, summarize=False, truncate_lists=False
+    )
+    encoded = json.dumps(
+        safe, sort_keys=True, separators=(",", ":"), default=str
+    ).encode()
+    if len(encoded) > max_bytes:
+        raise ConnectorConfigurationError(f"connector {channel} exceeds safe limit")
+    return safe
+
+
+def sanitize_operation_result(result):
+    if not isinstance(result.records, (list, tuple)) or len(result.records) > MAX_RECORDS:
+        raise ConnectorConfigurationError("connector records exceed safe limit")
+    records = _bounded_channel(
+        list(result.records), max_bytes=MAX_RECORDS_BYTES, channel="records"
+    )
+    if any(not isinstance(item, dict) for item in records):
+        raise ConnectorConfigurationError("connector records must be objects")
+    cursor = _bounded_channel(
+        dict(result.cursor), max_bytes=MAX_CURSOR_BYTES, channel="cursor"
+    )
+    checkpoint = _bounded_channel(
+        dict(result.checkpoint), max_bytes=MAX_CHECKPOINT_BYTES, channel="checkpoint"
+    )
+    evidence = _bounded_channel(
+        dict(result.evidence), max_bytes=MAX_EVIDENCE_BYTES, channel="evidence"
+    )
+    return replace(
+        result,
+        records=tuple(records),
+        cursor=cursor,
+        checkpoint=checkpoint,
+        evidence=evidence,
+    )
+
+
+def snapshot_diff(previous, current):
+    def identity(item):
+        if isinstance(item, dict):
+            return (
+                str(item.get("asset_key") or item.get("key") or "")
+                + ":"
+                + str(item.get("field") or "")
+            )
+        return json.dumps(item, sort_keys=True, default=str)
+
+    before = {identity(item): item for item in previous}
+    after = {identity(item): item for item in current}
+    shared = before.keys() & after.keys()
+    return {
+        "added": tuple(after[key] for key in sorted(after.keys() - before.keys())),
+        "removed": tuple(before[key] for key in sorted(before.keys() - after.keys())),
+        "changed": tuple(
+            {"before": before[key], "after": after[key]}
+            for key in sorted(shared)
+            if before[key] != after[key]
+        ),
+    }
+
+
+class InMemoryRunStore:
+    """Test/reference store. Production API injects the PostgreSQL repository."""
+
+    def __init__(self):
+        self._lock = threading.RLock()
+        self._runs = {}
+        self._hints = {}
+
+    def claim(self, key, record):
+        with self._lock:
+            hint = record.get("client_hint_hash")
+            if hint and hint in self._hints and self._hints[hint] != key:
+                raise ConnectorConflictError("idempotency hint conflicts with another request")
+            if key in self._runs:
+                if self._runs[key].get("request_hash") != record.get("request_hash"):
+                    raise ConnectorConflictError("idempotency request binding conflicts")
+                return self._runs[key], False
+            self._runs[key] = dict(record)
+            if hint:
+                self._hints[hint] = key
+            return self._runs[key], True
+
+    def update(self, key, **values):
+        with self._lock:
+            record = self._runs[key]
+            current = record.get("status")
+            requested = values.get("status")
+            expected_attempt = values.pop("expected_attempt", None)
+            lease_token = values.pop("lease_token", None)
+            acquired = True
+            if requested == "resumable":
+                acquired = current in {"failed", "cancelled"}
+            elif requested == "cancelled":
+                acquired = current == "running"
+            elif requested == "running":
+                attempt = int(values.get("attempt_count", -1))
+                acquired = (
+                    current in {"running", "resumable", "failed"}
+                    and int(record.get("attempt_count", 0)) < attempt
+                )
+            elif requested in {"succeeded", "dry_run", "failed"}:
+                acquired = (
+                    current == "running"
+                    and not record.get("cancel_requested", False)
+                    and int(record.get("attempt_count", -1)) == int(expected_attempt or -1)
+                    and record.get("attempt_lease_token") == lease_token
+                )
+            if not acquired:
+                return UpdateOutcome(dict(record), False)
+            self._runs[key].update(values)
+            if requested == "running":
+                self._runs[key]["attempt_lease_token"] = lease_token
+            return UpdateOutcome(dict(self._runs[key]), True)
+
+    def cancel(self, key):
+        outcome = self.update(key, status="cancelled", cancel_requested=True)
+        if not outcome.acquired:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        return outcome.record
+
+    def is_cancel_requested(self, key):
+        with self._lock:
+            return bool(self._runs.get(key, {}).get("cancel_requested"))
+
+    def get(self, key):
+        with self._lock:
+            value = self._runs.get(key)
+            if value is None:
+                canonical = self._hints.get(hashlib.sha256(str(key).encode()).hexdigest())
+                value = self._runs.get(canonical) if canonical else None
+            return dict(value) if value else None
+
+    def list(self):
+        with self._lock:
+            return [dict(item) for item in self._runs.values()]
+
+
+class SlidingWindowLimiter:
+    def __init__(self, limit=30, window_seconds=60, clock=None):
+        self.limit = int(limit)
+        self.window_seconds = float(window_seconds)
+        self.clock = clock or time.monotonic
+        self._lock = threading.Lock()
+        self._events = {}
+
+    def acquire(self, key):
+        now = self.clock()
+        with self._lock:
+            events = self._events.setdefault(key, deque())
+            while events and events[0] <= now - self.window_seconds:
+                events.popleft()
+            if len(events) >= self.limit:
+                raise ConnectorRateLimitError("connector runtime rate limit exceeded")
+            events.append(now)
+
+
+class ConnectorRuntime:
+    def __init__(
+        self, registry, store=None, limiter=None, max_attempts=3, sleeper=None
+    ):
+        if max_attempts < 1 or max_attempts > 5:
+            raise ConnectorConfigurationError("max_attempts must be between 1 and 5")
+        self.registry = registry
+        self.store = store or InMemoryRunStore()
+        self.limiter = limiter or SlidingWindowLimiter()
+        self.max_attempts = max_attempts
+        self.sleeper = sleeper or time.sleep
+
+    def _acquire_rate_limit(self, connector_id, source_uid):
+        key = f"{connector_id}:{source_uid}"
+        if hasattr(self.store, "acquire_rate_limit"):
+            self.store.acquire_rate_limit(key)
+        else:
+            self.limiter.acquire(key)
+
+    def _run_operation(self, key, record, request, operation):
+        first_attempt = int(record.get("attempt_count", 0)) + 1
+        remaining = MAX_TOTAL_ATTEMPTS - first_attempt + 1
+        invocation_attempts = min(self.max_attempts, remaining)
+        if invocation_attempts <= 0:
+            raise ConnectorConfigurationError(
+                "connector run attempt budget is exhausted"
+            )
+        for attempt in range(first_attempt, first_attempt + invocation_attempts):
+            lease_token = str(uuid.uuid4())
+            started = self.store.update(
+                key,
+                attempt_count=attempt,
+                status="running",
+                error_category=None,
+                error_code=None,
+                lease_token=lease_token,
+            )
+            if not started.acquired:
+                if started.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError()
+                raise ConnectorConfigurationError(
+                    "connector run attempt is already owned"
+                )
+            started_record = started.record
+            if started_record.get("status") == "cancelled":
+                raise ConnectorCancelledError()
+            if (
+                started_record.get("status") != "running"
+                or int(started_record.get("attempt_count", -1)) != attempt
+            ):
+                raise ConnectorConfigurationError(
+                    "connector run attempt could not be claimed"
+                )
+            try:
+                def cancel_probe():
+                    return bool(
+                        hasattr(self.store, "is_cancel_requested")
+                        and self.store.is_cancel_requested(key)
+                    )
+                attempt_request = replace(
+                    request,
+                    run_key=key,
+                    lease_token=lease_token,
+                    cancel_probe=cancel_probe,
+                )
+                if cancel_probe():
+                    raise ConnectorCancelledError()
+                result = operation(attempt_request)
+                if not isinstance(result, OperationResult):
+                    raise ConnectorConfigurationError(
+                        "connector returned an invalid result"
+                    )
+                if result.status != "succeeded":
+                    raise ConnectorConfigurationError(
+                        "connector operation returned an invalid status"
+                    )
+                if cancel_probe():
+                    raise ConnectorCancelledError()
+                safe_result = sanitize_operation_result(result)
+                status = "dry_run" if request.dry_run else "succeeded"
+                safe_result = replace(safe_result, status=status)
+                updated = self.store.update(
+                    key,
+                    status=status,
+                    result=safe_result,
+                    checkpoint=dict(safe_result.checkpoint),
+                    cursor=dict(safe_result.cursor),
+                    error_category=None,
+                    error_code=None,
+                    expected_attempt=attempt,
+                    lease_token=lease_token,
+                )
+                if not updated.acquired and updated.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError()
+                if not updated.acquired:
+                    raise ConnectorConfigurationError(
+                        "connector run completion ownership was lost"
+                    )
+                return safe_result
+            except Exception as error:
+                classified = classify_error(error)
+                updated = self.store.update(
+                    key,
+                    status="failed",
+                    error_category=classified.category,
+                    error_code=type(classified).__name__,
+                    expected_attempt=attempt,
+                    lease_token=lease_token,
+                )
+                if not updated.acquired and updated.record.get("status") == "cancelled":
+                    raise ConnectorCancelledError() from error
+                if not updated.acquired:
+                    raise ConnectorConfigurationError(
+                        "connector run failure ownership was lost"
+                    ) from error
+                final_attempt = attempt == first_attempt + invocation_attempts - 1
+                if not classified.retryable or final_attempt:
+                    raise classified from error
+                self.sleeper(min(0.1 * (2 ** (attempt - 1)), 1.0))
+        raise ConnectorConfigurationError("connector run did not complete")
+
+    def execute(self, connector_id, version, request):
+        connector = self.registry.resolve(connector_id, version, request.operation)
+        config = self.registry.validate(connector_id, version, request.config)
+        request = replace(request, config=config)
+        key = deterministic_idempotency_key(connector_id, version, request)
+        client_hint_hash = (
+            hashlib.sha256(str(request.idempotency_key).encode()).hexdigest()
+            if request.idempotency_key
+            else None
+        )
+        self._acquire_rate_limit(connector_id, request.source_uid)
+        record, created = self.store.claim(
+            key,
+            {
+                "idempotency_key": key,
+                "request_hash": key,
+                "client_hint_hash": client_hint_hash,
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": request.source_uid,
+                "operation": request.operation,
+                "config": dict(request.config),
+                "scope": dict(request.scope),
+                "status": "running",
+                "attempt_count": 0,
+                "checkpoint": dict(request.checkpoint),
+                "cursor": dict(request.cursor),
+                "dry_run": request.dry_run,
+                "principal_uid": request.principal_uid,
+                "business_domain_uid": request.business_domain_uid,
+                "environment": request.environment,
+                "process_key": request.process_key,
+                "source_binding_uid": request.source_binding_uid,
+                "source_binding_version": request.source_binding_version,
+                "cancel_requested": False,
+            },
+        )
+        if not created:
+            if record["status"] in {"succeeded", "dry_run"}:
+                return record.get("result") or OperationResult(
+                    cursor=record.get("cursor") or {},
+                    checkpoint=record.get("checkpoint") or {},
+                    evidence={"idempotent_replay": True},
+                    status=record["status"],
+                )
+            if record["status"] == "running":
+                raise ConnectorConfigurationError(
+                    "idempotent operation is already running"
+                )
+        if record.get("status") == "cancelled":
+            raise ConnectorCancelledError()
+        operation = getattr(connector, request.operation)
+        if request.dry_run:
+            health = connector.health(config)
+
+            def validation_only(_request):
+                return OperationResult(
+                    evidence={
+                        "validation_only": True,
+                        "health_status": health.status,
+                        "network_requested": False,
+                    },
+                )
+
+            operation = validation_only
+        return self._run_operation(key, record, request, operation)
+
+    def cancel(self, key):
+        record = self.store.get(key)
+        if not record:
+            raise ConnectorConfigurationError("connector run was not found")
+        if record["status"] in {"succeeded", "failed", "cancelled"}:
+            raise ConnectorConfigurationError("connector run cannot be cancelled")
+        key = record.get("idempotency_key", key)
+        connector = self.registry.resolve(
+            record["connector_id"], record["connector_version"], "cancel"
+        )
+        from app.core.connectors.sdk import OperationRequest
+
+        request = OperationRequest(
+            source_uid=record["source_uid"],
+            operation="cancel",
+            config=record.get("config") or {},
+            scope=record.get("scope") or {},
+            cursor=record.get("cursor") or {},
+            checkpoint=record.get("checkpoint") or {},
+            idempotency_key=key,
+            dry_run=bool(record.get("dry_run")),
+            principal_uid=record.get("principal_uid"),
+            business_domain_uid=record.get("business_domain_uid"),
+            environment=record.get("environment"),
+            process_key=record.get("process_key"),
+            source_binding_uid=record.get("source_binding_uid"),
+            source_binding_version=record.get("source_binding_version"),
+            run_key=key,
+        )
+        if hasattr(self.store, "cancel"):
+            cancelled = self.store.cancel(key)
+        else:
+            outcome = self.store.update(
+                key, status="cancelled", cancel_requested=True
+            )
+            if not outcome.acquired:
+                raise ConnectorConfigurationError("connector run cannot be cancelled")
+            cancelled = outcome.record
+        def cancel_probe():
+            return bool(
+                hasattr(self.store, "is_cancel_requested")
+                and self.store.is_cancel_requested(key)
+            )
+        request = replace(request, cancel_probe=cancel_probe)
+        if not request.dry_run:
+            connector.cancel(request)
+        return cancelled
+
+    def resume(self, key):
+        record = self.store.get(key)
+        if not record or record["status"] not in {"failed", "cancelled"}:
+            raise ConnectorConfigurationError("connector run cannot be resumed")
+        key = record.get("idempotency_key", key)
+        connector = self.registry.resolve(
+            record["connector_id"], record["connector_version"], "resume"
+        )
+        config = self.registry.validate(
+            record["connector_id"],
+            record["connector_version"],
+            record.get("config") or {},
+        )
+        self._acquire_rate_limit(record["connector_id"], record["source_uid"])
+        from app.core.connectors.sdk import OperationRequest
+
+        request = OperationRequest(
+            source_uid=record["source_uid"],
+            operation="resume",
+            config=config,
+            scope=record.get("scope") or {},
+            cursor=record.get("cursor") or {},
+            checkpoint=record.get("checkpoint") or {},
+            idempotency_key=key,
+            dry_run=bool(record.get("dry_run")),
+            principal_uid=record.get("principal_uid"),
+            business_domain_uid=record.get("business_domain_uid"),
+            environment=record.get("environment"),
+            process_key=record.get("process_key"),
+            source_binding_uid=record.get("source_binding_uid"),
+            source_binding_version=record.get("source_binding_version"),
+        )
+        resumable = self.store.update(
+            key,
+            status="resumable",
+            resumed_from=record.get("uid"),
+            cancel_requested=False,
+        )
+        if not resumable.acquired:
+            raise ConnectorConfigurationError("connector run cannot be resumed")
+        operation = connector.resume
+        if request.dry_run:
+            health = connector.health(config)
+
+            def validation_only(_request):
+                return OperationResult(
+                    evidence={
+                        "validation_only": True,
+                        "health_status": health.status,
+                        "network_requested": False,
+                        "resumed": True,
+                    }
+                )
+
+            operation = validation_only
+        return self._run_operation(key, resumable.record, request, operation)
+
+
+__all__ = [
+    "ConnectorRuntime",
+    "InMemoryRunStore",
+    "SlidingWindowLimiter",
+    "MAX_TOTAL_ATTEMPTS",
+    "deterministic_idempotency_key",
+    "snapshot_diff",
+    "redact_evidence",
+]

+ 233 - 0
deployment/app/core/connectors/sdk.py

@@ -0,0 +1,233 @@
+"""Versioned public connector SDK.
+
+Only symbols exported here and from ``app.core.connectors`` are stable. Connector
+packages register against the registry; the collection runtime never branches on
+connector names.
+"""
+
+from __future__ import annotations
+
+import re
+from abc import ABC, abstractmethod
+from collections.abc import Callable, Mapping
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+from jsonschema import Draft202012Validator
+from jsonschema.exceptions import SchemaError, ValidationError
+
+from app.core.connectors.errors import ConnectorConfigurationError
+
+SDK_VERSION = "1.0"
+CAPABILITIES = frozenset(
+    {
+        "discover",
+        "snapshot",
+        "incremental",
+        "lineage",
+        "profile",
+        "cancel",
+        "resume",
+        "evidence",
+    }
+)
+SECRET_KEY_PATTERN = re.compile(
+    r"(?:password|passwd|secret|token|api[_-]?key|authorization|private[_-]?key)", re.I
+)
+SECRET_REF_PATTERN = re.compile(
+    r"^(?:env|vault|secret):[A-Za-z0-9][A-Za-z0-9_./:-]{2,255}$"
+)
+SEMVER_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$")
+
+
+@dataclass(frozen=True)
+class ConnectorManifest:
+    connector_id: str
+    version: str
+    sdk_version: str
+    display_name: str
+    capabilities: tuple[str, ...]
+    config_schema: Mapping[str, Any] = field(repr=False)
+
+    def __post_init__(self):
+        if not re.fullmatch(r"[a-z][a-z0-9_-]{2,63}", self.connector_id):
+            raise ConnectorConfigurationError("connector_id is invalid")
+        if not SEMVER_PATTERN.fullmatch(self.version):
+            raise ConnectorConfigurationError("connector version is invalid")
+        if self.sdk_version != SDK_VERSION:
+            raise ConnectorConfigurationError("SDK version is incompatible")
+        capabilities = tuple(dict.fromkeys(self.capabilities))
+        if not capabilities or set(capabilities) - CAPABILITIES:
+            raise ConnectorConfigurationError("connector capability is unsupported")
+        object.__setattr__(self, "capabilities", capabilities)
+        validate_schema(self.config_schema)
+
+    def public_dict(self):
+        return asdict(self)
+
+
+@dataclass(frozen=True)
+class OperationRequest:
+    source_uid: str
+    operation: str
+    config: Mapping[str, Any]
+    scope: Mapping[str, Any] = field(default_factory=dict)
+    cursor: Mapping[str, Any] = field(default_factory=dict)
+    checkpoint: Mapping[str, Any] = field(default_factory=dict)
+    idempotency_key: str | None = None
+    dry_run: bool = False
+    principal_uid: str | None = None
+    business_domain_uid: str | None = None
+    environment: str | None = None
+    process_key: str | None = None
+    source_binding_uid: str | None = None
+    source_binding_version: int | None = None
+    run_key: str | None = None
+    lease_token: str | None = None
+    cancel_probe: Callable[[], bool] | None = field(
+        default=None, compare=False, repr=False
+    )
+
+
+@dataclass(frozen=True)
+class OperationResult:
+    records: tuple[Mapping[str, Any], ...] = ()
+    cursor: Mapping[str, Any] = field(default_factory=dict)
+    checkpoint: Mapping[str, Any] = field(default_factory=dict)
+    evidence: Mapping[str, Any] = field(default_factory=dict)
+    status: str = "succeeded"
+
+
+@dataclass(frozen=True)
+class HealthResult:
+    status: str
+    detail: str = ""
+
+
+@dataclass(frozen=True)
+class CompatibilityResult:
+    compatible: bool
+    connector_version: str
+    sdk_version: str = SDK_VERSION
+    detail: str = ""
+
+
+def _walk_secrets(value, path="config"):
+    if isinstance(value, Mapping):
+        for key, item in value.items():
+            current = f"{path}.{key}"
+            if SECRET_KEY_PATTERN.search(str(key)) and (
+                not str(key).endswith("_ref")
+                or not isinstance(item, str)
+                or not SECRET_REF_PATTERN.fullmatch(item)
+            ):
+                raise ConnectorConfigurationError(
+                    f"{current} must be a secret reference"
+                )
+            _walk_secrets(item, current)
+    elif isinstance(value, (list, tuple)):
+        for index, item in enumerate(value):
+            _walk_secrets(item, f"{path}[{index}]")
+
+
+def validate_schema(schema):
+    if not isinstance(schema, Mapping) or schema.get("type") != "object":
+        raise ConnectorConfigurationError("config schema must describe an object")
+    if schema.get("additionalProperties") is not False:
+        raise ConnectorConfigurationError(
+            "config schema must reject unknown properties"
+        )
+    properties = schema.get("properties")
+    if not isinstance(properties, Mapping):
+        raise ConnectorConfigurationError("config schema properties are required")
+
+    def inspect_definition(definition):
+        if not isinstance(definition, Mapping):
+            return
+        for key, child in definition.get("properties", {}).items():
+            if SECRET_KEY_PATTERN.search(str(key)) and not str(key).endswith("_ref"):
+                raise ConnectorConfigurationError(
+                    "config schema cannot define plaintext secrets"
+                )
+            if (
+                str(key).endswith("_ref")
+                and child.get("pattern") != SECRET_REF_PATTERN.pattern
+            ):
+                raise ConnectorConfigurationError(
+                    "secret reference schema pattern is required"
+                )
+            inspect_definition(child)
+        inspect_definition(definition.get("items"))
+        for keyword in ("allOf", "anyOf", "oneOf"):
+            for child in definition.get(keyword, ()):
+                inspect_definition(child)
+        for child in definition.get("$defs", {}).values():
+            inspect_definition(child)
+
+    inspect_definition(schema)
+    try:
+        Draft202012Validator.check_schema(dict(schema))
+    except SchemaError as exc:
+        raise ConnectorConfigurationError("config JSON Schema is invalid") from exc
+
+
+def validate_config(schema, config):
+    """Validate recursively with JSON Schema Draft 2020-12."""
+    if not isinstance(config, Mapping):
+        raise ConnectorConfigurationError("connector config must be an object")
+    _walk_secrets(config)
+    try:
+        Draft202012Validator(dict(schema)).validate(dict(config))
+    except ValidationError as exc:
+        raise ConnectorConfigurationError("connector config shape is invalid") from exc
+    return dict(config)
+
+
+class Connector(ABC):
+    manifest: ConnectorManifest
+
+    @abstractmethod
+    def discover(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def snapshot(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def incremental(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def lineage(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def profile(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def cancel(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def resume(self, request: OperationRequest) -> OperationResult: ...
+
+    @abstractmethod
+    def evidence(self, request: OperationRequest) -> OperationResult: ...
+
+    def health(self, config: Mapping[str, Any]) -> HealthResult:
+        validate_config(self.manifest.config_schema, config)
+        return HealthResult("available")
+
+    def compatibility(self) -> CompatibilityResult:
+        return CompatibilityResult(True, self.manifest.version)
+
+
+__all__ = [
+    "SDK_VERSION",
+    "CAPABILITIES",
+    "SECRET_REF_PATTERN",
+    "ConnectorManifest",
+    "OperationRequest",
+    "OperationResult",
+    "HealthResult",
+    "CompatibilityResult",
+    "Connector",
+    "validate_config",
+    "validate_schema",
+]

+ 39 - 0
deployment/app/core/connectors/secrets.py

@@ -0,0 +1,39 @@
+"""Deployable, fail-closed connector secret reference resolution."""
+
+from __future__ import annotations
+
+import os
+import re
+from collections.abc import Mapping
+
+from app.core.connectors.errors import ConnectorConfigurationError
+
+ENV_REFERENCE = re.compile(r"^env:(DATAOPS_CONNECTOR_[A-Z0-9_]{1,200})$")
+
+
+class EnvironmentSecretResolver:
+    """Resolve only the dedicated DATAOPS_CONNECTOR_* environment namespace."""
+
+    def __init__(self, environ: Mapping[str, str] | None = None):
+        self.environ = os.environ if environ is None else environ
+
+    def __call__(self, reference: str) -> str:
+        value = str(reference or "")
+        match = ENV_REFERENCE.fullmatch(value)
+        if match:
+            secret = self.environ.get(match.group(1))
+            if not isinstance(secret, str) or not secret.strip():
+                raise ConnectorConfigurationError(
+                    "connector environment secret is unavailable"
+                )
+            return secret
+        if value.startswith(("vault:", "secret:")):
+            raise ConnectorConfigurationError(
+                "connector secret backend is not configured"
+            )
+        raise ConnectorConfigurationError(
+            "connector environment secret reference is not allowed"
+        )
+
+
+__all__ = ["EnvironmentSecretResolver", "ENV_REFERENCE"]

+ 16 - 0
deployment/app/core/connectors/store.py

@@ -0,0 +1,16 @@
+"""Explicit persistence outcomes for connector state CAS operations."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True)
+class UpdateOutcome:
+    record: Mapping[str, Any]
+    acquired: bool
+
+
+__all__ = ["UpdateOutcome"]

+ 7 - 1
deployment/app/core/data_source/adapters/__init__.py

@@ -3,7 +3,9 @@
 from pathlib import Path
 
 from app.core.data_source.adapters.mysql import MySQLAdapter
+from app.core.data_source.adapters.oracle import OracleAdapter
 from app.core.data_source.adapters.postgresql import PostgreSQLAdapter
+from app.core.data_source.adapters.sqlserver import SqlServerAdapter
 from app.core.data_source.errors import DataSourceTypeUnsupported
 
 
@@ -16,7 +18,11 @@ def adapter_for(database_type: str, certificate_dir=None):
         return PostgreSQLAdapter(**options)
     if normalized == "mysql":
         return MySQLAdapter(**options)
+    if normalized == "oracle":
+        return OracleAdapter(**options)
+    if normalized in {"sqlserver", "mssql"}:
+        return SqlServerAdapter(**options)
     raise DataSourceTypeUnsupported()
 
 
-__all__ = ["adapter_for", "PostgreSQLAdapter", "MySQLAdapter"]
+__all__ = ["adapter_for", "PostgreSQLAdapter", "MySQLAdapter", "OracleAdapter", "SqlServerAdapter"]

+ 31 - 4
deployment/app/core/data_source/adapters/base.py

@@ -11,7 +11,6 @@ from app.core.data_source.errors import (
     DataSourceConnectionFailed,
 )
 
-
 READ_ONLY_PURPOSES = {
     "metadata_collection",
     "metadata_preview",
@@ -92,6 +91,16 @@ class BaseDataSourceAdapter:
             database=definition.database,
         )
 
+    def build_url_for_environment(
+        self,
+        definition,
+        credential,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
+        return self.build_url(definition, credential)
+
     def connect_args(self, definition, credential, query_timeout):
         raise NotImplementedError
 
@@ -114,7 +123,12 @@ class BaseDataSourceAdapter:
         query_timeout,
     ):
         engine = create_engine(
-            self.build_url(definition, credential),
+            self.build_url_for_environment(
+                definition,
+                credential,
+                trusted_environment="production",
+                allow_insecure_development=False,
+            ),
             poolclass=NullPool,
             connect_args=self.connect_args(
                 definition,
@@ -130,9 +144,22 @@ class BaseDataSourceAdapter:
         finally:
             engine.dispose()
 
-    def create_pooled_engine(self, definition, credential, settings):
+    def create_pooled_engine(
+        self,
+        definition,
+        credential,
+        settings,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
         return create_engine(
-            self.build_url(definition, credential),
+            self.build_url_for_environment(
+                definition,
+                credential,
+                trusted_environment=trusted_environment,
+                allow_insecure_development=allow_insecure_development,
+            ),
             pool_size=int(settings["pool_size"]),
             max_overflow=int(settings["max_overflow"]),
             pool_timeout=int(settings["pool_timeout"]),

+ 25 - 0
deployment/app/core/data_source/adapters/oracle.py

@@ -0,0 +1,25 @@
+"""Oracle adapter; the optional python-oracledb driver is checked lazily."""
+
+import importlib.util
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+from app.core.data_source.errors import DataSourceDriverUnavailable
+
+
+class OracleAdapter(BaseDataSourceAdapter):
+    database_type = "oracle"
+    drivername = "oracle+oracledb"
+    allowed_tls_options = frozenset()
+
+    def build_url(self, definition, credential):
+        if importlib.util.find_spec("oracledb") is None:
+            raise DataSourceDriverUnavailable()
+        return super().build_url(definition, credential)
+
+    def connect_args(self, definition, credential, query_timeout):
+        return {"tcp_connect_timeout": 5}
+
+    def configure_transaction(self, connection, purpose):
+        super().configure_transaction(connection, purpose)
+        if purpose != "dataflow_write":
+            connection.exec_driver_sql("SET TRANSACTION READ ONLY")

+ 92 - 0
deployment/app/core/data_source/adapters/sqlserver.py

@@ -0,0 +1,92 @@
+"""SQL Server adapter; the optional pyodbc driver is checked lazily."""
+
+import importlib.util
+import logging
+
+from app.core.data_source.adapters.base import BaseDataSourceAdapter
+from app.core.data_source.errors import DataSourceDriverUnavailable
+
+
+class SqlServerAdapter(BaseDataSourceAdapter):
+    database_type = "sqlserver"
+    drivername = "mssql+pyodbc"
+    allowed_tls_options = frozenset(
+        {
+            "Encrypt",
+            "TrustServerCertificate",
+            "ServerCertificate",
+        }
+    )
+    certificate_options = frozenset({"ServerCertificate"})
+
+    def build_url(self, definition, credential):
+        return self.build_url_for_environment(
+            definition,
+            credential,
+            trusted_environment="production",
+            allow_insecure_development=False,
+        )
+
+    def build_url_for_environment(
+        self,
+        definition,
+        credential,
+        *,
+        trusted_environment="production",
+        allow_insecure_development=False,
+    ):
+        if importlib.util.find_spec("pyodbc") is None:
+            raise DataSourceDriverUnavailable()
+        options = self.validate_options(definition.tls_options)
+        environment = str(trusted_environment)
+        if environment not in {"development", "staging", "production"}:
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "trusted SQL Server environment is invalid"
+            )
+        allow_insecure = bool(allow_insecure_development)
+        encrypt = options.setdefault("Encrypt", "yes")
+        trust = options.setdefault("TrustServerCertificate", "no")
+        if environment in {"staging", "production"} and (
+            encrypt != "yes" or trust != "no"
+        ):
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "SQL Server staging/production requires certificate-verified TLS"
+            )
+        if (encrypt != "yes" or trust != "no") and not (
+            environment == "development" and allow_insecure
+        ):
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid(
+                "SQL Server insecure TLS requires explicit development policy"
+            )
+        if allow_insecure and (encrypt != "yes" or trust != "no"):
+            logging.getLogger(__name__).warning(
+                "SQL Server approved insecure development TLS policy enabled"
+            )
+        query = {"driver": "ODBC Driver 18 for SQL Server", **options}
+        return super().build_url(definition, credential).update_query_dict(query)
+
+    def _validate_scalar_option(self, key, value):
+        normalized = str(value).strip().lower()
+        if normalized not in {"yes", "no"}:
+            from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+            raise DataSourceConfigurationInvalid("SQL Server TLS option is invalid")
+        return normalized
+
+    def connect_args(self, definition, credential, query_timeout):
+        return {"timeout": min(int(query_timeout), 30)}
+
+    def configure_transaction(self, connection, purpose):
+        from app.core.data_source.adapters.base import PURPOSES, READ_ONLY_PURPOSES
+        from app.core.data_source.errors import DataSourceConfigurationInvalid
+
+        if purpose not in PURPOSES:
+            raise DataSourceConfigurationInvalid("unsupported data source purpose")
+        if purpose in READ_ONLY_PURPOSES:
+            connection.exec_driver_sql("SET TRANSACTION ISOLATION LEVEL SNAPSHOT")

+ 6 - 0
deployment/app/core/data_source/errors.py

@@ -40,6 +40,12 @@ class DataSourceConnectionFailed(DataSourceError):
     default_message = "data source connection failed"
 
 
+class DataSourceDriverUnavailable(DataSourceError):
+    code = "DATASOURCE_DRIVER_UNAVAILABLE"
+    http_status = 503
+    default_message = "optional data source driver is unavailable"
+
+
 class DataSourcePoolTimeout(DataSourceError):
     code = "DATASOURCE_POOL_TIMEOUT"
     http_status = 503

+ 32 - 5
deployment/app/core/data_source/manager.py

@@ -1,7 +1,8 @@
 """Public context-managed access to external business data sources."""
 
+import inspect
 import time
-from contextlib import contextmanager
+from contextlib import contextmanager, suppress
 
 from sqlalchemy.exc import DBAPIError, OperationalError, TimeoutError
 
@@ -83,11 +84,22 @@ class DataSourceConnectionManager:
         self._clock = clock or time.monotonic
 
     @contextmanager
-    def connect(self, data_source_uid, purpose):
+    def connect(
+        self,
+        data_source_uid,
+        purpose,
+        *,
+        environment="production",
+        allow_insecure_development=False,
+    ):
         if purpose not in PURPOSES or purpose == "connection_test":
             raise DataSourceConfigurationInvalid(
                 "unsupported pooled connection purpose"
             )
+        if environment not in {"development", "staging", "production"}:
+            raise DataSourceConfigurationInvalid(
+                "trusted connector environment is invalid"
+            )
         definition = self.definitions.get(data_source_uid)
         if not definition:
             raise DataSourceNotFound()
@@ -103,6 +115,22 @@ class DataSourceConnectionManager:
             credential_version=int(definition.credential_version),
             config_fingerprint=definition.connection_fingerprint(),
         )
+        create_parameters = inspect.signature(adapter.create_pooled_engine).parameters
+        supports_security_context = (
+            "trusted_environment" in create_parameters
+            or any(
+                item.kind is inspect.Parameter.VAR_KEYWORD
+                for item in create_parameters.values()
+            )
+        )
+        security_context = (
+            {
+                "trusted_environment": environment,
+                "allow_insecure_development": bool(allow_insecure_development),
+            }
+            if supports_security_context
+            else {}
+        )
 
         with self.registry.lease(
             key,
@@ -110,6 +138,7 @@ class DataSourceConnectionManager:
                 definition,
                 credential,
                 settings,
+                **security_context,
             ),
         ) as engine:
             breaker = self.registry.breaker_for(key)
@@ -148,10 +177,8 @@ class DataSourceConnectionManager:
                     else:
                         transaction.rollback()
                 except Exception:
-                    try:
+                    with suppress(Exception):
                         transaction.rollback()
-                    except Exception:
-                        pass
                     raise
 
     def invalidate(self, data_source_uid, reason):

+ 4 - 1
deployment/app/core/data_source/service.py

@@ -19,6 +19,9 @@ TYPE_ALIASES = {
     "postgres": "postgresql",
     "postgresql": "postgresql",
     "mysql": "mysql",
+    "oracle": "oracle",
+    "sqlserver": "sqlserver",
+    "mssql": "sqlserver",
 }
 POOL_INVALIDATION_REASONS = {
     "admin_reset",
@@ -53,7 +56,7 @@ class DataSourceService:
         database_type = TYPE_ALIASES.get(requested)
         if database_type is None:
             raise DataSourceConfigurationInvalid(
-                "only PostgreSQL and MySQL data sources are supported"
+                "only registered PostgreSQL, MySQL, Oracle and SQL Server data sources are supported"
             )
         return database_type
 

+ 26 - 0
deployment/app/core/system/permissions.py

@@ -73,6 +73,9 @@ SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
 IDENTITY_READ = "identity:read"
 IDENTITY_MANAGE = "identity:manage"
 IDENTITY_OPERATE = "identity:operate"
+CONNECTORS_READ = "connectors:read"
+CONNECTORS_OPERATE = "connectors:operate"
+CONNECTORS_MANAGE = "connectors:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -88,6 +91,7 @@ ROLE_PERMISSIONS = {
             AGENTS_READ,
             SECURITY_GOVERNANCE_READ,
             IDENTITY_READ,
+            CONNECTORS_READ,
         }
     ),
     "editor": frozenset(
@@ -127,6 +131,8 @@ ROLE_PERMISSIONS = {
             SECURITY_GOVERNANCE_OPERATE,
             IDENTITY_READ,
             IDENTITY_OPERATE,
+            CONNECTORS_READ,
+            CONNECTORS_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -197,6 +203,9 @@ ROLE_PERMISSIONS = {
             IDENTITY_READ,
             IDENTITY_OPERATE,
             IDENTITY_MANAGE,
+            CONNECTORS_READ,
+            CONNECTORS_OPERATE,
+            CONNECTORS_MANAGE,
         }
     ),
 }
@@ -215,9 +224,16 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         ("/api/system/identity/callback", "GET"),
         ("/api/system/identity/exchange", "POST"),
         ("/api/system/identity/refresh", "POST"),
+        ("/api/datasource/connectors/machine/runs", "POST"),
     }
     if (path, method) in public_methods:
         return (PUBLIC,)
+    if (
+        path.startswith("/api/datasource/connectors/machine/runs/")
+        and path.rsplit("/", 1)[-1] in {"cancel", "resume"}
+        and method == "POST"
+    ):
+        return (PUBLIC,)
     if path.startswith("/api/system/identity"):
         if path == "/api/system/identity/logout" and method == "POST":
             return (IDENTITY_READ,)
@@ -230,6 +246,16 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if any(marker in path for marker in ("/idp-versions", "/emergency/")):
             return (IDENTITY_MANAGE,)
         return (IDENTITY_OPERATE,)
+    if path.startswith("/api/datasource/connectors"):
+        if "/source-bindings" in path:
+            return (CONNECTORS_MANAGE,)
+        if method == "GET":
+            return (CONNECTORS_READ,)
+        if any(marker in path for marker in ("/principals", "/credentials/")):
+            return (CONNECTORS_MANAGE,)
+        return (CONNECTORS_OPERATE,)
+    if path == "/api/datasource/graph":
+        return (CONNECTORS_READ,)
     if path.startswith("/api/system/responsibilities/"):
         if method == "GET":
             return (RESPONSIBILITIES_READ,)

+ 10 - 0
deployment/app/models/__init__.py

@@ -11,6 +11,12 @@ from app.models.active_metadata import (
     ActiveMetadataPlan,
     ActiveMetadataRun,
 )
+from app.models.connectors import (
+    ConnectorManifestRecord,
+    ConnectorPrincipal,
+    ConnectorRun,
+    ConnectorSourceBinding,
+)
 from app.models.data_product import DataOrder, DataProduct
 from app.models.data_research import (
     CandidateDecisionRecord,
@@ -55,6 +61,10 @@ __all__ = [
     "ActiveMetadataCorrectionAudit",
     "DataOrder",
     "DataProduct",
+    "ConnectorManifestRecord",
+    "ConnectorPrincipal",
+    "ConnectorRun",
+    "ConnectorSourceBinding",
     "MetadataReviewRecord",
     "MetadataVersionHistory",
     "SemanticAsset",

+ 83 - 0
deployment/app/models/connectors.py

@@ -0,0 +1,83 @@
+"""Read models for the enterprise connector control plane."""
+
+from sqlalchemy.dialects.postgresql import ARRAY, JSONB, UUID
+
+from app import db
+from app.core.common.identifiers import new_governance_uid
+
+
+class ConnectorManifestRecord(db.Model):
+    __tablename__ = "connector_manifests"
+    __table_args__ = (
+        db.UniqueConstraint("connector_id", "connector_version"),
+        {"schema": "public"},
+    )
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    sdk_version = db.Column(db.String(20), nullable=False)
+    display_name = db.Column(db.String(200), nullable=False)
+    capabilities = db.Column(JSONB, nullable=False)
+    config_schema = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+
+
+class ConnectorPrincipal(db.Model):
+    __tablename__ = "connector_principals"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    environment = db.Column(db.String(20), nullable=False)
+    allowed_operations = db.Column(ARRAY(db.Text), nullable=False)
+    allowed_scopes = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    source_binding_uid = db.Column(UUID(as_uuid=False))
+    source_binding_version = db.Column(db.Integer)
+
+
+class ConnectorSourceBinding(db.Model):
+    __tablename__ = "connector_source_bindings"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    binding_version = db.Column(db.Integer, primary_key=True)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    business_domain_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    environment = db.Column(db.String(20), nullable=False)
+    approved_base_url = db.Column(db.String(2048))
+    allowed_host = db.Column(db.String(253))
+    credential_ref = db.Column(db.String(300))
+    approved_config = db.Column(JSONB, nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    approved_by = db.Column(UUID(as_uuid=False), nullable=False)
+
+
+class ConnectorRun(db.Model):
+    __tablename__ = "connector_runs"
+    __table_args__ = {"schema": "public"}
+    uid = db.Column(UUID(as_uuid=False), primary_key=True, default=new_governance_uid)
+    idempotency_key = db.Column(db.String(64), nullable=False, unique=True)
+    connector_id = db.Column(db.String(64), nullable=False)
+    connector_version = db.Column(db.String(40), nullable=False)
+    source_uid = db.Column(UUID(as_uuid=False), nullable=False)
+    principal_uid = db.Column(UUID(as_uuid=False))
+    business_domain_uid = db.Column(UUID(as_uuid=False))
+    environment = db.Column(db.String(20))
+    process_key = db.Column(db.String(300))
+    operation = db.Column(db.String(30), nullable=False)
+    status = db.Column(db.String(20), nullable=False)
+    attempt_count = db.Column(db.Integer, nullable=False)
+    checkpoint = db.Column(JSONB, nullable=False)
+    cursor = db.Column(JSONB, nullable=False)
+    safe_config = db.Column(JSONB, nullable=False)
+    scope = db.Column(JSONB, nullable=False)
+    request_hash = db.Column(db.String(64), nullable=False)
+    client_hint_hash = db.Column(db.String(64))
+    source_binding_uid = db.Column(UUID(as_uuid=False))
+    source_binding_version = db.Column(db.Integer)
+    attempt_lease_token = db.Column(UUID(as_uuid=False))
+    cancel_requested = db.Column(db.Boolean, nullable=False, default=False)

+ 114 - 0
deployment/migrations/versions/20260802_472_enterprise_connectors.py

@@ -0,0 +1,114 @@
+"""Enterprise connector SDK, machine identity, execution and graph ledger."""
+
+from alembic import op
+
+revision = "20260802_472"
+down_revision = "20260802_471"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    CREATE TABLE public.connector_manifests (
+      uid UUID PRIMARY KEY, connector_id VARCHAR(64) NOT NULL, connector_version VARCHAR(40) NOT NULL,
+      sdk_version VARCHAR(20) NOT NULL, display_name VARCHAR(200) NOT NULL,
+      capabilities JSONB NOT NULL, config_schema JSONB NOT NULL, status VARCHAR(20) NOT NULL,
+      created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_manifest_version UNIQUE (connector_id, connector_version),
+      CONSTRAINT ck_connector_manifest_status CHECK (status IN ('active','retired')),
+      CONSTRAINT ck_connector_manifest_capabilities_array CHECK (jsonb_typeof(capabilities)='array'),
+      CONSTRAINT ck_connector_manifest_schema_object CHECK (jsonb_typeof(config_schema)='object')
+    );
+    CREATE TABLE public.connector_principals (
+      uid UUID PRIMARY KEY, connector_id VARCHAR(64) NOT NULL, source_uid UUID NOT NULL,
+      business_domain_uid UUID NOT NULL, environment VARCHAR(20) NOT NULL,
+      allowed_operations TEXT[] NOT NULL, allowed_scopes JSONB NOT NULL,
+      status VARCHAR(20) NOT NULL, created_by UUID NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMPTZ,
+      CONSTRAINT uq_connector_principal_binding UNIQUE (connector_id, source_uid, business_domain_uid, environment),
+      CONSTRAINT ck_connector_principal_environment CHECK (environment IN ('development','staging','production')),
+      CONSTRAINT ck_connector_principal_status CHECK (status IN ('active','revoked')),
+      CONSTRAINT ck_connector_principal_scope CHECK (jsonb_typeof(allowed_scopes)='object')
+    );
+    CREATE TABLE public.connector_machine_credentials (
+      uid UUID PRIMARY KEY, principal_uid UUID NOT NULL REFERENCES public.connector_principals(uid),
+      token_hash CHAR(64) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL,
+      issued_by UUID NOT NULL, issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      expires_at TIMESTAMPTZ NOT NULL, first_used_at TIMESTAMPTZ, use_count INTEGER NOT NULL DEFAULT 0,
+      revoked_at TIMESTAMPTZ, rotated_from_uid UUID REFERENCES public.connector_machine_credentials(uid),
+      CONSTRAINT ck_connector_credential_status CHECK (status IN ('active','revoked','rotated','expired','replayed')),
+      CONSTRAINT ck_connector_credential_ttl CHECK (expires_at <= issued_at + INTERVAL '15 minutes'),
+      CONSTRAINT ck_connector_credential_use_count CHECK (use_count >= 0)
+    );
+    CREATE INDEX ix_connector_credential_principal_status ON public.connector_machine_credentials(principal_uid,status,expires_at);
+    CREATE TABLE public.connector_runs (
+      uid UUID PRIMARY KEY, idempotency_key CHAR(64) NOT NULL UNIQUE,
+      connector_id VARCHAR(64) NOT NULL, connector_version VARCHAR(40) NOT NULL,
+      source_uid UUID NOT NULL, operation VARCHAR(30) NOT NULL, status VARCHAR(20) NOT NULL,
+      attempt_count INTEGER NOT NULL DEFAULT 0, checkpoint JSONB NOT NULL DEFAULT '{}'::jsonb,
+      cursor JSONB NOT NULL DEFAULT '{}'::jsonb, error_category VARCHAR(30), error_code VARCHAR(80),
+      resumed_from_run_uid UUID REFERENCES public.connector_runs(uid), dry_run BOOLEAN NOT NULL DEFAULT FALSE,
+      actor_uid UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT fk_connector_run_manifest FOREIGN KEY (connector_id,connector_version)
+        REFERENCES public.connector_manifests(connector_id,connector_version),
+      CONSTRAINT ck_connector_run_operation CHECK (operation IN ('discover','snapshot','incremental','lineage','profile','cancel','resume','evidence')),
+      CONSTRAINT ck_connector_run_status CHECK (status IN ('running','succeeded','dry_run','failed','cancelled','resumable')),
+      CONSTRAINT ck_connector_run_attempt CHECK (attempt_count BETWEEN 0 AND 5)
+    );
+    CREATE INDEX ix_connector_runs_source_created ON public.connector_runs(source_uid,created_at DESC);
+    CREATE TABLE public.connector_run_attempts (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      attempt_number INTEGER NOT NULL, status VARCHAR(20) NOT NULL, error_category VARCHAR(30),
+      started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TIMESTAMPTZ,
+      CONSTRAINT uq_connector_run_attempt UNIQUE(run_uid,attempt_number)
+    );
+    CREATE TABLE public.connector_checkpoints (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      sequence_number INTEGER NOT NULL, cursor JSONB NOT NULL, checkpoint JSONB NOT NULL,
+      content_hash CHAR(64) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_checkpoint_sequence UNIQUE(run_uid,sequence_number)
+    );
+    CREATE TABLE public.connector_evidence (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      evidence_type VARCHAR(40) NOT NULL, payload JSONB NOT NULL, content_hash CHAR(64) NOT NULL,
+      byte_size INTEGER NOT NULL, redacted BOOLEAN NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT ck_connector_evidence_size CHECK (byte_size BETWEEN 0 AND 32768)
+    );
+    CREATE TABLE public.connector_graph_edges (
+      uid UUID PRIMARY KEY, source_uid UUID NOT NULL, from_type VARCHAR(30) NOT NULL,
+      from_key VARCHAR(500) NOT NULL, relation_type VARCHAR(40) NOT NULL,
+      to_type VARCHAR(30) NOT NULL, to_key VARCHAR(500) NOT NULL,
+      business_domain_uid UUID, run_uid UUID REFERENCES public.connector_runs(uid),
+      run_status VARCHAR(20), evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_graph_edge UNIQUE(source_uid,from_type,from_key,relation_type,to_type,to_key),
+      CONSTRAINT ck_connector_graph_node_types CHECK (from_type IN ('source','asset','process','business_domain','run') AND to_type IN ('source','asset','process','business_domain','run'))
+    );
+    CREATE INDEX ix_connector_graph_source ON public.connector_graph_edges(source_uid,relation_type);
+    CREATE INDEX ix_connector_graph_to ON public.connector_graph_edges(to_type,to_key);
+    CREATE TABLE public.connector_audit_events (
+      uid UUID PRIMARY KEY, principal_uid UUID REFERENCES public.connector_principals(uid),
+      credential_uid UUID REFERENCES public.connector_machine_credentials(uid), event_type VARCHAR(80) NOT NULL,
+      actor_uid UUID, success BOOLEAN NOT NULL, safe_detail VARCHAR(500) NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE INDEX ix_connector_audit_principal_created ON public.connector_audit_events(principal_uid,created_at DESC);
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DROP TABLE IF EXISTS public.connector_audit_events;
+    DROP TABLE IF EXISTS public.connector_graph_edges;
+    DROP TABLE IF EXISTS public.connector_evidence;
+    DROP TABLE IF EXISTS public.connector_checkpoints;
+    DROP TABLE IF EXISTS public.connector_run_attempts;
+    DROP TABLE IF EXISTS public.connector_runs;
+    DROP TABLE IF EXISTS public.connector_machine_credentials;
+    DROP TABLE IF EXISTS public.connector_principals;
+    DROP TABLE IF EXISTS public.connector_manifests;
+    """)

+ 92 - 0
deployment/migrations/versions/20260802_473_connector_runtime_hardening.py

@@ -0,0 +1,92 @@
+"""Harden connector identity bindings and persistent runtime state."""
+
+from alembic import op
+
+revision = "20260802_473"
+down_revision = "20260802_472"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals p
+          JOIN (
+            SELECT connector_id
+              FROM public.connector_manifests
+             WHERE status='active'
+             GROUP BY connector_id
+            HAVING COUNT(DISTINCT connector_version)>1
+          ) ambiguous USING(connector_id)
+      ) THEN
+        RAISE EXCEPTION
+          'connector 473 upgrade rejected before backfill: multiple active versions require an explicit one-active-version mapping';
+      END IF;
+    END $$;
+    ALTER TABLE public.connector_principals ADD COLUMN connector_version VARCHAR(40);
+    UPDATE public.connector_principals p SET connector_version=(
+      SELECT m.connector_version FROM public.connector_manifests m
+      WHERE m.connector_id=p.connector_id AND m.status='active'
+      ORDER BY m.created_at DESC LIMIT 1
+    );
+    DO $$ BEGIN
+      IF EXISTS (SELECT 1 FROM public.connector_principals WHERE connector_version IS NULL) THEN
+        RAISE EXCEPTION 'connector principal version cannot be backfilled safely';
+      END IF;
+    END $$;
+    ALTER TABLE public.connector_principals ALTER COLUMN connector_version SET NOT NULL;
+    ALTER TABLE public.connector_principals
+      ADD CONSTRAINT fk_connector_principal_manifest FOREIGN KEY(connector_id,connector_version)
+      REFERENCES public.connector_manifests(connector_id,connector_version);
+    ALTER TABLE public.connector_principals DROP CONSTRAINT uq_connector_principal_binding;
+    ALTER TABLE public.connector_principals ADD CONSTRAINT uq_connector_principal_binding_v2
+      UNIQUE(connector_id,connector_version,source_uid,business_domain_uid,environment);
+
+    ALTER TABLE public.connector_runs
+      ADD COLUMN principal_uid UUID REFERENCES public.connector_principals(uid),
+      ADD COLUMN business_domain_uid UUID,
+      ADD COLUMN environment VARCHAR(20),
+      ADD COLUMN process_key VARCHAR(300),
+      ADD COLUMN safe_config JSONB NOT NULL DEFAULT '{}'::jsonb,
+      ADD COLUMN scope JSONB NOT NULL DEFAULT '{}'::jsonb;
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_environment
+      CHECK(environment IS NULL OR environment IN ('development','staging','production'));
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_machine_run_binding
+      CHECK(dry_run OR (principal_uid IS NOT NULL AND business_domain_uid IS NOT NULL AND environment IS NOT NULL AND process_key IS NOT NULL)) NOT VALID;
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_config_object CHECK(jsonb_typeof(safe_config)='object');
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_scope_object CHECK(jsonb_typeof(scope)='object');
+
+    CREATE TABLE public.connector_rate_limits(
+      limit_key VARCHAR(300) NOT NULL,
+      window_started_at TIMESTAMPTZ NOT NULL,
+      request_count INTEGER NOT NULL,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY(limit_key,window_started_at),
+      CONSTRAINT ck_connector_rate_count CHECK(request_count BETWEEN 1 AND 30)
+    );
+    CREATE INDEX ix_connector_rate_limits_updated ON public.connector_rate_limits(updated_at);
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DROP TABLE IF EXISTS public.connector_rate_limits;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_scope_object;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_config_object;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_machine_run_binding;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_environment;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS scope;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS safe_config;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS process_key;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS environment;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS business_domain_uid;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS principal_uid;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS uq_connector_principal_binding_v2;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS fk_connector_principal_manifest;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS connector_version;
+    ALTER TABLE public.connector_principals ADD CONSTRAINT uq_connector_principal_binding
+      UNIQUE(connector_id,source_uid,business_domain_uid,environment);
+    """)

+ 70 - 0
deployment/migrations/versions/20260802_474_connector_version_guardrails.py

@@ -0,0 +1,70 @@
+"""Continuously reject ambiguous active connector versions."""
+
+from alembic import op
+
+revision = "20260802_474"
+down_revision = "20260802_473"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals p
+          JOIN (
+            SELECT connector_id
+              FROM public.connector_manifests
+             WHERE status='active'
+             GROUP BY connector_id
+            HAVING COUNT(DISTINCT connector_version)>1
+          ) ambiguous USING(connector_id)
+      ) THEN
+        RAISE EXCEPTION
+          'connector 474 upgrade rejected: principal has multiple active manifest versions; provide an explicit principal-to-version mapping';
+      END IF;
+    END $$;
+    CREATE OR REPLACE FUNCTION public.enforce_connector_active_version()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    DECLARE target_connector VARCHAR(64);
+    BEGIN
+      target_connector := COALESCE(NEW.connector_id, OLD.connector_id);
+      IF EXISTS (SELECT 1 FROM public.connector_principals WHERE connector_id=target_connector)
+         AND (SELECT COUNT(DISTINCT connector_version)
+                FROM public.connector_manifests
+               WHERE connector_id=target_connector AND status='active') > 1 THEN
+        RAISE EXCEPTION
+          'multiple active connector versions require an explicit one-active-version mapping';
+      END IF;
+      RETURN COALESCE(NEW, OLD);
+    END $$;
+    CREATE TRIGGER trg_connector_manifest_active_version
+      AFTER INSERT OR UPDATE OF connector_id,connector_version,status
+      ON public.connector_manifests
+      FOR EACH ROW EXECUTE FUNCTION public.enforce_connector_active_version();
+    CREATE TRIGGER trg_connector_principal_active_version
+      AFTER INSERT OR UPDATE OF connector_id,connector_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.enforce_connector_active_version();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals
+         GROUP BY connector_id,source_uid,business_domain_uid,environment
+        HAVING COUNT(DISTINCT connector_version)>1
+      ) THEN
+        RAISE EXCEPTION
+          'connector downgrade below 474 rejected: multiple principal versions share one legacy binding; consolidate explicitly before downgrade';
+      END IF;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_active_version ON public.connector_principals;
+    DROP TRIGGER IF EXISTS trg_connector_manifest_active_version ON public.connector_manifests;
+    DROP FUNCTION IF EXISTS public.enforce_connector_active_version();
+    """)

+ 145 - 0
deployment/migrations/versions/20260802_475_connector_security_bindings.py

@@ -0,0 +1,145 @@
+"""Bind connector destinations, idempotency requests and attempt leases."""
+
+from alembic import op
+
+revision = "20260802_475"
+down_revision = "20260802_474"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    CREATE TABLE public.connector_source_bindings (
+      uid UUID NOT NULL,
+      binding_version INTEGER NOT NULL,
+      connector_id VARCHAR(64) NOT NULL,
+      connector_version VARCHAR(40) NOT NULL,
+      source_uid UUID NOT NULL,
+      business_domain_uid UUID NOT NULL,
+      environment VARCHAR(20) NOT NULL,
+      approved_base_url VARCHAR(2048),
+      allowed_host VARCHAR(253),
+      credential_ref VARCHAR(300),
+      approved_config JSONB NOT NULL DEFAULT '{}'::jsonb,
+      status VARCHAR(20) NOT NULL,
+      approved_by UUID NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY(uid,binding_version),
+      CONSTRAINT fk_connector_source_binding_manifest
+        FOREIGN KEY(connector_id,connector_version)
+        REFERENCES public.connector_manifests(connector_id,connector_version),
+      CONSTRAINT ck_connector_source_binding_environment
+        CHECK(environment IN ('development','staging','production')),
+      CONSTRAINT ck_connector_source_binding_status
+        CHECK(status IN ('approved','revoked')),
+      CONSTRAINT ck_connector_source_binding_config
+        CHECK(jsonb_typeof(approved_config)='object'),
+      CONSTRAINT ck_connector_source_binding_rest_destination CHECK(
+        connector_id<>'rest-catalog' OR
+        (approved_base_url ~ '^https://[^/?#]+(?:/[^?#]*)?$'
+         AND allowed_host IS NOT NULL AND credential_ref IS NOT NULL)
+      )
+    );
+    CREATE UNIQUE INDEX uq_connector_source_binding_active
+      ON public.connector_source_bindings(
+        connector_id,connector_version,source_uid,business_domain_uid,environment
+      ) WHERE status='approved';
+    CREATE INDEX ix_connector_source_binding_lookup
+      ON public.connector_source_bindings(source_uid,business_domain_uid,environment,status);
+
+    ALTER TABLE public.connector_principals
+      ADD COLUMN source_binding_uid UUID,
+      ADD COLUMN source_binding_version INTEGER;
+    ALTER TABLE public.connector_principals
+      ADD CONSTRAINT fk_connector_principal_source_binding
+      FOREIGN KEY(source_binding_uid,source_binding_version)
+      REFERENCES public.connector_source_bindings(uid,binding_version);
+
+    ALTER TABLE public.connector_runs
+      ADD COLUMN request_hash CHAR(64),
+      ADD COLUMN client_hint_hash CHAR(64),
+      ADD COLUMN source_binding_uid UUID,
+      ADD COLUMN source_binding_version INTEGER,
+      ADD COLUMN attempt_lease_token UUID,
+      ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
+    ALTER TABLE public.connector_runs
+      ADD CONSTRAINT ck_connector_run_request_hash
+      CHECK(request_hash IS NOT NULL) NOT VALID;
+    ALTER TABLE public.connector_runs
+      ADD CONSTRAINT fk_connector_run_source_binding
+      FOREIGN KEY(source_binding_uid,source_binding_version)
+      REFERENCES public.connector_source_bindings(uid,binding_version);
+    CREATE INDEX ix_connector_runs_request_hash ON public.connector_runs(request_hash);
+    CREATE UNIQUE INDEX uq_connector_runs_client_hint
+      ON public.connector_runs(client_hint_hash) WHERE client_hint_hash IS NOT NULL;
+    CREATE INDEX ix_connector_runs_binding
+      ON public.connector_runs(source_binding_uid,source_binding_version,created_at DESC);
+
+    ALTER TABLE public.connector_run_attempts ADD COLUMN lease_token UUID;
+    CREATE UNIQUE INDEX uq_connector_attempt_lease
+      ON public.connector_run_attempts(run_uid,lease_token) WHERE lease_token IS NOT NULL;
+
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        IF NEW.connector_id='rest-catalog' THEN
+          RAISE EXCEPTION 'REST connector principal requires an approved source binding';
+        END IF;
+        RETURN NEW;
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE OF connector_id,connector_version,source_uid,
+        business_domain_uid,environment,source_binding_uid,source_binding_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (SELECT 1 FROM public.connector_source_bindings)
+         OR EXISTS (SELECT 1 FROM public.connector_principals WHERE source_binding_uid IS NOT NULL)
+         OR EXISTS (SELECT 1 FROM public.connector_runs WHERE source_binding_uid IS NOT NULL) THEN
+        RAISE EXCEPTION
+          'connector downgrade below 475 rejected: revoke and explicitly remove source bindings first';
+      END IF;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding ON public.connector_principals;
+    DROP FUNCTION IF EXISTS public.validate_connector_binding();
+    DROP INDEX IF EXISTS public.uq_connector_attempt_lease;
+    ALTER TABLE public.connector_run_attempts DROP COLUMN IF EXISTS lease_token;
+    DROP INDEX IF EXISTS public.ix_connector_runs_binding;
+    DROP INDEX IF EXISTS public.ix_connector_runs_request_hash;
+    DROP INDEX IF EXISTS public.uq_connector_runs_client_hint;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS fk_connector_run_source_binding;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_request_hash;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS cancel_requested;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS attempt_lease_token;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS source_binding_version;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS source_binding_uid;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS request_hash;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS client_hint_hash;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS fk_connector_principal_source_binding;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS source_binding_version;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS source_binding_uid;
+    DROP TABLE public.connector_source_bindings;
+    """)

+ 85 - 0
deployment/migrations/versions/20260802_476_connector_binding_enforcement.py

@@ -0,0 +1,85 @@
+"""Require approved source bindings for every enterprise connector principal."""
+
+from alembic import op
+
+revision = "20260802_476"
+down_revision = "20260802_475"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1 FROM public.connector_principals
+         WHERE status='active'
+           AND (source_binding_uid IS NULL OR source_binding_version IS NULL)
+      ) THEN
+        RAISE EXCEPTION
+          'connector upgrade to 476 rejected: bind every active enterprise principal first';
+      END IF;
+    END $$;
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        RAISE EXCEPTION 'enterprise connector principal requires an approved source binding';
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding
+      ON public.connector_principals;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        IF NEW.connector_id='rest-catalog' THEN
+          RAISE EXCEPTION 'REST connector principal requires an approved source binding';
+        END IF;
+        RETURN NEW;
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding
+      ON public.connector_principals;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE OF connector_id,connector_version,source_uid,
+        business_domain_uid,environment,source_binding_uid,source_binding_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)

+ 19 - 8
docs/DATAOPS_PHASE3_6_MONTH_DEVELOPMENT_PLAN_20260802.md

@@ -326,14 +326,25 @@ AI、多租户、BI/AI、成本或插件输入。P0 企业、安全、恢复和
 
 **主要工作:**
 
-- [ ] 定义连接器 manifest、能力声明、配置 Schema、秘密引用、健康和版本兼容契约。
-- [ ] 统一 discover、snapshot、incremental、lineage、profile、cancel、resume 和 evidence 接口。
-- [ ] 建设连接器机器身份、最小权限、短期凭证、轮换和审计。
-- [ ] 实现并验收至少两个新增连接器;优先从 Oracle/SQL Server/国产库和 ERP/MES/API 中选择。
-- [ ] 支持游标、快照差异、幂等、重试、取消、断点续跑、限流和错误分类。
-- [ ] 将来源、资产、流程、业务域和运行状态接入数据源关系图。
-- [ ] 建立 SDK 示例、兼容测试套件、开发指南和失败注入样本。
-- [ ] 用 SDK 示例实现第三个参考连接器,并证明其只依赖公开扩展契约,不修改采集核心。
+- [x] 定义连接器 manifest、能力声明、配置 Schema、秘密引用、健康和版本兼容契约。
+- [x] 统一 discover、snapshot、incremental、lineage、profile、cancel、resume 和 evidence 接口。
+- [x] 建设连接器机器身份、最小权限、短期凭证、轮换和审计。
+- [x] 完成 Oracle 与 SQL Server 工程连接器实现及注入式兼容验证。
+- [ ] 在企业批准账号、版本、样本和网络中完成 Oracle 与 SQL Server 真实来源验收。
+- [x] 支持游标、快照差异、幂等、重试、取消、断点续跑、限流和错误分类。
+- [x] 将来源、资产、流程、业务域和运行状态接入数据源关系图。
+- [x] 建立 SDK 示例、兼容测试套件、开发指南和失败注入样本。
+- [x] 用 SDK 示例实现受控 REST Catalog 参考连接器,并证明其只依赖公开扩展契约。
+- [x] 建立服务端批准 source binding;机器 payload 不接受 config,principal 与 binding 版本强绑定。
+- [x] 建立规范 request hash/client hint 冲突隔离、attempt lease CAS、数据库权威协作取消和人工/机器动作隔离。
+- [x] 对 records/cursor/checkpoint/evidence 做持久化前脱敏与上界,并使运行列表只返回摘要。
+- [x] 修正 capability 声明、REST 多页游标守卫和 SQL Server 默认证书验证策略。
+- [x] 完成 472→473→474→475→476 真实迁移链、结构变更前歧义预检、持续版本守卫、全连接器 binding 强制和受控降级验证。
+
+**当前状态:** `ENGINEERING_COMPLETE_ENTERPRISE_SOURCE_UAT_BLOCKED`。企业侧
+`enterprise_source_1`、`enterprise_source_2`、真实版本/只读账号/样本及 `network`
+尚未提供,`enterprise_confirmation_status` 保持 `TBD_EXTERNAL`,不得视为真实来源 UAT
+或生产就绪。
 
 **主要文件区域:** `app/core/data_source/`、`app/core/meta_data/`、
 `app/api/data_source/`、`app/models/`、`migrations/versions/`、

+ 650 - 10
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 413
+x-route-count: 430
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -2184,6 +2184,478 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/datasource/connectors/config/validate":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_config_validate_post
+      summary: "connector config validate"
+      x-source: "app/api/data_source/routes.py"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorConfigValidationRequest'
+      x-required-permission: "connectors:operate"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorValidationEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/credentials/{credential_uid}/{action}":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_credential_action_post
+      summary: "connector credential action"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: credential_uid
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: action
+          in: path
+          required: true
+          schema:
+            type: string
+            enum: [revoke, rotate]
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorCredentialRequest'
+      x-required-permission: "connectors:manage"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorCredentialEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/machine/runs":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_machine_run_post
+      summary: "Machine-only run boundary; a human bearer token is never accepted here."
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: X-Connector-Credential
+          in: header
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/MachineConnectorRunRequest'
+      x-required-permission: "machine-credential"
+      responses:
+        "201":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorRunResultEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/machine/runs/{idempotency_key}/cancel":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_machine_run_cancel_post
+      summary: "Cancel one bound machine run with a fresh one-time credential."
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: idempotency_key
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: X-Connector-Credential
+          in: header
+          required: true
+          schema:
+            type: string
+      x-required-permission: "machine-credential"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/machine/runs/{idempotency_key}/resume":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_machine_run_resume_post
+      summary: "Resume one bound machine run with a fresh one-time credential."
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: idempotency_key
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: X-Connector-Credential
+          in: header
+          required: true
+          schema:
+            type: string
+      x-required-permission: "machine-credential"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/manifests":
+    get:
+      tags: [data_source]
+      operationId: data_source_connector_manifests_get
+      summary: "connector manifests"
+      x-source: "app/api/data_source/routes.py"
+      x-required-permission: "connectors:read"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorManifestEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/principals":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_principal_create_post
+      summary: "connector principal create"
+      x-source: "app/api/data_source/routes.py"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorPrincipalRequest'
+      x-required-permission: "connectors:manage"
+      responses:
+        "201":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorPrincipalEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/principals/{principal_uid}/credentials":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_credential_issue_post
+      summary: "connector credential issue"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: principal_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorCredentialRequest'
+      x-required-permission: "connectors:manage"
+      responses:
+        "201":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorCredentialEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/runs":
+    get:
+      tags: [data_source]
+      operationId: data_source_connector_runs_list_get
+      summary: "connector runs list"
+      x-source: "app/api/data_source/routes.py"
+      x-required-permission: "connectors:read"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorRunListEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_run_create_post
+      summary: "connector run create"
+      x-source: "app/api/data_source/routes.py"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorRunRequest'
+      x-required-permission: "connectors:operate"
+      responses:
+        "201":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorRunResultEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/runs/{idempotency_key}/cancel":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_run_cancel_post
+      summary: "connector run cancel"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: idempotency_key
+          in: path
+          required: true
+          schema:
+            type: string
+      x-required-permission: "connectors:operate"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorRunRecordEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/runs/{idempotency_key}/resume":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_run_resume_post
+      summary: "connector run resume"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: idempotency_key
+          in: path
+          required: true
+          schema:
+            type: string
+      x-required-permission: "connectors:operate"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorRunResultEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/source-bindings":
+    get:
+      tags: [data_source]
+      operationId: data_source_connector_source_bindings_list_get
+      summary: "connector source bindings list"
+      x-source: "app/api/data_source/routes.py"
+      x-required-permission: "connectors:manage"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_source_binding_approve_post
+      summary: "connector source binding approve"
+      x-source: "app/api/data_source/routes.py"
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorSourceBindingRequest'
+      x-required-permission: "connectors:manage"
+      responses:
+        "201":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorSourceBindingEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/source-bindings/{binding_uid}/revoke":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_source_binding_revoke_post
+      summary: "connector source binding revoke"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: binding_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      x-required-permission: "connectors:manage"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/{connector_id}/{version}/compatibility":
+    get:
+      tags: [data_source]
+      operationId: data_source_connector_compatibility_get
+      summary: "connector compatibility"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: connector_id
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: path
+          required: true
+          schema:
+            type: string
+      x-required-permission: "connectors:read"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorCompatibilityEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
+  "/api/datasource/connectors/{connector_id}/{version}/health":
+    post:
+      tags: [data_source]
+      operationId: data_source_connector_health_post
+      summary: "connector health"
+      x-source: "app/api/data_source/routes.py"
+      parameters:
+        - name: connector_id
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: version
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: true
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/ConnectorHealthRequest'
+      x-required-permission: "connectors:operate"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorHealthEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
   "/api/datasource/conntest":
     post:
       tags: [data_source]
@@ -2243,25 +2715,25 @@ paths:
       summary: "data source graph relationship"
       x-source: "app/api/data_source/routes.py"
       requestBody:
-        required: false
+        required: true
         content:
           application/json:
             schema:
-              type: object
-              additionalProperties: true
+              $ref: '#/components/schemas/ConnectorGraphRequest'
+      x-required-permission: "connectors:read"
       responses:
         "200":
           description: "请求已由当前实现处理"
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/ApiEnvelope'
+                $ref: '#/components/schemas/ConnectorGraphEnvelope'
         default:
           description: "错误响应"
           content:
             application/json:
               schema:
-                $ref: '#/components/schemas/ApiEnvelope'
+                $ref: '#/components/schemas/ConnectorErrorEnvelope'
   "/api/datasource/list":
     post:
       tags: [data_source]
@@ -10771,16 +11243,184 @@ components:
     ApiEnvelope:
       type: object
       additionalProperties: true
+      required: [code, message, data]
       properties:
-        success:
-          type: boolean
         code:
           type: integer
         message:
           type: string
         data: {}
-        timestamp:
-          type: integer
+        error: {type: object}
+    ConnectorConfigValidationRequest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, config]
+      properties:
+        connector_id: {type: string, minLength: 3, maxLength: 64}
+        version: {type: string, pattern: '^[0-9]+\.[0-9]+\.[0-9]+'}
+        config: {type: object}
+    ConnectorRunRequest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, source_uid, operation, config, scope, dry_run]
+      properties:
+        connector_id: {type: string}
+        version: {type: string}
+        source_uid: {type: string, format: uuid}
+        operation: {type: string, enum: [discover, snapshot, incremental, lineage, profile, evidence]}
+        config: {type: object}
+        scope: {$ref: '#/components/schemas/ConnectorScope'}
+        cursor: {type: object}
+        checkpoint: {type: object}
+        idempotency_key: {type: string, pattern: '^[a-f0-9]{64}$'}
+        dry_run: {const: true}
+        process_key: {type: string, minLength: 1, maxLength: 300}
+    MachineConnectorRunRequest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, source_uid, business_domain_uid, environment, process_key, operation, scope]
+      properties:
+        connector_id: {type: string}
+        version: {type: string}
+        source_uid: {type: string, format: uuid}
+        business_domain_uid: {type: string, format: uuid}
+        environment: {type: string, enum: [development, staging, production]}
+        process_key: {type: string, minLength: 1, maxLength: 300}
+        operation: {type: string, enum: [discover, snapshot, incremental, lineage, profile, evidence]}
+        scope: {$ref: '#/components/schemas/ConnectorScope'}
+        cursor: {type: object}
+        checkpoint: {type: object}
+        idempotency_key: {type: string, pattern: '^[a-f0-9]{64}$'}
+        dry_run: {type: boolean, default: false}
+    ConnectorPrincipalRequest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, source_uid, business_domain_uid, environment, operations, scopes]
+      properties:
+        connector_id: {type: string}
+        version: {type: string}
+        source_uid: {type: string, format: uuid}
+        business_domain_uid: {type: string, format: uuid}
+        environment: {type: string, enum: [development, staging, production]}
+        operations: {type: array, minItems: 1, uniqueItems: true, items: {type: string}}
+        scopes: {$ref: '#/components/schemas/ConnectorScope'}
+        source_binding_uid: {type: string, format: uuid}
+        source_binding_version: {type: integer, minimum: 1}
+    ConnectorSourceBindingRequest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, source_uid, business_domain_uid, environment, approved_config]
+      properties:
+        binding_uid: {type: string, format: uuid}
+        connector_id: {type: string}
+        version: {type: string}
+        source_uid: {type: string, format: uuid}
+        business_domain_uid: {type: string, format: uuid}
+        environment: {type: string, enum: [development, staging, production]}
+        approved_config: {type: object}
+    ConnectorScope:
+      type: object
+      additionalProperties: false
+      properties:
+        include_schemas: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}
+        exclude_schemas: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}
+        include_tables: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}
+        exclude_tables: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}
+    ConnectorHealthRequest:
+      type: object
+      additionalProperties: false
+      required: [config]
+      properties: {config: {type: object}}
+    ConnectorCredentialRequest:
+      type: object
+      additionalProperties: false
+      properties: {ttl_seconds: {type: integer, minimum: 60, maximum: 900}}
+    ConnectorGraphRequest:
+      type: object
+      additionalProperties: false
+      properties: {source_uid: {type: string, format: uuid}, business_domain_uid: {type: string, format: uuid}, process_key: {type: string}, run_uid: {type: string, format: uuid}, limit: {type: integer, minimum: 1, maximum: 1000}}
+    ConnectorManifestEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties:
+        code: {type: integer}
+        message: {type: string}
+        data: {type: object, additionalProperties: false, required: [manifests], properties: {manifests: {type: array, items: {$ref: '#/components/schemas/ConnectorManifest'}}}}
+    ConnectorManifest:
+      type: object
+      additionalProperties: false
+      required: [connector_id, version, sdk_version, display_name, capabilities, config_schema]
+      properties: {connector_id: {type: string}, version: {type: string}, sdk_version: {type: string}, display_name: {type: string}, capabilities: {type: array, items: {type: string}}, config_schema: {type: object}}
+    ConnectorOperationResult:
+      type: object
+      additionalProperties: false
+      required: [records, cursor, checkpoint, evidence, status]
+      properties: {records: {type: array, items: {type: object}}, cursor: {type: object}, checkpoint: {type: object}, evidence: {type: object}, status: {type: string, enum: [succeeded, dry_run]}}
+    ConnectorRunResultEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {$ref: '#/components/schemas/ConnectorOperationResult'}}
+    ConnectorRunRecord:
+      type: object
+      additionalProperties: false
+      required: [uid, idempotency_key, connector_id, connector_version, source_uid, operation, status, attempt_count, checkpoint_summary, cursor_summary, dry_run]
+      properties: {uid: {type: string, format: uuid}, idempotency_key: {type: string}, connector_id: {type: string}, connector_version: {type: string}, source_uid: {type: string, format: uuid}, principal_uid: {type: [string, 'null'], format: uuid}, business_domain_uid: {type: [string, 'null'], format: uuid}, environment: {type: [string, 'null']}, process_key: {type: [string, 'null']}, operation: {type: string}, status: {type: string}, attempt_count: {type: integer}, checkpoint_summary: {type: object}, cursor_summary: {type: object}, scope: {type: object}, error_category: {type: [string, 'null']}, dry_run: {type: boolean}, created_at: {type: string}, updated_at: {type: string}}
+    ConnectorRunRecordEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {$ref: '#/components/schemas/ConnectorRunRecord'}}
+    ConnectorRunListEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [runs], properties: {runs: {type: array, items: {$ref: '#/components/schemas/ConnectorRunRecord'}}}}}
+    ConnectorValidationEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, required: [valid, config_keys], properties: {valid: {const: true}, config_keys: {type: array, items: {type: string}}}, additionalProperties: false}}
+    ConnectorErrorEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data, error]
+      properties:
+        code: {type: integer}
+        message: {type: string}
+        data: {type: 'null'}
+        error: {type: object, additionalProperties: false, required: [code, category, retryable], properties: {code: {const: CONNECTOR_ERROR}, category: {type: string, enum: [configuration, conflict, authentication, permission, rate_limit, timeout, upstream, contract, cancelled]}, retryable: {type: boolean}}}
+    ConnectorHealthEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [status, detail], properties: {status: {type: string}, detail: {type: string}}}}
+    ConnectorCompatibilityEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [compatible, connector_version, sdk_version, detail], properties: {compatible: {type: boolean}, connector_version: {type: string}, sdk_version: {type: string}, detail: {type: string}}}}
+    ConnectorPrincipalEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [principal_uid], properties: {principal_uid: {type: string, format: uuid}}}}
+    ConnectorSourceBindingEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [binding_uid, binding_version, status, rebound_principals], properties: {binding_uid: {type: string, format: uuid}, binding_version: {type: integer, minimum: 1}, status: {const: approved}, rebound_principals: {type: integer, minimum: 0}}}}
+    ConnectorCredentialEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {oneOf: [{type: object, additionalProperties: false, required: [credential_uid, credential, expires_in, returned_once], properties: {credential_uid: {type: string, format: uuid}, credential: {type: string, writeOnly: true}, expires_in: {type: integer}, returned_once: {const: true}}}, {type: object, additionalProperties: false, required: [revoked], properties: {revoked: {type: boolean}}}]}}
+    ConnectorGraphEnvelope:
+      type: object
+      additionalProperties: false
+      required: [code, message, data]
+      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [nodes, edges, summary], properties: {nodes: {type: array, items: {type: object, additionalProperties: false, required: [type, key], properties: {type: {type: string, enum: [source, asset, process, business_domain, run]}, key: {type: string}}}}, edges: {type: array, items: {type: object}}, summary: {type: object, additionalProperties: false, required: [node_count, edge_count], properties: {node_count: {type: integer}, edge_count: {type: integer}}}}}}
   securitySchemes:
     bearerAuth:
       type: http

+ 55 - 0
docs/development/CONNECTOR_SDK_GUIDE.md

@@ -0,0 +1,55 @@
+# Connector SDK v1 开发指南
+
+## 最小接入
+
+从 `app.core.connectors` 导入 `Connector`、`ConnectorManifest`、`OperationRequest`、
+`OperationResult` 和 `ConnectorRegistry`。连接器声明唯一 `connector_id`、语义版本、固定
+`sdk_version=1.0`、能力清单与 `additionalProperties: false` 的配置 Schema,实现八个统一方法,
+然后调用 `registry.register(connector)`。不要修改 runtime 或采集 dispatcher。
+
+配置由 JSON Schema Draft 2020-12 递归校验,包括嵌套对象、数组、上下界、组合规则和未知字段。
+非 dry-run 机器运行不得出现调用方 config 字段,即使值为 `{}` 也拒绝。Oracle、SQL Server、REST
+均须先由管理员创建版本化 source binding,再将 principal 绑定到同一 connector/version/source/
+domain/environment;runtime 只使用服务端批准的完整配置,包括非 REST 的 credential reference。
+配置只能保存连接信息与秘密引用。秘密字段必须命名为 `*_ref`,值使用 `env:`、`vault:` 或
+`secret:` 引用;不得把 password、token、API key、Authorization 或私钥放入配置、manifest、
+异常、日志或 evidence。
+
+## 契约规则
+
+1. 所有外部读取必须有超时、响应/记录上界与明确 scope;SQL 必须只读且参数化。
+2. `OperationResult` 返回不可变 records、cursor、checkpoint 和安全 evidence。增量和恢复必须
+   使用已持久化的 cursor/checkpoint。大型 snapshot checkpoint 应保存有界 hash/count 摘要;若没有
+   足够信息精确重建 added/removed/changed,就明确 `details_materialized=false`,不得伪造明细。evidence 超过
+   32 KiB 时拒绝;records、cursor 和 checkpoint 也分别受 10,000 条/1 MiB、32 KiB、256 KiB
+   上界。所有通道在持久化前递归脱敏,列表只暴露 checkpoint/cursor 摘要。
+3. 未声明能力、未知版本、非法配置、重复运行和不允许状态迁移必须失败关闭。
+4. 上游异常映射到 configuration/authentication/permission/rate_limit/timeout/upstream/
+   contract/cancelled 稳定类别,响应不携带驱动原始错误。
+5. capability 必须与实际行为一致;未实现的方法不得写入 manifest。使用通用兼容套件验证已声明
+   方法、幂等、重试、取消、恢复、脱敏和注册零核心修改。
+6. 普通 connector operation 的 `OperationResult.status` 只能是 `succeeded`;不得返回 running、failed
+   或 cancelled 让 runtime 写入悬挂状态。dry-run 状态由 runtime 产生。
+
+受控 REST Catalog 是参考实现。生产 transport 必须使用首次验证的公共 IP 建立连接,同时保留
+原始 host 用于 TLS SNI、证书 hostname 与 Host 校验;不得跟随重定向,也不得在 DNS 重解析后
+更换目标。响应必须流式限长;秘密解析器只能将解析值送入 Authorization,不得写入配置或证据。
+默认环境解析器仅接受 `env:DATAOPS_CONNECTOR_*`;`vault:` 和 `secret:` 必须在部署方显式注入
+后端解析器,否则稳定失败关闭。
+
+人工管理接口仅用于显式 dry-run;execute/resume 均不得调用真实 operation、secret resolver 或 I/O。
+真实非 dry-run 采集必须使用 connector principal 的一次性
+短期凭证,并同时匹配 connector/version/source/business-domain/environment、操作及资源 scope。
+execute 与 resume 共用单个运行的五次总 attempt 上限;resume 失败必须落回 failed 并保留原
+checkpoint,不能停留在 resumable。持久化适配器的状态更新必须返回显式 CAS ownership 结果;
+每次 attempt 使用不可复用 lease token,只有 exact attempt + lease 的 `acquired=true` 调用方可提交
+终态。竞争失败方必须返回冲突或 already-running,
+不得把“读取到当前行”解释成已获得运行权,也不得追加重复 attempt。
+
+取消是协作式协议:先持久化运行取消标志,再由连接器在 I/O 边界探测;最长响应时间受数据库驱动
+query timeout 与 REST read timeout 限制。不要用 connector 实例的 source 级内存标志,否则会把
+同一来源的其他运行误取消。REST 分页还必须拒绝重复/倒退 cursor,并设置页数和总记录数上界。
+
+SQL Server 连接默认启用加密并验证服务器证书。可信 environment 必须从已认证 source binding 传给
+连接管理器,禁止在 `tls_options` 中自报 development。staging/production 不允许
+`TrustServerCertificate=yes` 或 `Encrypt=no`;development 例外必须显式声明,仅用于本地验证并留警告。

+ 1 - 1
docs/phase3/P3_WP00_REQUIREMENTS.json

@@ -528,7 +528,7 @@
         "enterprise_source_2",
         "network"
       ],
-      "engineering_completion_status": "NOT_STARTED",
+      "engineering_completion_status": "ENGINEERING_COMPLETE_ENTERPRISE_SOURCE_UAT_BLOCKED",
       "enterprise_confirmation_status": "TBD_EXTERNAL",
       "dynamic_required_inputs_by_started_wp": {}
     },

+ 70 - 0
docs/phase3/P3_WP03_ENTERPRISE_CONNECTORS.md

@@ -0,0 +1,70 @@
+# P3-WP03 企业连接器与扩展契约交付说明
+
+## 状态
+
+`ENGINEERING_COMPLETE_ENTERPRISE_SOURCE_UAT_BLOCKED`
+
+`enterprise_confirmation_status` 仍为 `TBD_EXTERNAL`。Oracle 与 SQL Server 是工程实现,
+不是企业真实来源验收结果。当前缺少 `enterprise_source_1`、`enterprise_source_2`、企业
+网络、真实产品版本、只读账号与批准样本,因此不得声明企业 UAT 或生产就绪。
+
+## 工程交付
+
+- 稳定公开入口 `app.core.connectors`,SDK v1.0 包含版本化 manifest、能力白名单、严格
+  配置 Schema、秘密引用、健康与兼容接口,以及 discover/snapshot/incremental/lineage/
+  profile/cancel/resume/evidence 统一协议。
+- registry 以 `(connector_id, version)` 精确注册,未知连接器、版本和未声明能力默认拒绝;
+  受控 REST Catalog 只调用公开注册入口,采集运行核心没有连接器名称分支。
+- runtime 以 connector/version/source/principal/domain/environment/process/binding/config/scope/
+  cursor/checkpoint/operation/dry-run 和 client hint 生成服务端规范请求哈希;调用方提供的 hint 不再直接
+  充当运行主键。相同 hint 跨身份或请求边界复用返回 409,且不返回另一运行记录。
+- execute/resume 合计最多五次 attempt。每次 attempt 具有随机 lease token,终态写入必须同时命中
+  running、attempt number、lease token 和未取消标志;迟到 worker 不能覆盖新 attempt。失败态重试和
+  取消态恢复只有 CAS 获胜者可调用 connector operation,竞争失败者不追加重复 attempt。
+- 取消在单条数据库 UPDATE 中原子写入 `status=cancelled,cancel_requested=true`,并通过 runtime probe、SQL 查询前后/逐行、REST 地址与流式
+  分块/逐页协作检查。它受驱动查询超时和网络读取超时约束,不承诺立即中断已进入驱动的阻塞调用。
+- Oracle 与 SQL Server 使用只读系统目录 SQL,支持 schema/table include/exclude scope;
+  `python-oracledb` 与 `pyodbc` 是可选驱动,缺失时返回稳定配置/驱动不可用错误,不影响启动。
+- REST Catalog 配置只能来自服务端批准的版本化 source binding;机器请求携带 config(即便猜中
+  环境 secret reference)也会在 transport 前拒绝。REST 强制 HTTPS、精确 host allowlist、一次 DNS 公共地址校验后直接连接固定 IP,
+  同时用原域名完成 TLS SNI 与证书 hostname 校验;禁重定向、连接/读取超时、64 KiB 流式读取、
+  2 MiB 响应上界和 Draft 2020-12 响应校验。默认缺少 secret resolver 时在 DNS/socket 前失败关闭;
+  resolver 的秘密只进入 `Authorization` 请求头。默认 resolver 只允许
+  `env:DATAOPS_CONNECTOR_*` 命名空间;未配置后端的 `vault:`/`secret:` 稳定失败关闭。
+- Oracle、SQL Server、REST 的每个 connector principal 都严格绑定 connector/version/source/business-domain/environment 以及批准的
+  source binding 版本;操作和资源 scope
+  最小授权;binding 升版在同一事务内撤销旧版、创建新版并 rebind principal,binding 撤销同步停用
+  principal 和存量 active credential。凭证最长 15 分钟、仅返回一次、仅存 SHA-256;绑定、环境和
+  scope 在消费一次性凭证前校验,拒绝不会消耗凭证。
+- PostgreSQL 迁移 `20260802_472` 持久化 manifest、principal、credential metadata、run、attempt、
+  checkpoint、evidence、graph edge 与 audit;追加迁移 `20260802_473` 增加版本绑定、运行绑定、
+  安全配置/scope 与持久限流;后续 `20260802_474` 在多 active manifest 或 legacy binding
+  多版本 principal 时要求显式映射/合并并持续拒绝歧义 active 版本。`20260802_475` 增加批准
+  source binding、规范请求哈希/client hint、attempt lease 和持久取消标志;存在绑定或绑定运行时
+  降级会失败关闭。`20260802_476` 在升级时拒绝 active 未绑定 principal,并将所有企业连接器的
+  新建/更新 trigger 统一为 approved binding 强约束。473 在任何 ALTER/backfill 前执行歧义预检。
+- run claim 时立即建立 domain→source、source→run、process→run 控制关系;running、failed、
+  cancelled、resumable、succeeded 状态同步到关系边,成功结果再补 source/run→asset。
+- `/api/datasource/graph` 已由 501 替换为关系查询,管理 API 分为 `connectors:read`、
+  `connectors:operate`、`connectors:manage`;人工运行入口只允许显式 `dry_run=true`,非 dry-run
+  只能通过一次性机器凭证入口。人工 cancel/resume 只能处理人工 dry-run;机器 cancel/resume 必须
+  提交新的短期凭证并按已持久化运行重新认证,不能用人工 bearer 或另一 binding/principal 越权。
+  cancel/resume 路由不读取请求体,OpenAPI 也不声明 requestBody。
+  人工 dry-run 的首次执行和 resume 都只做本地 validation,不调用 connector operation、秘密解析器
+  或网络/数据库;第三方 operation 只能返回 `succeeded`,最终 `dry_run` 状态由 runtime 设置。
+  Vue2 页面按精确 permission 控制动作且不展示秘密或短期 credential。
+- runtime 在持久化前分别清洗 records、cursor、checkpoint 和 evidence;限制分别为 10,000 条/
+  1 MiB、32 KiB、256 KiB、32 KiB,并递归移除 secret key、Bearer、URL userinfo 与敏感查询参数。
+  列表 API 只返回 cursor/checkpoint 的哈希、项数与字节数摘要。
+- Oracle/SQL Server 当前只声明并实现已验证的 discover/snapshot/incremental/cancel/resume/evidence
+  能力,不虚报 lineage/profile。REST 逐页消费 next cursor,完成后持久化 completion marker,下一轮
+  从完整扫描起点开始;checkpoint 只保存 snapshot hash/count 摘要,不保存全量记录,也不伪造
+  removed 明细。分页仍受 100 页与 10,000 条总量上界约束。SQL Server 默认 `Encrypt=yes`、
+  `TrustServerCertificate=no`;环境来自 authenticated binding 而非 tls_options,伪造 development 被拒绝。
+  开发弱化仅允许 binding 中明确批准的策略,证书路径限制在批准目录内。
+
+## 外部门禁
+
+真实 UAT 需企业提供两个来源的厂商/版本、规模、批准 schema/table scope、只读账号、网络路径、
+TLS/证书、游标语义、限流与失败恢复预期。应在同一版本和批准样本上重跑兼容、增量、幂等、
+取消/恢复、凭据轮换、审计和容量验证,之后由企业数据源验收责任人签字。

+ 50 - 0
docs/validation/P3_WP03_ENTERPRISE_CONNECTOR_EVIDENCE.md

@@ -0,0 +1,50 @@
+# P3-WP03 定向验证证据
+
+## 结论边界
+
+状态为 `ENGINEERING_COMPLETE_ENTERPRISE_SOURCE_UAT_BLOCKED`,企业确认仍为 `TBD_EXTERNAL`。
+本文只记录本地工程验证;未连接真实 Oracle、SQL Server 或外部 REST 服务,不能作为企业 UAT、
+容量、网络或生产就绪证据。
+
+## 验证范围
+
+- SDK manifest/schema/秘密拒绝、八个统一接口与 registry 默认拒绝。
+- Oracle/SQL Server 注入式只读目录行归一化和 schema/table scope。
+- REST 参考连接器生产 transport 参数与调用链验证:固定 IP 连接目标、原域名
+  SNI/hostname/Host 参数、secret resolver、host allowlist、私网地址/重定向/流式超限/非法
+  JSON 失败关闭。本轮没有建立真实 TLS socket,因此不将参数验证表述为真实 TLS 握手证据。
+- 配置与响应 Draft 2020-12 递归校验;服务端批准 source binding、调用方 config 失败关闭、规范
+  request hash/client hint 冲突隔离、有限重试、跨会话持久限流、
+  execute/resume 合计五次 attempt 上限、resume 失败落回 failed 且保留 checkpoint、双会话
+  取消/完成 CAS、exact attempt+lease 的迟到终态写拒绝、失败态重试及取消态恢复的单一 operation ownership、
+  数据库权威 cooperative cancellation、connector cancel/resume 调用、
+  跨页游标、identity-based 快照差异、
+  错误分类,以及 records/cursor/checkpoint/evidence 四通道持久化前脱敏/上界与列表摘要。
+- 机器 credential 15 分钟 TTL、所有企业 connector 的 approved source binding、事务化版本 rebind/
+  revoke、connector/version/source/domain/environment 严格绑定、精确
+  资源 scope、一次使用、轮换、撤销、重放拒绝和审计的代码及 PostgreSQL 约束;人工入口
+  缺少 `dry_run=true` 时失败关闭。
+- `20260802_472 -> 473 -> 474 -> 475 -> 476` head、473 任何结构变更前歧义拒绝、474 持续 active
+  version 守卫、475 binding 受控降级、476 未绑定 active principal 升级拒绝与全 UPDATE trigger;
+  revoked 未绑定 principal 可安全升级,但仅修改 status 激活时由数据库拒绝,补齐 approved binding 后方可激活;
+  4 线程幂等唯一约束、claim 即有的控制关系和全状态同步、API 权限、
+  前端构建、OpenAPI、app/deployment 文件一致性和差异检查。
+
+## 本地结果(2026-08-02)
+
+- P3-WP03 SDK/runtime/连接器/身份/API/OpenAPI/发布副本契约单文件:`50 passed`。
+- 指定 Docker PostgreSQL 的 476 head、迁移往返与升级/持续版本/降级守卫、数据库约束、4 线程同幂等
+  键 claim、一次性身份、跨会话持久限流、五次 attempt、失败后恢复、运行/失败/取消图状态、
+  双会话取消/完成 CAS,以及从同一 failed/cancelled 状态竞争重试/恢复时 connector operation
+  调用各为 1、attempt lease 不重复且迟到 worker 无法提交终态;另含真实 Flask+PostgreSQL 的
+  source binding 升版/rebind/revoke、攻击 config(含空对象)、人工/机器 cancel-resume 隔离、跨
+  binding/token/hint 攻击,以及 Oracle/SQL Server 完整 approved config、消费前认证和可信环境:`4 passed`。
+- 既有数据源生命周期、安全、前端、权限、企业身份及架构契约定向集:`60 passed`。
+- OpenAPI 3.1 重新生成,共 `430` 个操作;P3-WP03 测试验证 source binding、机器凭证头、权限、状态码、
+  dry-run 常量、专用响应、稳定错误类别,以及不读取 body 的 cancel/resume 不声明 requestBody。
+- Ruff 定向检查:通过;Python `py_compile`:通过;最终工作树 Vue2 production build 退出码 0;
+  app/deployment 与 migrations/deployment 递归 diff:通过;
+  `git diff --check`:通过。
+
+构建仅提示浏览器兼容数据库与 Webpack API 陈旧警告,不影响本次退出码。真实企业 UAT 项保持
+未完成。

+ 51 - 0
docs/validation/P3_WP03_FAILURE_INJECTION.json

@@ -0,0 +1,51 @@
+{
+  "schema_version": "1.0",
+  "work_package": "P3-WP03",
+  "status": "ENGINEERING_COMPLETE_ENTERPRISE_SOURCE_UAT_BLOCKED",
+  "samples": [
+    {"id": "unknown-version", "inject": "unregistered connector version", "expect": "configuration, no external call"},
+    {"id": "plaintext-secret", "inject": "password in config", "expect": "configuration, rejected before persistence"},
+    {"id": "driver-missing", "inject": "optional Oracle or SQL Server driver absent", "expect": "driver unavailable, process remains healthy"},
+    {"id": "rate-limit", "inject": "persistent minute window capacity exhausted across sessions", "expect": "rate_limit, no process-local bypass"},
+    {"id": "upstream-timeout", "inject": "transport timeout", "expect": "timeout, at most configured attempts"},
+    {"id": "cancel-complete-race", "inject": "cancel wins before a second worker commits completion", "expect": "CAS preserves cancelled terminal state"},
+    {"id": "late-attempt-terminal-write", "inject": "attempt 1 completes after cancel and attempt 2 lease acquisition", "expect": "both stale success and stale failure writes are rejected; attempt 2 remains owner"},
+    {"id": "failed-retry-ownership-race", "inject": "two database sessions retry the same failed run", "expect": "one CAS owner invokes the connector once; loser is rejected and no duplicate attempt is written"},
+    {"id": "cancelled-resume-ownership-race", "inject": "two database sessions resume the same cancelled run", "expect": "one CAS owner invokes resume once; loser is rejected and no duplicate attempt is written"},
+    {"id": "cancel-resume", "inject": "cancel between checkpoints", "expect": "connector cancel invoked, then connector resume consumes persisted cursor/checkpoint"},
+    {"id": "human-dry-run-resume-io", "inject": "cancel a running human dry-run and resume it", "expect": "validation-only dry_run; no connector operation, secret resolver, network or database I/O"},
+    {"id": "nonterminal-operation-status", "inject": "third-party operation returns running, failed or cancelled", "expect": "configuration failure; runtime never persists an operation-controlled nonterminal status"},
+    {"id": "cross-session-cancel-probe", "inject": "session A cancels while session B probes", "expect": "one atomic update commits cancelled and cancel_requested=true; session B immediately observes both"},
+    {"id": "same-source-cancel-isolation", "inject": "cancel one run while another run uses the same source", "expect": "database run flag cancels only the selected run"},
+    {"id": "resume-upstream-failure", "inject": "resume exhausts its invocation retry budget", "expect": "failed with error metadata; original checkpoint remains reusable"},
+    {"id": "attempt-cap", "inject": "execute fails 3 times then is invoked again", "expect": "only attempts 4 and 5 occur; attempt 6 is rejected"},
+    {"id": "credential-replay", "inject": "reuse one-time machine token", "expect": "replayed and rejected with audit"},
+    {"id": "payload-config-injection", "inject": "machine payload guesses an approved secret reference but changes target host", "expect": "configuration rejected before transport; server binding remains authoritative"},
+    {"id": "human-machine-action-crossover", "inject": "human bearer cancels/resumes a machine run or machine token targets a human run", "expect": "scope-separated lookup rejects the action"},
+    {"id": "cross-binding-action", "inject": "fresh credential from another principal/binding targets an existing run", "expect": "permission denied without revealing run internals"},
+    {"id": "client-hint-conflict", "inject": "reuse the same raw idempotency hint across principal/domain/environment/process boundaries", "expect": "409 conflict and no foreign run record returned"},
+    {"id": "cross-binding", "inject": "credential used with another connector version, source, domain or environment", "expect": "permission, credential scope rejected"},
+    {"id": "unbound-enterprise-principal", "inject": "create Oracle, SQL Server or REST principal without an approved binding", "expect": "application and 476 database trigger reject it"},
+    {"id": "binding-version-rebind", "inject": "approve version 2 while principals reference version 1", "expect": "one transaction revokes v1, creates v2 and rebinds principals without a unique-constraint gap"},
+    {"id": "binding-revoke-cascade", "inject": "revoke a binding with active principals and credentials", "expect": "principals and active credentials are deactivated in the same transaction"},
+    {"id": "unknown-scope-key", "inject": "machine request includes an undeclared resource scope key", "expect": "configuration, fail closed"},
+    {"id": "human-non-dry-run", "inject": "human bearer endpoint omits dry_run or sets false", "expect": "configuration, machine-only path enforced"},
+    {"id": "secret-resolver-missing", "inject": "REST connector has no production secret resolver", "expect": "configuration before DNS or socket"},
+    {"id": "ssrf-private-ip", "inject": "allowlisted host resolves to loopback/private/link-local/mixed public-private set", "expect": "configuration, no socket opened"},
+    {"id": "redirect", "inject": "3xx REST response", "expect": "upstream, redirect rejected"},
+    {"id": "oversize", "inject": "streamed REST body exceeds 2 MiB", "expect": "contract, connection released and body rejected"},
+    {"id": "operation-output-oversize", "inject": "connector returns records/cursor/checkpoint/evidence beyond persistence limits", "expect": "contract rejection before any unsafe output is persisted"},
+    {"id": "output-secret-redaction", "inject": "secrets appear in records, cursor, checkpoint and evidence", "expect": "all four channels recursively redacted before persistence; list response exposes summaries only"},
+    {"id": "rest-repeated-cursor", "inject": "REST pagination repeats or numerically decreases next_cursor", "expect": "contract failure within 100-page and 10000-record bounds"},
+    {"id": "rest-completed-cursor-reuse", "inject": "run incremental again with a persisted completed cursor", "expect": "restart full scan, persist completion marker and bounded snapshot summary, report no false removed detail"},
+    {"id": "sqlserver-insecure-tls", "inject": "production config disables encryption or trusts the server certificate", "expect": "configuration rejected; development exception requires explicit opt-in"},
+    {"id": "sqlserver-forged-development", "inject": "machine payload or tls_options claims development for a production binding", "expect": "permission/configuration rejection before credential consumption; adapter receives authenticated production environment"},
+    {"id": "schema-recursion", "inject": "nested/array/bounds/oneOf/additionalProperties violation", "expect": "configuration, Draft 2020-12 rejection"},
+    {"id": "migration-roundtrip", "inject": "single-version 475 downgrade through 474/473/472 then upgrade to 475", "expect": "safe round trip; new non-dry-run rows remain binding constrained"},
+    {"id": "migration-upgrade-ambiguity", "inject": "principal connector has multiple active manifest versions before 473", "expect": "upgrade rejected before ALTER/backfill until explicit mapping"},
+    {"id": "migration-continuous-version-ambiguity", "inject": "insert a second active manifest after 474 is installed", "expect": "trigger rejects ambiguity while an existing principal is bound"},
+    {"id": "migration-binding-downgrade", "inject": "downgrade 475 while source bindings or bound runs/principals exist", "expect": "downgrade rejected until explicit cleanup"},
+    {"id": "migration-476-unbound-principal", "inject": "upgrade 475 to 476 with an active unbound enterprise principal", "expect": "upgrade rejected until an approved source binding is attached"},
+    {"id": "migration-476-status-only-activation", "inject": "upgrade a revoked unbound principal from 475, then activate it by changing status only", "expect": "476 trigger rejects activation until an approved exact binding is attached"}
+  ]
+}

+ 9 - 0
frontend/src/api/dataOrigin.js

@@ -40,3 +40,12 @@ export function invalidateDatasourcePool (uid) {
     reason: 'admin_reset'
   })
 }
+
+export const getConnectorManifests = () => http.get('/datasource/connectors/manifests')
+export const validateConnectorConfig = data => http.post('/datasource/connectors/config/validate', data)
+export const getConnectorCompatibility = (id, version) => http.get(`/datasource/connectors/${id}/${version}/compatibility`)
+export const getConnectorRuns = () => http.get('/datasource/connectors/runs')
+export const executeConnectorRun = data => http.post('/datasource/connectors/runs', data)
+export const cancelConnectorRun = key => http.post(`/datasource/connectors/runs/${key}/cancel`)
+export const resumeConnectorRun = key => http.post(`/datasource/connectors/runs/${key}/resume`)
+export const getDatasourceGraph = sourceUid => http.post('/datasource/graph', sourceUid ? { source_uid: sourceUid } : {})

+ 13 - 0
frontend/src/router/routes.js

@@ -209,6 +209,19 @@ export default {
           name: 'dataResearchCenter',
           alwaysShow: 0
         },
+        {
+          hidden: 0,
+          icon: 'mdi-connection',
+          type: 1,
+          title: '企业连接器',
+          path: '/data-governance/development/enterprise-connectors',
+          children: [],
+          label: '企业连接器',
+          component: 'dataGovernance/development/enterpriseConnectors',
+          meta: { permissions: ['connectors:read'], title: '企业连接器', readOnly: 'viewer' },
+          name: 'enterpriseConnectors',
+          alwaysShow: 0
+        },
         {
           hidden: 1,
           type: 1,

+ 3 - 1
frontend/src/views/dataGovernance/dataSource/components/edit.vue

@@ -124,7 +124,9 @@ export default {
           dense: true,
           items: [
             { label: 'PostgreSQL', value: 'postgresql' },
-            { label: 'MySQL', value: 'mysql' }
+            { label: 'MySQL', value: 'mysql' },
+            { label: 'Oracle(需企业 UAT)', value: 'oracle' },
+            { label: 'SQL Server(需企业 UAT)', value: 'sqlserver' }
           ],
           rules: [value => !!value || '请选择数据库类型']
         },

+ 101 - 0
frontend/src/views/dataGovernance/development/enterpriseConnectors.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="pa-6">
+    <div class="d-flex align-start mb-5">
+      <div>
+        <h1 class="text-h4 mb-1">企业连接器</h1>
+        <p class="text--secondary mb-0">统一查看扩展契约、兼容性、运行检查点与关系摘要。</p>
+      </div>
+      <v-spacer />
+      <v-btn outlined color="primary" :loading="loading" @click="loadAll">刷新</v-btn>
+    </div>
+    <v-alert type="warning" outlined>
+      Oracle 与 SQL Server 已完成工程兼容验证,真实企业账号、版本、网络与 UAT 尚未提供。界面不会显示秘密或短期机器凭证。
+    </v-alert>
+    <v-chip v-if="canManage" small color="primary" outlined class="mb-3">具备连接器管理权限</v-chip>
+    <v-row>
+      <v-col v-for="item in manifests" :key="`${item.connector_id}:${item.version}`" cols="12" md="4">
+        <v-card outlined height="100%">
+          <v-card-title>{{ item.display_name }}</v-card-title>
+          <v-card-subtitle>{{ item.connector_id }} · {{ item.version }} · SDK {{ item.sdk_version }}</v-card-subtitle>
+          <v-card-text>
+            <v-chip v-for="capability in item.capabilities" :key="capability" small outlined class="mr-1 mb-1">{{ capability }}</v-chip>
+            <div class="mt-3">兼容状态:<strong>{{ compatibility[`${item.connector_id}:${item.version}`] || '待检查' }}</strong></div>
+          </v-card-text>
+          <v-card-actions>
+            <v-btn text color="primary" @click="checkCompatibility(item)">兼容检查</v-btn>
+            <v-btn v-if="canOperate" text color="primary" @click="openDryRun(item)">Dry-run</v-btn>
+          </v-card-actions>
+        </v-card>
+      </v-col>
+    </v-row>
+    <v-card outlined class="mt-5">
+      <v-card-title>运行与检查点</v-card-title>
+      <v-data-table :headers="runHeaders" :items="runs" :loading="loading">
+        <template v-slot:[`item.error_category`]="{ item }">{{ item.error_category || '-' }}</template>
+        <template v-slot:[`item.run_type`]="{ item }">{{ isHumanDryRun(item) ? '人工 Dry-run' : '机器运行' }}</template>
+        <template v-slot:[`item.checkpoint_summary`]="{ item }"><code>{{ compact(item.checkpoint_summary) }}</code></template>
+        <template v-slot:[`item.cursor_summary`]="{ item }"><code>{{ compact(item.cursor_summary) }}</code></template>
+        <template v-slot:[`item.actions`]="{ item }">
+          <v-btn v-if="canOperate && isHumanDryRun(item) && item.status === 'running'" text small color="warning" @click="cancel(item)">取消</v-btn>
+          <v-btn v-if="canOperate && isHumanDryRun(item) && ['failed','cancelled'].includes(item.status)" text small color="primary" @click="resume(item)">恢复</v-btn>
+          <span v-if="!isHumanDryRun(item)" class="text--secondary">仅机器凭证可操作</span>
+        </template>
+      </v-data-table>
+    </v-card>
+    <v-card outlined class="mt-5">
+      <v-card-title>数据关系摘要</v-card-title>
+      <v-card-text>节点 {{ graph.node_count || 0 }} · 关系 {{ graph.edge_count || 0 }}</v-card-text>
+    </v-card>
+    <v-dialog v-model="dialog" max-width="620">
+      <v-card>
+        <v-card-title>连接器 Dry-run</v-card-title>
+        <v-card-text>
+          <v-text-field v-model.trim="form.source_uid" label="数据源 UID *" />
+          <v-text-field v-model.trim="form.credential_ref" label="秘密引用 *" hint="例如 env:DATAOPS_CONNECTOR_CREDENTIAL" persistent-hint />
+          <v-text-field v-if="selected && selected.connector_id === 'rest-catalog'" v-model.trim="form.base_url" label="HTTPS Catalog URL *" />
+          <v-text-field v-if="selected && selected.connector_id === 'rest-catalog'" v-model.trim="form.allowed_host" label="允许主机 *" />
+        </v-card-text>
+        <v-card-actions><v-spacer /><v-btn text @click="dialog=false">取消</v-btn><v-btn color="primary" @click="dryRun">执行</v-btn></v-card-actions>
+      </v-card>
+    </v-dialog>
+  </div>
+</template>
+
+<script>
+import { cancelConnectorRun, executeConnectorRun, getConnectorCompatibility, getConnectorManifests, getConnectorRuns, getDatasourceGraph, resumeConnectorRun } from '@/api/dataOrigin'
+
+export default {
+  name: 'EnterpriseConnectors',
+  data: () => ({
+    loading: false,
+    manifests: [],
+    runs: [],
+    compatibility: {},
+    graph: {},
+    dialog: false,
+    selected: null,
+    form: { source_uid: '', credential_ref: 'env:DATAOPS_CONNECTOR_CREDENTIAL', base_url: '', allowed_host: '' },
+    runHeaders: [
+      { text: '连接器', value: 'connector_id' }, { text: '运行类型', value: 'run_type' }, { text: '操作', value: 'operation' }, { text: '状态', value: 'status' },
+      { text: '尝试', value: 'attempt_count' }, { text: '检查点摘要', value: 'checkpoint_summary' }, { text: '游标摘要', value: 'cursor_summary' }, { text: '错误类别', value: 'error_category' }, { text: '操作', value: 'actions', sortable: false }
+    ]
+  }),
+  computed: {
+    connectorPermissions () { return (this.$store.state.user.userInfo && this.$store.state.user.userInfo.permissions) || [] },
+    canRead () { return this.connectorPermissions.includes('connectors:read') },
+    canOperate () { return this.connectorPermissions.includes('connectors:operate') || this.canManage },
+    canManage () { return this.connectorPermissions.includes('connectors:manage') }
+  },
+  created () { this.loadAll() },
+  methods: {
+    compact (value) { const text = JSON.stringify(value || {}); return text.length > 100 ? `${text.slice(0, 100)}…` : text },
+    isHumanDryRun (item) { return item.dry_run === true && !item.principal_uid },
+    async loadAll () { this.loading = true; try { const [manifests, runs, graph] = await Promise.all([getConnectorManifests(), getConnectorRuns(), getDatasourceGraph()]); this.manifests = manifests.data.manifests; this.runs = runs.data.runs; this.graph = graph.data.summary || {} } catch (error) { this.$snackbar.error(error) } finally { this.loading = false } },
+    async checkCompatibility (item) { try { const { data } = await getConnectorCompatibility(item.connector_id, item.version); this.$set(this.compatibility, `${item.connector_id}:${item.version}`, data.compatible ? '兼容' : '不兼容') } catch (error) { this.$snackbar.error(error) } },
+    openDryRun (item) { this.selected = item; this.dialog = true },
+    async dryRun () { const config = { credential_ref: this.form.credential_ref }; if (this.selected.connector_id === 'rest-catalog') Object.assign(config, { base_url: this.form.base_url, allowed_host: this.form.allowed_host }); try { await executeConnectorRun({ connector_id: this.selected.connector_id, version: this.selected.version, source_uid: this.form.source_uid, operation: 'discover', config, scope: {}, dry_run: true }); this.dialog = false; this.loadAll() } catch (error) { this.$snackbar.error(error) } },
+    async cancel (item) { try { await cancelConnectorRun(item.idempotency_key); this.loadAll() } catch (error) { this.$snackbar.error(error) } },
+    async resume (item) { try { await resumeConnectorRun(item.idempotency_key); this.loadAll() } catch (error) { this.$snackbar.error(error) } }
+  }
+}
+</script>

+ 114 - 0
migrations/versions/20260802_472_enterprise_connectors.py

@@ -0,0 +1,114 @@
+"""Enterprise connector SDK, machine identity, execution and graph ledger."""
+
+from alembic import op
+
+revision = "20260802_472"
+down_revision = "20260802_471"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    CREATE TABLE public.connector_manifests (
+      uid UUID PRIMARY KEY, connector_id VARCHAR(64) NOT NULL, connector_version VARCHAR(40) NOT NULL,
+      sdk_version VARCHAR(20) NOT NULL, display_name VARCHAR(200) NOT NULL,
+      capabilities JSONB NOT NULL, config_schema JSONB NOT NULL, status VARCHAR(20) NOT NULL,
+      created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_manifest_version UNIQUE (connector_id, connector_version),
+      CONSTRAINT ck_connector_manifest_status CHECK (status IN ('active','retired')),
+      CONSTRAINT ck_connector_manifest_capabilities_array CHECK (jsonb_typeof(capabilities)='array'),
+      CONSTRAINT ck_connector_manifest_schema_object CHECK (jsonb_typeof(config_schema)='object')
+    );
+    CREATE TABLE public.connector_principals (
+      uid UUID PRIMARY KEY, connector_id VARCHAR(64) NOT NULL, source_uid UUID NOT NULL,
+      business_domain_uid UUID NOT NULL, environment VARCHAR(20) NOT NULL,
+      allowed_operations TEXT[] NOT NULL, allowed_scopes JSONB NOT NULL,
+      status VARCHAR(20) NOT NULL, created_by UUID NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, revoked_at TIMESTAMPTZ,
+      CONSTRAINT uq_connector_principal_binding UNIQUE (connector_id, source_uid, business_domain_uid, environment),
+      CONSTRAINT ck_connector_principal_environment CHECK (environment IN ('development','staging','production')),
+      CONSTRAINT ck_connector_principal_status CHECK (status IN ('active','revoked')),
+      CONSTRAINT ck_connector_principal_scope CHECK (jsonb_typeof(allowed_scopes)='object')
+    );
+    CREATE TABLE public.connector_machine_credentials (
+      uid UUID PRIMARY KEY, principal_uid UUID NOT NULL REFERENCES public.connector_principals(uid),
+      token_hash CHAR(64) NOT NULL UNIQUE, status VARCHAR(20) NOT NULL,
+      issued_by UUID NOT NULL, issued_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      expires_at TIMESTAMPTZ NOT NULL, first_used_at TIMESTAMPTZ, use_count INTEGER NOT NULL DEFAULT 0,
+      revoked_at TIMESTAMPTZ, rotated_from_uid UUID REFERENCES public.connector_machine_credentials(uid),
+      CONSTRAINT ck_connector_credential_status CHECK (status IN ('active','revoked','rotated','expired','replayed')),
+      CONSTRAINT ck_connector_credential_ttl CHECK (expires_at <= issued_at + INTERVAL '15 minutes'),
+      CONSTRAINT ck_connector_credential_use_count CHECK (use_count >= 0)
+    );
+    CREATE INDEX ix_connector_credential_principal_status ON public.connector_machine_credentials(principal_uid,status,expires_at);
+    CREATE TABLE public.connector_runs (
+      uid UUID PRIMARY KEY, idempotency_key CHAR(64) NOT NULL UNIQUE,
+      connector_id VARCHAR(64) NOT NULL, connector_version VARCHAR(40) NOT NULL,
+      source_uid UUID NOT NULL, operation VARCHAR(30) NOT NULL, status VARCHAR(20) NOT NULL,
+      attempt_count INTEGER NOT NULL DEFAULT 0, checkpoint JSONB NOT NULL DEFAULT '{}'::jsonb,
+      cursor JSONB NOT NULL DEFAULT '{}'::jsonb, error_category VARCHAR(30), error_code VARCHAR(80),
+      resumed_from_run_uid UUID REFERENCES public.connector_runs(uid), dry_run BOOLEAN NOT NULL DEFAULT FALSE,
+      actor_uid UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT fk_connector_run_manifest FOREIGN KEY (connector_id,connector_version)
+        REFERENCES public.connector_manifests(connector_id,connector_version),
+      CONSTRAINT ck_connector_run_operation CHECK (operation IN ('discover','snapshot','incremental','lineage','profile','cancel','resume','evidence')),
+      CONSTRAINT ck_connector_run_status CHECK (status IN ('running','succeeded','dry_run','failed','cancelled','resumable')),
+      CONSTRAINT ck_connector_run_attempt CHECK (attempt_count BETWEEN 0 AND 5)
+    );
+    CREATE INDEX ix_connector_runs_source_created ON public.connector_runs(source_uid,created_at DESC);
+    CREATE TABLE public.connector_run_attempts (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      attempt_number INTEGER NOT NULL, status VARCHAR(20) NOT NULL, error_category VARCHAR(30),
+      started_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at TIMESTAMPTZ,
+      CONSTRAINT uq_connector_run_attempt UNIQUE(run_uid,attempt_number)
+    );
+    CREATE TABLE public.connector_checkpoints (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      sequence_number INTEGER NOT NULL, cursor JSONB NOT NULL, checkpoint JSONB NOT NULL,
+      content_hash CHAR(64) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_checkpoint_sequence UNIQUE(run_uid,sequence_number)
+    );
+    CREATE TABLE public.connector_evidence (
+      uid UUID PRIMARY KEY, run_uid UUID NOT NULL REFERENCES public.connector_runs(uid) ON DELETE CASCADE,
+      evidence_type VARCHAR(40) NOT NULL, payload JSONB NOT NULL, content_hash CHAR(64) NOT NULL,
+      byte_size INTEGER NOT NULL, redacted BOOLEAN NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT ck_connector_evidence_size CHECK (byte_size BETWEEN 0 AND 32768)
+    );
+    CREATE TABLE public.connector_graph_edges (
+      uid UUID PRIMARY KEY, source_uid UUID NOT NULL, from_type VARCHAR(30) NOT NULL,
+      from_key VARCHAR(500) NOT NULL, relation_type VARCHAR(40) NOT NULL,
+      to_type VARCHAR(30) NOT NULL, to_key VARCHAR(500) NOT NULL,
+      business_domain_uid UUID, run_uid UUID REFERENCES public.connector_runs(uid),
+      run_status VARCHAR(20), evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      CONSTRAINT uq_connector_graph_edge UNIQUE(source_uid,from_type,from_key,relation_type,to_type,to_key),
+      CONSTRAINT ck_connector_graph_node_types CHECK (from_type IN ('source','asset','process','business_domain','run') AND to_type IN ('source','asset','process','business_domain','run'))
+    );
+    CREATE INDEX ix_connector_graph_source ON public.connector_graph_edges(source_uid,relation_type);
+    CREATE INDEX ix_connector_graph_to ON public.connector_graph_edges(to_type,to_key);
+    CREATE TABLE public.connector_audit_events (
+      uid UUID PRIMARY KEY, principal_uid UUID REFERENCES public.connector_principals(uid),
+      credential_uid UUID REFERENCES public.connector_machine_credentials(uid), event_type VARCHAR(80) NOT NULL,
+      actor_uid UUID, success BOOLEAN NOT NULL, safe_detail VARCHAR(500) NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
+    );
+    CREATE INDEX ix_connector_audit_principal_created ON public.connector_audit_events(principal_uid,created_at DESC);
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DROP TABLE IF EXISTS public.connector_audit_events;
+    DROP TABLE IF EXISTS public.connector_graph_edges;
+    DROP TABLE IF EXISTS public.connector_evidence;
+    DROP TABLE IF EXISTS public.connector_checkpoints;
+    DROP TABLE IF EXISTS public.connector_run_attempts;
+    DROP TABLE IF EXISTS public.connector_runs;
+    DROP TABLE IF EXISTS public.connector_machine_credentials;
+    DROP TABLE IF EXISTS public.connector_principals;
+    DROP TABLE IF EXISTS public.connector_manifests;
+    """)

+ 92 - 0
migrations/versions/20260802_473_connector_runtime_hardening.py

@@ -0,0 +1,92 @@
+"""Harden connector identity bindings and persistent runtime state."""
+
+from alembic import op
+
+revision = "20260802_473"
+down_revision = "20260802_472"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals p
+          JOIN (
+            SELECT connector_id
+              FROM public.connector_manifests
+             WHERE status='active'
+             GROUP BY connector_id
+            HAVING COUNT(DISTINCT connector_version)>1
+          ) ambiguous USING(connector_id)
+      ) THEN
+        RAISE EXCEPTION
+          'connector 473 upgrade rejected before backfill: multiple active versions require an explicit one-active-version mapping';
+      END IF;
+    END $$;
+    ALTER TABLE public.connector_principals ADD COLUMN connector_version VARCHAR(40);
+    UPDATE public.connector_principals p SET connector_version=(
+      SELECT m.connector_version FROM public.connector_manifests m
+      WHERE m.connector_id=p.connector_id AND m.status='active'
+      ORDER BY m.created_at DESC LIMIT 1
+    );
+    DO $$ BEGIN
+      IF EXISTS (SELECT 1 FROM public.connector_principals WHERE connector_version IS NULL) THEN
+        RAISE EXCEPTION 'connector principal version cannot be backfilled safely';
+      END IF;
+    END $$;
+    ALTER TABLE public.connector_principals ALTER COLUMN connector_version SET NOT NULL;
+    ALTER TABLE public.connector_principals
+      ADD CONSTRAINT fk_connector_principal_manifest FOREIGN KEY(connector_id,connector_version)
+      REFERENCES public.connector_manifests(connector_id,connector_version);
+    ALTER TABLE public.connector_principals DROP CONSTRAINT uq_connector_principal_binding;
+    ALTER TABLE public.connector_principals ADD CONSTRAINT uq_connector_principal_binding_v2
+      UNIQUE(connector_id,connector_version,source_uid,business_domain_uid,environment);
+
+    ALTER TABLE public.connector_runs
+      ADD COLUMN principal_uid UUID REFERENCES public.connector_principals(uid),
+      ADD COLUMN business_domain_uid UUID,
+      ADD COLUMN environment VARCHAR(20),
+      ADD COLUMN process_key VARCHAR(300),
+      ADD COLUMN safe_config JSONB NOT NULL DEFAULT '{}'::jsonb,
+      ADD COLUMN scope JSONB NOT NULL DEFAULT '{}'::jsonb;
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_environment
+      CHECK(environment IS NULL OR environment IN ('development','staging','production'));
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_machine_run_binding
+      CHECK(dry_run OR (principal_uid IS NOT NULL AND business_domain_uid IS NOT NULL AND environment IS NOT NULL AND process_key IS NOT NULL)) NOT VALID;
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_config_object CHECK(jsonb_typeof(safe_config)='object');
+    ALTER TABLE public.connector_runs ADD CONSTRAINT ck_connector_run_scope_object CHECK(jsonb_typeof(scope)='object');
+
+    CREATE TABLE public.connector_rate_limits(
+      limit_key VARCHAR(300) NOT NULL,
+      window_started_at TIMESTAMPTZ NOT NULL,
+      request_count INTEGER NOT NULL,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY(limit_key,window_started_at),
+      CONSTRAINT ck_connector_rate_count CHECK(request_count BETWEEN 1 AND 30)
+    );
+    CREATE INDEX ix_connector_rate_limits_updated ON public.connector_rate_limits(updated_at);
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DROP TABLE IF EXISTS public.connector_rate_limits;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_scope_object;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_config_object;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_machine_run_binding;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_environment;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS scope;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS safe_config;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS process_key;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS environment;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS business_domain_uid;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS principal_uid;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS uq_connector_principal_binding_v2;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS fk_connector_principal_manifest;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS connector_version;
+    ALTER TABLE public.connector_principals ADD CONSTRAINT uq_connector_principal_binding
+      UNIQUE(connector_id,source_uid,business_domain_uid,environment);
+    """)

+ 70 - 0
migrations/versions/20260802_474_connector_version_guardrails.py

@@ -0,0 +1,70 @@
+"""Continuously reject ambiguous active connector versions."""
+
+from alembic import op
+
+revision = "20260802_474"
+down_revision = "20260802_473"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals p
+          JOIN (
+            SELECT connector_id
+              FROM public.connector_manifests
+             WHERE status='active'
+             GROUP BY connector_id
+            HAVING COUNT(DISTINCT connector_version)>1
+          ) ambiguous USING(connector_id)
+      ) THEN
+        RAISE EXCEPTION
+          'connector 474 upgrade rejected: principal has multiple active manifest versions; provide an explicit principal-to-version mapping';
+      END IF;
+    END $$;
+    CREATE OR REPLACE FUNCTION public.enforce_connector_active_version()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    DECLARE target_connector VARCHAR(64);
+    BEGIN
+      target_connector := COALESCE(NEW.connector_id, OLD.connector_id);
+      IF EXISTS (SELECT 1 FROM public.connector_principals WHERE connector_id=target_connector)
+         AND (SELECT COUNT(DISTINCT connector_version)
+                FROM public.connector_manifests
+               WHERE connector_id=target_connector AND status='active') > 1 THEN
+        RAISE EXCEPTION
+          'multiple active connector versions require an explicit one-active-version mapping';
+      END IF;
+      RETURN COALESCE(NEW, OLD);
+    END $$;
+    CREATE TRIGGER trg_connector_manifest_active_version
+      AFTER INSERT OR UPDATE OF connector_id,connector_version,status
+      ON public.connector_manifests
+      FOR EACH ROW EXECUTE FUNCTION public.enforce_connector_active_version();
+    CREATE TRIGGER trg_connector_principal_active_version
+      AFTER INSERT OR UPDATE OF connector_id,connector_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.enforce_connector_active_version();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1
+          FROM public.connector_principals
+         GROUP BY connector_id,source_uid,business_domain_uid,environment
+        HAVING COUNT(DISTINCT connector_version)>1
+      ) THEN
+        RAISE EXCEPTION
+          'connector downgrade below 474 rejected: multiple principal versions share one legacy binding; consolidate explicitly before downgrade';
+      END IF;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_active_version ON public.connector_principals;
+    DROP TRIGGER IF EXISTS trg_connector_manifest_active_version ON public.connector_manifests;
+    DROP FUNCTION IF EXISTS public.enforce_connector_active_version();
+    """)

+ 145 - 0
migrations/versions/20260802_475_connector_security_bindings.py

@@ -0,0 +1,145 @@
+"""Bind connector destinations, idempotency requests and attempt leases."""
+
+from alembic import op
+
+revision = "20260802_475"
+down_revision = "20260802_474"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    CREATE TABLE public.connector_source_bindings (
+      uid UUID NOT NULL,
+      binding_version INTEGER NOT NULL,
+      connector_id VARCHAR(64) NOT NULL,
+      connector_version VARCHAR(40) NOT NULL,
+      source_uid UUID NOT NULL,
+      business_domain_uid UUID NOT NULL,
+      environment VARCHAR(20) NOT NULL,
+      approved_base_url VARCHAR(2048),
+      allowed_host VARCHAR(253),
+      credential_ref VARCHAR(300),
+      approved_config JSONB NOT NULL DEFAULT '{}'::jsonb,
+      status VARCHAR(20) NOT NULL,
+      approved_by UUID NOT NULL,
+      created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+      PRIMARY KEY(uid,binding_version),
+      CONSTRAINT fk_connector_source_binding_manifest
+        FOREIGN KEY(connector_id,connector_version)
+        REFERENCES public.connector_manifests(connector_id,connector_version),
+      CONSTRAINT ck_connector_source_binding_environment
+        CHECK(environment IN ('development','staging','production')),
+      CONSTRAINT ck_connector_source_binding_status
+        CHECK(status IN ('approved','revoked')),
+      CONSTRAINT ck_connector_source_binding_config
+        CHECK(jsonb_typeof(approved_config)='object'),
+      CONSTRAINT ck_connector_source_binding_rest_destination CHECK(
+        connector_id<>'rest-catalog' OR
+        (approved_base_url ~ '^https://[^/?#]+(?:/[^?#]*)?$'
+         AND allowed_host IS NOT NULL AND credential_ref IS NOT NULL)
+      )
+    );
+    CREATE UNIQUE INDEX uq_connector_source_binding_active
+      ON public.connector_source_bindings(
+        connector_id,connector_version,source_uid,business_domain_uid,environment
+      ) WHERE status='approved';
+    CREATE INDEX ix_connector_source_binding_lookup
+      ON public.connector_source_bindings(source_uid,business_domain_uid,environment,status);
+
+    ALTER TABLE public.connector_principals
+      ADD COLUMN source_binding_uid UUID,
+      ADD COLUMN source_binding_version INTEGER;
+    ALTER TABLE public.connector_principals
+      ADD CONSTRAINT fk_connector_principal_source_binding
+      FOREIGN KEY(source_binding_uid,source_binding_version)
+      REFERENCES public.connector_source_bindings(uid,binding_version);
+
+    ALTER TABLE public.connector_runs
+      ADD COLUMN request_hash CHAR(64),
+      ADD COLUMN client_hint_hash CHAR(64),
+      ADD COLUMN source_binding_uid UUID,
+      ADD COLUMN source_binding_version INTEGER,
+      ADD COLUMN attempt_lease_token UUID,
+      ADD COLUMN cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
+    ALTER TABLE public.connector_runs
+      ADD CONSTRAINT ck_connector_run_request_hash
+      CHECK(request_hash IS NOT NULL) NOT VALID;
+    ALTER TABLE public.connector_runs
+      ADD CONSTRAINT fk_connector_run_source_binding
+      FOREIGN KEY(source_binding_uid,source_binding_version)
+      REFERENCES public.connector_source_bindings(uid,binding_version);
+    CREATE INDEX ix_connector_runs_request_hash ON public.connector_runs(request_hash);
+    CREATE UNIQUE INDEX uq_connector_runs_client_hint
+      ON public.connector_runs(client_hint_hash) WHERE client_hint_hash IS NOT NULL;
+    CREATE INDEX ix_connector_runs_binding
+      ON public.connector_runs(source_binding_uid,source_binding_version,created_at DESC);
+
+    ALTER TABLE public.connector_run_attempts ADD COLUMN lease_token UUID;
+    CREATE UNIQUE INDEX uq_connector_attempt_lease
+      ON public.connector_run_attempts(run_uid,lease_token) WHERE lease_token IS NOT NULL;
+
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        IF NEW.connector_id='rest-catalog' THEN
+          RAISE EXCEPTION 'REST connector principal requires an approved source binding';
+        END IF;
+        RETURN NEW;
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE OF connector_id,connector_version,source_uid,
+        business_domain_uid,environment,source_binding_uid,source_binding_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (SELECT 1 FROM public.connector_source_bindings)
+         OR EXISTS (SELECT 1 FROM public.connector_principals WHERE source_binding_uid IS NOT NULL)
+         OR EXISTS (SELECT 1 FROM public.connector_runs WHERE source_binding_uid IS NOT NULL) THEN
+        RAISE EXCEPTION
+          'connector downgrade below 475 rejected: revoke and explicitly remove source bindings first';
+      END IF;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding ON public.connector_principals;
+    DROP FUNCTION IF EXISTS public.validate_connector_binding();
+    DROP INDEX IF EXISTS public.uq_connector_attempt_lease;
+    ALTER TABLE public.connector_run_attempts DROP COLUMN IF EXISTS lease_token;
+    DROP INDEX IF EXISTS public.ix_connector_runs_binding;
+    DROP INDEX IF EXISTS public.ix_connector_runs_request_hash;
+    DROP INDEX IF EXISTS public.uq_connector_runs_client_hint;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS fk_connector_run_source_binding;
+    ALTER TABLE public.connector_runs DROP CONSTRAINT IF EXISTS ck_connector_run_request_hash;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS cancel_requested;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS attempt_lease_token;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS source_binding_version;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS source_binding_uid;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS request_hash;
+    ALTER TABLE public.connector_runs DROP COLUMN IF EXISTS client_hint_hash;
+    ALTER TABLE public.connector_principals DROP CONSTRAINT IF EXISTS fk_connector_principal_source_binding;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS source_binding_version;
+    ALTER TABLE public.connector_principals DROP COLUMN IF EXISTS source_binding_uid;
+    DROP TABLE public.connector_source_bindings;
+    """)

+ 85 - 0
migrations/versions/20260802_476_connector_binding_enforcement.py

@@ -0,0 +1,85 @@
+"""Require approved source bindings for every enterprise connector principal."""
+
+from alembic import op
+
+revision = "20260802_476"
+down_revision = "20260802_475"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute("""
+    DO $$ BEGIN
+      IF EXISTS (
+        SELECT 1 FROM public.connector_principals
+         WHERE status='active'
+           AND (source_binding_uid IS NULL OR source_binding_version IS NULL)
+      ) THEN
+        RAISE EXCEPTION
+          'connector upgrade to 476 rejected: bind every active enterprise principal first';
+      END IF;
+    END $$;
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        RAISE EXCEPTION 'enterprise connector principal requires an approved source binding';
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding
+      ON public.connector_principals;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)
+
+
+def downgrade() -> None:
+    op.execute("""
+    CREATE OR REPLACE FUNCTION public.validate_connector_binding()
+    RETURNS trigger LANGUAGE plpgsql AS $$
+    BEGIN
+      IF NEW.source_binding_uid IS NULL OR NEW.source_binding_version IS NULL THEN
+        IF NEW.connector_id='rest-catalog' THEN
+          RAISE EXCEPTION 'REST connector principal requires an approved source binding';
+        END IF;
+        RETURN NEW;
+      END IF;
+      IF NOT EXISTS (
+        SELECT 1 FROM public.connector_source_bindings b
+         WHERE b.uid=NEW.source_binding_uid
+           AND b.binding_version=NEW.source_binding_version
+           AND b.status='approved'
+           AND b.connector_id=NEW.connector_id
+           AND b.connector_version=NEW.connector_version
+           AND b.source_uid=NEW.source_uid
+           AND b.business_domain_uid=NEW.business_domain_uid
+           AND b.environment=NEW.environment
+      ) THEN
+        RAISE EXCEPTION 'connector principal source binding mismatch';
+      END IF;
+      RETURN NEW;
+    END $$;
+    DROP TRIGGER IF EXISTS trg_connector_principal_binding
+      ON public.connector_principals;
+    CREATE TRIGGER trg_connector_principal_binding
+      BEFORE INSERT OR UPDATE OF connector_id,connector_version,source_uid,
+        business_domain_uid,environment,source_binding_uid,source_binding_version
+      ON public.connector_principals
+      FOR EACH ROW EXECUTE FUNCTION public.validate_connector_binding();
+    """)

+ 355 - 57
scripts/generate_openapi.py

@@ -52,6 +52,68 @@ RESPONSE_FIELDS = {
         "freshness_status",
     ],
 }
+CONNECTOR_REQUEST_SCHEMAS = {
+    "/api/datasource/connectors/config/validate": "ConnectorConfigValidationRequest",
+    "/api/datasource/connectors/runs": "ConnectorRunRequest",
+    "/api/datasource/connectors/machine/runs": "MachineConnectorRunRequest",
+    "/api/datasource/connectors/source-bindings": "ConnectorSourceBindingRequest",
+    "/api/datasource/connectors/principals": "ConnectorPrincipalRequest",
+    "/api/datasource/connectors/{connector_id}/{version}/health": "ConnectorHealthRequest",
+    "/api/datasource/connectors/principals/{principal_uid}/credentials": "ConnectorCredentialRequest",
+    "/api/datasource/connectors/credentials/{credential_uid}/{action}": "ConnectorCredentialRequest",
+    "/api/datasource/graph": "ConnectorGraphRequest",
+}
+CONNECTOR_RESPONSE_SCHEMAS = {
+    ("/api/datasource/connectors/manifests", "get"): "ConnectorManifestEnvelope",
+    ("/api/datasource/connectors/runs", "get"): "ConnectorRunListEnvelope",
+    ("/api/datasource/connectors/runs", "post"): "ConnectorRunResultEnvelope",
+    ("/api/datasource/connectors/machine/runs", "post"): "ConnectorRunResultEnvelope",
+    (
+        "/api/datasource/connectors/runs/{idempotency_key}/cancel",
+        "post",
+    ): "ConnectorRunRecordEnvelope",
+    (
+        "/api/datasource/connectors/runs/{idempotency_key}/resume",
+        "post",
+    ): "ConnectorRunResultEnvelope",
+    (
+        "/api/datasource/connectors/config/validate",
+        "post",
+    ): "ConnectorValidationEnvelope",
+    (
+        "/api/datasource/connectors/{connector_id}/{version}/health",
+        "post",
+    ): "ConnectorHealthEnvelope",
+    (
+        "/api/datasource/connectors/{connector_id}/{version}/compatibility",
+        "get",
+    ): "ConnectorCompatibilityEnvelope",
+    ("/api/datasource/connectors/principals", "post"): "ConnectorPrincipalEnvelope",
+    (
+        "/api/datasource/connectors/source-bindings",
+        "post",
+    ): "ConnectorSourceBindingEnvelope",
+    (
+        "/api/datasource/connectors/principals/{principal_uid}/credentials",
+        "post",
+    ): "ConnectorCredentialEnvelope",
+    (
+        "/api/datasource/connectors/credentials/{credential_uid}/{action}",
+        "post",
+    ): "ConnectorCredentialEnvelope",
+    ("/api/datasource/graph", "post"): "ConnectorGraphEnvelope",
+}
+NO_REQUEST_BODY_PATHS = {
+    "/api/datasource/connectors/runs/{idempotency_key}/cancel",
+    "/api/datasource/connectors/runs/{idempotency_key}/resume",
+    "/api/datasource/connectors/machine/runs/{idempotency_key}/cancel",
+    "/api/datasource/connectors/machine/runs/{idempotency_key}/resume",
+    "/api/datasource/connectors/source-bindings/{binding_uid}/revoke",
+}
+OPTIONAL_REQUEST_BODY_PATHS = {
+    "/api/datasource/connectors/principals/{principal_uid}/credentials",
+    "/api/datasource/connectors/credentials/{credential_uid}/{action}",
+}
 
 
 def quoted(value: str) -> str:
@@ -68,54 +130,58 @@ def route_path(raw_path: str) -> tuple[str, list[dict[str, str]]]:
         parameters.append({"name": name, "type": schema_type})
         return "{" + name + "}"
 
-    return re.sub(r"<(?:([a-zA-Z_]+):)?([a-zA-Z_][a-zA-Z0-9_]*)>", replace, raw_path), parameters
+    return re.sub(
+        r"<(?:([a-zA-Z_]+):)?([a-zA-Z_][a-zA-Z0-9_]*)>", replace, raw_path
+    ), parameters
 
 
 def extract_routes() -> list[dict[str, object]]:
     routes: list[dict[str, object]] = []
     for module, prefix in PREFIXES.items():
-      for route_file in sorted((ROOT / "app" / "api" / module).glob("*.py")):
-        if route_file.name == "__init__.py":
-            continue
-        tree = ast.parse(route_file.read_text(encoding="utf-8"))
-        for node in tree.body:
-            if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
+        for route_file in sorted((ROOT / "app" / "api" / module).glob("*.py")):
+            if route_file.name == "__init__.py":
                 continue
-            for decorator in node.decorator_list:
-                if not isinstance(decorator, ast.Call):
-                    continue
-                function = decorator.func
-                if not (
-                    isinstance(function, ast.Attribute)
-                    and isinstance(function.value, ast.Name)
-                    and function.value.id == "bp"
-                    and decorator.args
-                ):
-                    continue
-                if function.attr == "route":
-                    methods = ["GET"]
-                    for keyword in decorator.keywords:
-                        if keyword.arg == "methods":
-                            methods = ast.literal_eval(keyword.value)
-                elif function.attr in SHORTHAND_METHODS:
-                    methods = [function.attr.upper()]
-                else:
+            tree = ast.parse(route_file.read_text(encoding="utf-8"))
+            for node in tree.body:
+                if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                     continue
-                raw_path = ast.literal_eval(decorator.args[0])
-                path, parameters = route_path(prefix + raw_path)
-                summary = (ast.get_docstring(node) or node.name.replace("_", " ")).splitlines()[0]
-                for method in methods:
-                    routes.append(
-                        {
-                            "path": path,
-                            "method": method.lower(),
-                            "tag": module,
-                            "operation_id": f"{module}_{node.name}_{method.lower()}",
-                            "summary": summary,
-                            "parameters": parameters,
-                            "source": str(route_file.relative_to(ROOT)),
-                        }
-                    )
+                for decorator in node.decorator_list:
+                    if not isinstance(decorator, ast.Call):
+                        continue
+                    function = decorator.func
+                    if not (
+                        isinstance(function, ast.Attribute)
+                        and isinstance(function.value, ast.Name)
+                        and function.value.id == "bp"
+                        and decorator.args
+                    ):
+                        continue
+                    if function.attr == "route":
+                        methods = ["GET"]
+                        for keyword in decorator.keywords:
+                            if keyword.arg == "methods":
+                                methods = ast.literal_eval(keyword.value)
+                    elif function.attr in SHORTHAND_METHODS:
+                        methods = [function.attr.upper()]
+                    else:
+                        continue
+                    raw_path = ast.literal_eval(decorator.args[0])
+                    path, parameters = route_path(prefix + raw_path)
+                    summary = (
+                        ast.get_docstring(node) or node.name.replace("_", " ")
+                    ).splitlines()[0]
+                    for method in methods:
+                        routes.append(
+                            {
+                                "path": path,
+                                "method": method.lower(),
+                                "tag": module,
+                                "operation_id": f"{module}_{node.name}_{method.lower()}",
+                                "summary": summary,
+                                "parameters": parameters,
+                                "source": str(route_file.relative_to(ROOT)),
+                            }
+                        )
     return sorted(routes, key=lambda item: (str(item["path"]), str(item["method"])))
 
 
@@ -153,54 +219,118 @@ def render(routes: list[dict[str, object]]) -> str:
                 ]
             )
             parameters = operation["parameters"]
+            if path.startswith("/api/datasource/connectors/machine/runs"):
+                parameters = [
+                    *parameters,
+                    {
+                        "name": "X-Connector-Credential",
+                        "type": "string",
+                        "header": True,
+                    },
+                ]
             if parameters:
                 lines.append("      parameters:")
                 for parameter in parameters:
                     lines.extend(
                         [
                             f"        - name: {parameter['name']}",
-                            "          in: path",
+                            f"          in: {'header' if parameter.get('header') else 'path'}",
                             "          required: true",
                             "          schema:",
                             f"            type: {parameter['type']}",
                         ]
                     )
-            if operation["method"] in {"post", "put", "patch"}:
+                    if (
+                        path
+                        == "/api/datasource/connectors/credentials/{credential_uid}/{action}"
+                        and parameter["name"] == "action"
+                    ):
+                        lines.append("            enum: [revoke, rotate]")
+            if (
+                operation["method"] in {"post", "put", "patch"}
+                and path not in NO_REQUEST_BODY_PATHS
+            ):
+                connector_schema = CONNECTOR_REQUEST_SCHEMAS.get(path)
                 lines.extend(
                     [
                         "      requestBody:",
-                        "        required: false",
+                        "        required: "
+                        + (
+                            "false"
+                            if path in OPTIONAL_REQUEST_BODY_PATHS
+                            else "true" if connector_schema else "false"
+                        ),
                         "        content:",
                         "          application/json:",
                         "            schema:",
-                        "              type: object",
-                        "              additionalProperties: true",
+                        f"              $ref: '#/components/schemas/{connector_schema}'"
+                        if connector_schema
+                        else "              type: object",
                     ]
                 )
+                if not connector_schema:
+                    lines.append("              additionalProperties: true")
             response_fields = RESPONSE_FIELDS.get(
                 (str(operation["tag"]), str(operation["operation_id"]).split("_")[-2])
             )
             if response_fields:
                 lines.append(
-                    "      x-response-fields: ["
-                    + ", ".join(response_fields)
-                    + "]"
+                    "      x-response-fields: [" + ", ".join(response_fields) + "]"
+                )
+            connector_response = CONNECTOR_RESPONSE_SCHEMAS.get(
+                (path, str(operation["method"]))
+            )
+            response_status = (
+                "201"
+                if operation["method"] == "post"
+                and path
+                in {
+                    "/api/datasource/connectors/runs",
+                    "/api/datasource/connectors/machine/runs",
+                    "/api/datasource/connectors/principals",
+                    "/api/datasource/connectors/principals/{principal_uid}/credentials",
+                    "/api/datasource/connectors/source-bindings",
+                }
+                else "200"
+            )
+            if path.startswith("/api/datasource/connectors"):
+                required_permission = (
+                    "machine-credential"
+                    if path.startswith("/api/datasource/connectors/machine/runs")
+                    else (
+                        "connectors:manage"
+                        if "/source-bindings" in path
+                        else (
+                            "connectors:read"
+                            if operation["method"] == "get"
+                            else (
+                                "connectors:manage"
+                                if "/principals" in path or "/credentials" in path
+                                else "connectors:operate"
+                            )
+                        )
+                    )
+                )
+                lines.append(
+                    f"      x-required-permission: {quoted(required_permission)}"
                 )
+            elif path == "/api/datasource/graph":
+                lines.append('      x-required-permission: "connectors:read"')
             lines.extend(
                 [
                     "      responses:",
-                    '        "200":',
+                    f'        "{response_status}":',
                     '          description: "请求已由当前实现处理"',
                     "          content:",
                     "            application/json:",
                     "              schema:",
-                    "                $ref: '#/components/schemas/ApiEnvelope'",
+                    f"                $ref: '#/components/schemas/{connector_response or 'ApiEnvelope'}'",
                     "        default:",
                     '          description: "错误响应"',
                     "          content:",
                     "            application/json:",
                     "              schema:",
-                    "                $ref: '#/components/schemas/ApiEnvelope'",
+                    f"                $ref: '#/components/schemas/{'ConnectorErrorEnvelope' if path.startswith('/api/datasource/connectors') or path == '/api/datasource/graph' else 'ApiEnvelope'}'",
                 ]
             )
 
@@ -211,22 +341,190 @@ def render(routes: list[dict[str, object]]) -> str:
             "    ApiEnvelope:",
             "      type: object",
             "      additionalProperties: true",
+            "      required: [code, message, data]",
             "      properties:",
-            "        success:",
-            "          type: boolean",
             "        code:",
             "          type: integer",
             "        message:",
             "          type: string",
             "        data: {}",
-            "        timestamp:",
-            "          type: integer",
+            "        error: {type: object}",
+            "    ConnectorConfigValidationRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, config]",
+            "      properties:",
+            "        connector_id: {type: string, minLength: 3, maxLength: 64}",
+            "        version: {type: string, pattern: '^[0-9]+\\.[0-9]+\\.[0-9]+'}",
+            "        config: {type: object}",
+            "    ConnectorRunRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, source_uid, operation, config, scope, dry_run]",
+            "      properties:",
+            "        connector_id: {type: string}",
+            "        version: {type: string}",
+            "        source_uid: {type: string, format: uuid}",
+            "        operation: {type: string, enum: [discover, snapshot, incremental, lineage, profile, evidence]}",
+            "        config: {type: object}",
+            "        scope: {$ref: '#/components/schemas/ConnectorScope'}",
+            "        cursor: {type: object}",
+            "        checkpoint: {type: object}",
+            "        idempotency_key: {type: string, pattern: '^[a-f0-9]{64}$'}",
+            "        dry_run: {const: true}",
+            "        process_key: {type: string, minLength: 1, maxLength: 300}",
+            "    MachineConnectorRunRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, source_uid, business_domain_uid, environment, process_key, operation, scope]",
+            "      properties:",
+            "        connector_id: {type: string}",
+            "        version: {type: string}",
+            "        source_uid: {type: string, format: uuid}",
+            "        business_domain_uid: {type: string, format: uuid}",
+            "        environment: {type: string, enum: [development, staging, production]}",
+            "        process_key: {type: string, minLength: 1, maxLength: 300}",
+            "        operation: {type: string, enum: [discover, snapshot, incremental, lineage, profile, evidence]}",
+            "        scope: {$ref: '#/components/schemas/ConnectorScope'}",
+            "        cursor: {type: object}",
+            "        checkpoint: {type: object}",
+            "        idempotency_key: {type: string, pattern: '^[a-f0-9]{64}$'}",
+            "        dry_run: {type: boolean, default: false}",
+            "    ConnectorPrincipalRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, source_uid, business_domain_uid, environment, operations, scopes]",
+            "      properties:",
+            "        connector_id: {type: string}",
+            "        version: {type: string}",
+            "        source_uid: {type: string, format: uuid}",
+            "        business_domain_uid: {type: string, format: uuid}",
+            "        environment: {type: string, enum: [development, staging, production]}",
+            "        operations: {type: array, minItems: 1, uniqueItems: true, items: {type: string}}",
+            "        scopes: {$ref: '#/components/schemas/ConnectorScope'}",
+            "        source_binding_uid: {type: string, format: uuid}",
+            "        source_binding_version: {type: integer, minimum: 1}",
+            "    ConnectorSourceBindingRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, source_uid, business_domain_uid, environment, approved_config]",
+            "      properties:",
+            "        binding_uid: {type: string, format: uuid}",
+            "        connector_id: {type: string}",
+            "        version: {type: string}",
+            "        source_uid: {type: string, format: uuid}",
+            "        business_domain_uid: {type: string, format: uuid}",
+            "        environment: {type: string, enum: [development, staging, production]}",
+            "        approved_config: {type: object}",
+            "    ConnectorScope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      properties:",
+            "        include_schemas: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}",
+            "        exclude_schemas: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}",
+            "        include_tables: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}",
+            "        exclude_tables: {type: array, uniqueItems: true, items: {type: string, minLength: 1}}",
+            "    ConnectorHealthRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [config]",
+            "      properties: {config: {type: object}}",
+            "    ConnectorCredentialRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      properties: {ttl_seconds: {type: integer, minimum: 60, maximum: 900}}",
+            "    ConnectorGraphRequest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      properties: {source_uid: {type: string, format: uuid}, business_domain_uid: {type: string, format: uuid}, process_key: {type: string}, run_uid: {type: string, format: uuid}, limit: {type: integer, minimum: 1, maximum: 1000}}",
+            "    ConnectorManifestEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties:",
+            "        code: {type: integer}",
+            "        message: {type: string}",
+            "        data: {type: object, additionalProperties: false, required: [manifests], properties: {manifests: {type: array, items: {$ref: '#/components/schemas/ConnectorManifest'}}}}",
+            "    ConnectorManifest:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [connector_id, version, sdk_version, display_name, capabilities, config_schema]",
+            "      properties: {connector_id: {type: string}, version: {type: string}, sdk_version: {type: string}, display_name: {type: string}, capabilities: {type: array, items: {type: string}}, config_schema: {type: object}}",
+            "    ConnectorOperationResult:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [records, cursor, checkpoint, evidence, status]",
+            "      properties: {records: {type: array, items: {type: object}}, cursor: {type: object}, checkpoint: {type: object}, evidence: {type: object}, status: {type: string, enum: [succeeded, dry_run]}}",
+            "    ConnectorRunResultEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {$ref: '#/components/schemas/ConnectorOperationResult'}}",
+            "    ConnectorRunRecord:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [uid, idempotency_key, connector_id, connector_version, source_uid, operation, status, attempt_count, checkpoint_summary, cursor_summary, dry_run]",
+            "      properties: {uid: {type: string, format: uuid}, idempotency_key: {type: string}, connector_id: {type: string}, connector_version: {type: string}, source_uid: {type: string, format: uuid}, principal_uid: {type: [string, 'null'], format: uuid}, business_domain_uid: {type: [string, 'null'], format: uuid}, environment: {type: [string, 'null']}, process_key: {type: [string, 'null']}, operation: {type: string}, status: {type: string}, attempt_count: {type: integer}, checkpoint_summary: {type: object}, cursor_summary: {type: object}, scope: {type: object}, error_category: {type: [string, 'null']}, dry_run: {type: boolean}, created_at: {type: string}, updated_at: {type: string}}",
+            "    ConnectorRunRecordEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {$ref: '#/components/schemas/ConnectorRunRecord'}}",
+            "    ConnectorRunListEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [runs], properties: {runs: {type: array, items: {$ref: '#/components/schemas/ConnectorRunRecord'}}}}}",
+            "    ConnectorValidationEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, required: [valid, config_keys], properties: {valid: {const: true}, config_keys: {type: array, items: {type: string}}}, additionalProperties: false}}",
+            "    ConnectorErrorEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data, error]",
+            "      properties:",
+            "        code: {type: integer}",
+            "        message: {type: string}",
+            "        data: {type: 'null'}",
+            "        error: {type: object, additionalProperties: false, required: [code, category, retryable], properties: {code: {const: CONNECTOR_ERROR}, category: {type: string, enum: [configuration, conflict, authentication, permission, rate_limit, timeout, upstream, contract, cancelled]}, retryable: {type: boolean}}}",
+            "    ConnectorHealthEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [status, detail], properties: {status: {type: string}, detail: {type: string}}}}",
+            "    ConnectorCompatibilityEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [compatible, connector_version, sdk_version, detail], properties: {compatible: {type: boolean}, connector_version: {type: string}, sdk_version: {type: string}, detail: {type: string}}}}",
+            "    ConnectorPrincipalEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [principal_uid], properties: {principal_uid: {type: string, format: uuid}}}}",
+            "    ConnectorSourceBindingEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [binding_uid, binding_version, status, rebound_principals], properties: {binding_uid: {type: string, format: uuid}, binding_version: {type: integer, minimum: 1}, status: {const: approved}, rebound_principals: {type: integer, minimum: 0}}}}",
+            "    ConnectorCredentialEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {oneOf: [{type: object, additionalProperties: false, required: [credential_uid, credential, expires_in, returned_once], properties: {credential_uid: {type: string, format: uuid}, credential: {type: string, writeOnly: true}, expires_in: {type: integer}, returned_once: {const: true}}}, {type: object, additionalProperties: false, required: [revoked], properties: {revoked: {type: boolean}}}]}}",
+            "    ConnectorGraphEnvelope:",
+            "      type: object",
+            "      additionalProperties: false",
+            "      required: [code, message, data]",
+            "      properties: {code: {type: integer}, message: {type: string}, data: {type: object, additionalProperties: false, required: [nodes, edges, summary], properties: {nodes: {type: array, items: {type: object, additionalProperties: false, required: [type, key], properties: {type: {type: string, enum: [source, asset, process, business_domain, run]}, key: {type: string}}}}, edges: {type: array, items: {type: object}}, summary: {type: object, additionalProperties: false, required: [node_count, edge_count], properties: {node_count: {type: integer}, edge_count: {type: integer}}}}}}",
             "  securitySchemes:",
             "    bearerAuth:",
             "      type: http",
             "      scheme: bearer",
             "      bearerFormat: JWT",
-            "      description: \"下一阶段统一认证方案;当前路由尚未全部接入。\"",
+            '      description: "下一阶段统一认证方案;当前路由尚未全部接入。"',
         ]
     )
     return "\n".join(lines) + "\n"

+ 1635 - 0
tests/integration/test_phase3_wp03_connectors_postgres.py

@@ -0,0 +1,1635 @@
+from __future__ import annotations
+
+import hashlib
+import os
+import subprocess
+import uuid
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import contextmanager
+from pathlib import Path
+from threading import Barrier, Event, Lock
+
+import pytest
+from sqlalchemy import create_engine, inspect, text
+from sqlalchemy.exc import DBAPIError, IntegrityError
+from sqlalchemy.orm import Session
+
+from app.core.connectors.builtin.oracle import OracleConnector
+from app.core.connectors.builtin.rest_catalog import RestCatalogConnector
+from app.core.connectors.builtin.sqlserver import SqlServerConnector
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorConfigurationError,
+    ConnectorRateLimitError,
+    ConnectorUpstreamError,
+)
+from app.core.connectors.identity import ConnectorIdentityRepository
+from app.core.connectors.registry import ConnectorRegistry
+from app.core.connectors.repository import ConnectorRepository
+from app.core.connectors.runtime import (
+    ConnectorRuntime,
+    deterministic_idempotency_key,
+)
+from app.core.connectors.sdk import (
+    SDK_VERSION,
+    SECRET_REF_PATTERN,
+    Connector,
+    ConnectorManifest,
+    OperationRequest,
+    OperationResult,
+)
+
+ROOT = Path(__file__).resolve().parents[2]
+pytestmark = pytest.mark.integration
+
+
+def _uid():
+    return str(uuid.uuid4())
+
+
+def _alembic(url, command, target):
+    subprocess.run(
+        [
+            str(ROOT / ".venv/bin/alembic"),
+            "-c",
+            str(ROOT / "alembic.ini"),
+            command,
+            target,
+        ],
+        cwd=ROOT,
+        env={**os.environ, "SQLALCHEMY_DATABASE_URI": url},
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+
+
+def _clear_connector_test_data(engine):
+    tables = set(inspect(engine).get_table_names(schema="public"))
+    with engine.begin() as connection:
+        for table in (
+            "connector_audit_events",
+            "connector_graph_edges",
+            "connector_evidence",
+            "connector_checkpoints",
+            "connector_run_attempts",
+            "connector_runs",
+            "connector_machine_credentials",
+            "connector_principals",
+            "connector_source_bindings",
+            "connector_manifests",
+            "connector_rate_limits",
+        ):
+            if table in tables:
+                connection.execute(text(f"DELETE FROM public.{table}"))
+
+
+def test_connector_flask_postgres_security_bindings_and_machine_actions(
+    monkeypatch,
+):
+    url = os.environ.get("TEST_DATABASE_URL")
+    if not url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(url, pool_pre_ping=True)
+    _clear_connector_test_data(engine)
+    engine.dispose()
+    _alembic(url, "upgrade", "head")
+    monkeypatch.setenv("DATABASE_URL", url)
+
+    from app import create_app
+
+    actor = _uid()
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {"id": actor, "sub": actor, "roles": ["admin"]},
+    )
+
+    class SpyTransport:
+        def __init__(self):
+            self.calls = []
+
+        def get_json(self, **values):
+            self.calls.append(values)
+            return {
+                "assets": [
+                    {
+                        "key": "approved-asset",
+                        "name": "Approved",
+                        "namespace": "security",
+                        "type": "table",
+                    }
+                ]
+            }
+
+    spy = SpyTransport()
+    registry = ConnectorRegistry()
+    registry.register(RestCatalogConnector(spy))
+    app = create_app()
+    app.config.update(TESTING=True)
+    app.extensions["connector_registry"] = registry
+    client = app.test_client()
+    source, domain = _uid(), _uid()
+
+    binding_response = client.post(
+        "/api/datasource/connectors/source-bindings",
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "approved_config": {
+                "base_url": "https://approved.example.test",
+                "allowed_host": "approved.example.test",
+                "credential_ref": "env:DATAOPS_CONNECTOR_APPROVED",
+            },
+        },
+    )
+    assert binding_response.status_code == 201
+    binding = binding_response.get_json()["data"]
+    principal_response = client.post(
+        "/api/datasource/connectors/principals",
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "operations": ["discover", "cancel", "resume"],
+            "scopes": {},
+            "source_binding_uid": binding["binding_uid"],
+            "source_binding_version": binding["binding_version"],
+        },
+    )
+    assert principal_response.status_code == 201
+    principal = principal_response.get_json()["data"]["principal_uid"]
+
+    rebound_response = client.post(
+        "/api/datasource/connectors/source-bindings",
+        json={
+            "binding_uid": binding["binding_uid"],
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "approved_config": {
+                "base_url": "https://approved.example.test",
+                "allowed_host": "approved.example.test",
+                "credential_ref": "env:DATAOPS_CONNECTOR_APPROVED_V2",
+            },
+        },
+    )
+    assert rebound_response.status_code == 201
+    rebound = rebound_response.get_json()["data"]
+    assert rebound["binding_version"] == 2
+    assert rebound["rebound_principals"] == 1
+    with create_engine(url).connect() as connection:
+        versions = connection.execute(
+            text("""
+                SELECT p.source_binding_version,
+                       ARRAY_AGG(b.status ORDER BY b.binding_version)
+                  FROM public.connector_principals p
+                  JOIN public.connector_source_bindings b
+                    ON b.uid=p.source_binding_uid
+                 WHERE p.uid=CAST(:principal AS uuid)
+                 GROUP BY p.source_binding_version
+            """),
+            {"principal": principal},
+        ).one()
+    assert versions == (2, ["revoked", "approved"])
+
+    def issue(principal_uid):
+        response = client.post(
+            f"/api/datasource/connectors/principals/{principal_uid}/credentials",
+            json={"ttl_seconds": 300},
+        )
+        assert response.status_code == 201
+        return response.get_json()["data"]["credential"]
+
+    hint = uuid.uuid4().hex + uuid.uuid4().hex
+    token = issue(principal)
+    empty_config = client.post(
+        "/api/datasource/connectors/machine/runs",
+        headers={"X-Connector-Credential": token},
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "process_key": "security-probe",
+            "operation": "discover",
+            "config": {},
+            "scope": {},
+            "idempotency_key": hint,
+        },
+    )
+    assert empty_config.status_code == 400 and spy.calls == []
+    attack = client.post(
+        "/api/datasource/connectors/machine/runs",
+        headers={"X-Connector-Credential": token},
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "process_key": "security-probe",
+            "operation": "discover",
+            "config": {
+                "base_url": "https://attacker.example.test",
+                "allowed_host": "attacker.example.test",
+                "credential_ref": "env:DATAOPS_CONNECTOR_APPROVED",
+            },
+            "scope": {},
+            "idempotency_key": hint,
+        },
+    )
+    assert attack.status_code == 400 and spy.calls == []
+
+    created = client.post(
+        "/api/datasource/connectors/machine/runs",
+        headers={"X-Connector-Credential": token},
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": "staging",
+            "process_key": "security-probe",
+            "operation": "discover",
+            "scope": {},
+            "idempotency_key": hint,
+        },
+    )
+    assert created.status_code == 201
+    assert len(spy.calls) == 1
+    assert spy.calls[0]["url"].startswith("https://approved.example.test/")
+    assert spy.calls[0]["allowed_host"] == "approved.example.test"
+    assert spy.calls[0]["credential_ref"] == "env:DATAOPS_CONNECTOR_APPROVED_V2"
+
+    assert client.post(
+        f"/api/datasource/connectors/runs/{hint}/cancel"
+    ).status_code == 400
+    assert client.post(
+        f"/api/datasource/connectors/runs/{hint}/resume"
+    ).status_code == 400
+
+    second_source, second_domain = _uid(), _uid()
+    second_binding = client.post(
+        "/api/datasource/connectors/source-bindings",
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": second_source,
+            "business_domain_uid": second_domain,
+            "environment": "production",
+            "approved_config": {
+                "base_url": "https://second.example.test",
+                "allowed_host": "second.example.test",
+                "credential_ref": "env:DATAOPS_CONNECTOR_SECOND",
+            },
+        },
+    ).get_json()["data"]
+    second_principal = client.post(
+        "/api/datasource/connectors/principals",
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": second_source,
+            "business_domain_uid": second_domain,
+            "environment": "production",
+            "operations": ["discover", "cancel", "resume"],
+            "scopes": {},
+            "source_binding_uid": second_binding["binding_uid"],
+            "source_binding_version": second_binding["binding_version"],
+        },
+    ).get_json()["data"]["principal_uid"]
+    cross = client.post(
+        f"/api/datasource/connectors/machine/runs/{hint}/cancel",
+        headers={"X-Connector-Credential": issue(second_principal)},
+    )
+    assert cross.status_code == 403
+    replay = client.post(
+        f"/api/datasource/connectors/machine/runs/{hint}/cancel",
+        headers={"X-Connector-Credential": token},
+    )
+    assert replay.status_code == 401
+    with create_engine(url).begin() as connection:
+        connection.execute(
+            text("""
+            UPDATE public.connector_runs
+               SET status='running',cancel_requested=FALSE
+             WHERE client_hint_hash=:hint
+            """),
+            {"hint": hashlib.sha256(hint.encode()).hexdigest()},
+        )
+    cancelled = client.post(
+        f"/api/datasource/connectors/machine/runs/{hint}/cancel",
+        headers={"X-Connector-Credential": issue(principal)},
+    )
+    assert cancelled.status_code == 200
+    resumed = client.post(
+        f"/api/datasource/connectors/machine/runs/{hint}/resume",
+        headers={"X-Connector-Credential": issue(principal)},
+    )
+    assert resumed.status_code == 200
+
+    conflict = client.post(
+        "/api/datasource/connectors/machine/runs",
+        headers={"X-Connector-Credential": issue(second_principal)},
+        json={
+            "connector_id": "rest-catalog",
+            "version": "1.0.0",
+            "source_uid": second_source,
+            "business_domain_uid": second_domain,
+            "environment": "production",
+            "process_key": "different-process",
+            "operation": "discover",
+            "scope": {},
+            "idempotency_key": hint,
+        },
+    )
+    assert conflict.status_code == 409
+
+    listed = client.get("/api/datasource/connectors/source-bindings")
+    assert listed.status_code == 200
+    serialized = str(listed.get_json())
+    assert "credential_ref" not in serialized
+    assert "DATAOPS_CONNECTOR_APPROVED" not in serialized
+    with create_engine(url).connect() as connection:
+        persisted = connection.execute(
+            text("""
+            SELECT COALESCE(string_agg(payload::text,''),'')
+              FROM public.connector_evidence
+             WHERE run_uid IN (
+               SELECT uid FROM public.connector_runs WHERE principal_uid=CAST(:principal AS uuid)
+             )
+            """),
+            {"principal": principal},
+        ).scalar_one()
+    assert "DATAOPS_CONNECTOR_APPROVED" not in persisted
+
+    revoked = client.post(
+        f"/api/datasource/connectors/source-bindings/{binding['binding_uid']}/revoke"
+    )
+    assert revoked.status_code == 200
+    assert revoked.get_json()["data"] == {
+        "revoked": True,
+        "principals_deactivated": 1,
+    }
+    with create_engine(url).connect() as connection:
+        statuses = connection.execute(
+            text("""
+                SELECT p.status,COALESCE(MAX(c.status),'none')
+                  FROM public.connector_principals p
+                  LEFT JOIN public.connector_machine_credentials c
+                    ON c.principal_uid=p.uid
+                 WHERE p.uid=CAST(:principal AS uuid)
+                 GROUP BY p.status
+            """),
+            {"principal": principal},
+        ).one()
+    assert statuses[0] == "revoked"
+
+
+def test_oracle_sqlserver_bindings_authentication_and_trusted_environment(monkeypatch):
+    url = os.environ.get("TEST_DATABASE_URL")
+    if not url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(url, pool_pre_ping=True)
+    _clear_connector_test_data(engine)
+    _alembic(url, "upgrade", "head")
+    monkeypatch.setenv("DATABASE_URL", url)
+
+    from app import create_app
+
+    actor = _uid()
+    monkeypatch.setattr(
+        "app.core.system.permissions.authenticate_request",
+        lambda: {"id": actor, "sub": actor, "roles": ["admin"]},
+    )
+    provider_calls = []
+
+    class Rows:
+        def mappings(self):
+            return self
+
+        def all(self):
+            return [
+                {
+                    "schema_name": "APP",
+                    "asset_name": "ORDERS",
+                    "asset_type": "TABLE",
+                    "column_name": "ID",
+                    "ordinal_position": 1,
+                    "data_type": "NUMBER",
+                    "is_nullable": "NO",
+                    "column_default": None,
+                    "column_comment": None,
+                }
+            ]
+
+    class Connection:
+        def execute(self, _statement, _parameters):
+            return Rows()
+
+    @contextmanager
+    def provider(
+        source_uid,
+        purpose,
+        *,
+        environment="production",
+        allow_insecure_development=False,
+    ):
+        provider_calls.append(
+            {
+                "source_uid": source_uid,
+                "purpose": purpose,
+                "environment": environment,
+                "allow_insecure_development": allow_insecure_development,
+            }
+        )
+        yield Connection()
+
+    class TrackingOracle(OracleConnector):
+        seen_configs = []
+
+        def discover(self, request):
+            self.seen_configs.append(dict(request.config))
+            return super().discover(request)
+
+    class TrackingSqlServer(SqlServerConnector):
+        seen_configs = []
+
+        def discover(self, request):
+            self.seen_configs.append(dict(request.config))
+            return super().discover(request)
+
+    registry = ConnectorRegistry()
+    oracle = TrackingOracle(provider)
+    sqlserver = TrackingSqlServer(provider)
+    registry.register(oracle)
+    registry.register(sqlserver)
+    app = create_app()
+    app.config.update(TESTING=True)
+    app.extensions["connector_registry"] = registry
+    client = app.test_client()
+
+    rejected = client.post(
+        "/api/datasource/connectors/principals",
+        json={
+            "connector_id": "oracle",
+            "version": "1.0.0",
+            "source_uid": _uid(),
+            "business_domain_uid": _uid(),
+            "environment": "staging",
+            "operations": ["discover"],
+            "scopes": {},
+        },
+    )
+    assert rejected.status_code == 400
+
+    for connector_id, environment, approved_config in (
+        (
+            "oracle",
+            "staging",
+            {"credential_ref": "env:DATAOPS_CONNECTOR_ORACLE"},
+        ),
+        (
+            "sqlserver",
+            "production",
+            {
+                "credential_ref": "env:DATAOPS_CONNECTOR_SQLSERVER",
+                "allow_insecure_development": True,
+            },
+        ),
+    ):
+        source, domain = _uid(), _uid()
+        binding_response = client.post(
+            "/api/datasource/connectors/source-bindings",
+            json={
+                "connector_id": connector_id,
+                "version": "1.0.0",
+                "source_uid": source,
+                "business_domain_uid": domain,
+                "environment": environment,
+                "approved_config": approved_config,
+            },
+        )
+        assert binding_response.status_code == 201
+        binding = binding_response.get_json()["data"]
+        principal_response = client.post(
+            "/api/datasource/connectors/principals",
+            json={
+                "connector_id": connector_id,
+                "version": "1.0.0",
+                "source_uid": source,
+                "business_domain_uid": domain,
+                "environment": environment,
+                "operations": ["discover"],
+                "scopes": {},
+                "source_binding_uid": binding["binding_uid"],
+                "source_binding_version": binding["binding_version"],
+            },
+        )
+        assert principal_response.status_code == 201
+        principal = principal_response.get_json()["data"]["principal_uid"]
+        credential_response = client.post(
+            f"/api/datasource/connectors/principals/{principal}/credentials"
+        )
+        assert credential_response.status_code == 201
+        token = credential_response.get_json()["data"]["credential"]
+        payload = {
+            "connector_id": connector_id,
+            "version": "1.0.0",
+            "source_uid": source,
+            "business_domain_uid": domain,
+            "environment": environment,
+            "process_key": f"{connector_id}-catalog",
+            "operation": "discover",
+            "scope": {},
+        }
+        if connector_id == "sqlserver":
+            forged = client.post(
+                "/api/datasource/connectors/machine/runs",
+                headers={"X-Connector-Credential": token},
+                json={**payload, "environment": "development"},
+            )
+            assert forged.status_code == 403
+        created = client.post(
+            "/api/datasource/connectors/machine/runs",
+            headers={"X-Connector-Credential": token},
+            json=payload,
+        )
+        assert created.status_code == 201
+        assert created.get_json()["data"]["records"][0]["name"] == "ORDERS"
+
+    assert oracle.seen_configs == [
+        {"credential_ref": "env:DATAOPS_CONNECTOR_ORACLE"}
+    ]
+    assert sqlserver.seen_configs == [
+        {
+            "credential_ref": "env:DATAOPS_CONNECTOR_SQLSERVER",
+            "allow_insecure_development": True,
+        }
+    ]
+    assert provider_calls[0]["environment"] == "production"
+    assert provider_calls[0]["allow_insecure_development"] is False
+    assert provider_calls[1]["environment"] == "production"
+    assert provider_calls[1]["allow_insecure_development"] is True
+    with engine.connect() as connection:
+        configs = connection.execute(
+            text("""
+                SELECT connector_id,safe_config
+                  FROM public.connector_runs
+                 WHERE connector_id IN ('oracle','sqlserver')
+                 ORDER BY connector_id
+            """)
+        ).all()
+    assert configs == [
+        ("oracle", {"credential_ref": "env:DATAOPS_CONNECTOR_ORACLE"}),
+        (
+            "sqlserver",
+            {
+                "credential_ref": "env:DATAOPS_CONNECTOR_SQLSERVER",
+                "allow_insecure_development": True,
+            },
+        ),
+    ]
+    engine.dispose()
+
+
+def test_connector_postgres_attempt_lease_rejects_late_terminal_writes():
+    url = os.environ.get("TEST_DATABASE_URL")
+    if not url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(url, pool_pre_ping=True)
+    _clear_connector_test_data(engine)
+    _alembic(url, "upgrade", "head")
+    actor, source = _uid(), _uid()
+    connector_id = f"lease-test-{uuid.uuid4().hex[:8]}"
+    with engine.begin() as connection:
+        connection.execute(
+            text("""
+            INSERT INTO public.connector_manifests
+              (uid,connector_id,connector_version,sdk_version,display_name,
+               capabilities,config_schema,status,created_by)
+            VALUES(CAST(:uid AS uuid),:connector,'1.0.0','1.0','lease-test',
+                   '["discover","cancel","resume"]'::jsonb,
+                   CAST(:schema AS jsonb),
+                   'active',CAST(:actor AS uuid))
+            """),
+            {
+                "uid": _uid(),
+                "connector": connector_id,
+                "actor": actor,
+                "schema": '{"type":"object","additionalProperties":false,"properties":{}}',
+            },
+        )
+    key = uuid.uuid4().hex + uuid.uuid4().hex
+    first = Session(engine)
+    second = Session(engine)
+    try:
+        repository = ConnectorRepository(first)
+        repository.claim(
+            key,
+            {
+                "idempotency_key": key,
+                "request_hash": key,
+                "client_hint_hash": None,
+                "connector_id": connector_id,
+                "connector_version": "1.0.0",
+                "source_uid": source,
+                "operation": "discover",
+                "config": {},
+                "scope": {},
+                "checkpoint": {},
+                "cursor": {},
+                "dry_run": True,
+                "actor_uid": actor,
+            },
+        )
+        lease_one, lease_two = _uid(), _uid()
+        assert repository.update(
+            key, status="running", attempt_count=1, lease_token=lease_one
+        ).acquired
+        assert repository.cancel(key)["status"] == "cancelled"
+        with Session(engine) as observer:
+            observed = ConnectorRepository(observer).get(key)
+            assert observed["status"] == "cancelled"
+            assert observed["cancel_requested"] is True
+            assert ConnectorRepository(observer).is_cancel_requested(key) is True
+
+        resumed = ConnectorRepository(second).update(
+            key, status="resumable", cancel_requested=False
+        )
+        assert resumed.acquired
+        assert ConnectorRepository(second).update(
+            key, status="running", attempt_count=2, lease_token=lease_two
+        ).acquired
+
+        stale_success = ConnectorRepository(first).update(
+            key,
+            status="succeeded",
+            expected_attempt=1,
+            lease_token=lease_one,
+        )
+        stale_failure = ConnectorRepository(first).update(
+            key,
+            status="failed",
+            error_category="upstream",
+            error_code="late",
+            expected_attempt=1,
+            lease_token=lease_one,
+        )
+        assert stale_success.acquired is False
+        assert stale_failure.acquired is False
+        winner = ConnectorRepository(second).update(
+            key,
+            status="succeeded",
+            expected_attempt=2,
+            lease_token=lease_two,
+        )
+        assert winner.acquired
+        final = ConnectorRepository(second).get(key)
+        assert final["status"] == "succeeded"
+        assert final["attempt_count"] == 2
+        attempts = second.execute(
+            text("""
+            SELECT attempt_number,status,lease_token::text
+              FROM public.connector_run_attempts
+             WHERE run_uid=CAST(:run AS uuid)
+             ORDER BY attempt_number
+            """),
+            {"run": final["uid"]},
+        ).all()
+        assert attempts == [
+            (1, "cancelled", lease_one),
+            (2, "succeeded", lease_two),
+        ]
+    finally:
+        first.close()
+        second.close()
+        engine.dispose()
+
+
+def test_connector_postgres_repository_identity_graph_cas_and_473_rollback():
+    url = os.environ.get("TEST_DATABASE_URL")
+    if not url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(url, pool_pre_ping=True)
+    _clear_connector_test_data(engine)
+    actor, source, domain = _uid(), _uid(), _uid()
+    connector_id, version = f"pg-test-{uuid.uuid4().hex[:8]}", "1.0.0"
+    _alembic(url, "upgrade", "head")
+    try:
+        tables = inspect(engine).get_table_names(schema="public")
+        assert {"connector_runs", "connector_rate_limits"}.issubset(tables)
+        binding_uid = _uid()
+        with engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_manifests
+                  (uid,connector_id,connector_version,sdk_version,display_name,capabilities,config_schema,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,:version,'1.0','test','["discover","cancel","resume"]'::jsonb,
+                       '{"type":"object"}'::jsonb,'active',CAST(:actor AS uuid))
+            """),
+                {
+                    "uid": _uid(),
+                    "connector": connector_id,
+                    "version": version,
+                    "actor": actor,
+                },
+            )
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_source_bindings
+                  (uid,binding_version,connector_id,connector_version,source_uid,
+                   business_domain_uid,environment,approved_config,status,approved_by)
+                VALUES(CAST(:uid AS uuid),1,:connector,:version,CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging',
+                       '{"credential_ref":"env:DATAOPS_CONNECTOR_TEST"}'::jsonb,
+                       'approved',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": binding_uid,
+                    "connector": connector_id,
+                    "version": version,
+                    "source": source,
+                    "domain": domain,
+                    "actor": actor,
+                },
+            )
+
+        idem = uuid.uuid4().hex + uuid.uuid4().hex
+
+        def claim(_index):
+            with engine.begin() as connection:
+                return connection.execute(
+                    text("""
+                    INSERT INTO public.connector_runs
+                      (uid,idempotency_key,connector_id,connector_version,source_uid,operation,status,attempt_count,
+                       checkpoint,cursor,dry_run,actor_uid,safe_config,scope,request_hash)
+                    VALUES(CAST(:uid AS uuid),:key,:connector,:version,CAST(:source AS uuid),'discover','running',0,
+                           '{}'::jsonb,'{}'::jsonb,TRUE,CAST(:actor AS uuid),'{}'::jsonb,'{}'::jsonb,:key)
+                    ON CONFLICT(idempotency_key) DO NOTHING RETURNING uid
+                """),
+                    {
+                        "uid": _uid(),
+                        "key": idem,
+                        "connector": connector_id,
+                        "version": version,
+                        "source": source,
+                        "actor": actor,
+                    },
+                ).scalar_one_or_none()
+
+        with ThreadPoolExecutor(max_workers=4) as executor:
+            assert sum(item is not None for item in executor.map(claim, range(4))) == 1
+
+        principal = _uid()
+        with engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_principals
+                  (uid,connector_id,connector_version,source_uid,business_domain_uid,environment,
+                   allowed_operations,allowed_scopes,status,created_by,
+                   source_binding_uid,source_binding_version)
+                VALUES(CAST(:uid AS uuid),:connector,:version,CAST(:source AS uuid),CAST(:domain AS uuid),
+                       'staging',ARRAY['discover'],'{}'::jsonb,'active',CAST(:actor AS uuid),
+                       CAST(:binding AS uuid),1)
+            """),
+                {
+                    "uid": principal,
+                    "connector": connector_id,
+                    "version": version,
+                    "source": source,
+                    "domain": domain,
+                    "actor": actor,
+                    "binding": binding_uid,
+                },
+            )
+        with pytest.raises(IntegrityError), engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_machine_credentials(uid,principal_uid,token_hash,status,issued_by,expires_at)
+                VALUES(CAST(:uid AS uuid),CAST(:principal AS uuid),:hash,'active',CAST(:actor AS uuid),
+                       CURRENT_TIMESTAMP+INTERVAL '901 seconds')
+            """),
+                {
+                    "uid": _uid(),
+                    "principal": principal,
+                    "hash": "a" * 64,
+                    "actor": actor,
+                },
+            )
+
+        session = Session(engine)
+        identity = ConnectorIdentityRepository(session)
+        issued = identity.issue(principal, ttl_seconds=300, actor_uid=actor)
+        authenticated = identity.authenticate(
+            issued["credential"],
+            connector_id=connector_id,
+            connector_version=version,
+            source_uid=source,
+            business_domain_uid=domain,
+            environment="staging",
+            operation="discover",
+            scope={},
+        )
+        assert authenticated["principal_uid"] == principal
+        with pytest.raises(ConnectorAuthenticationError):
+            identity.authenticate(
+                issued["credential"],
+                connector_id=connector_id,
+                connector_version=version,
+                source_uid=source,
+                business_domain_uid=domain,
+                environment="staging",
+                operation="discover",
+                scope={},
+            )
+        second = identity.issue(principal, ttl_seconds=300, actor_uid=actor)
+        rotated = identity.rotate(
+            second["credential_uid"], ttl_seconds=300, actor_uid=actor
+        )
+        assert identity.revoke(rotated["credential_uid"], actor) is True
+
+        limiter_key = f"{connector_id}:{source}"
+        for _ in range(30):
+            with Session(engine) as limiter_session:
+                ConnectorRepository(limiter_session).acquire_rate_limit(limiter_key)
+        with Session(engine) as limiter_session, pytest.raises(ConnectorRateLimitError):
+            ConnectorRepository(limiter_session).acquire_rate_limit(limiter_key)
+
+        run_key = uuid.uuid4().hex + uuid.uuid4().hex
+        repository = ConnectorRepository(session, principal)
+        repository.claim(
+            run_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": source,
+                "operation": "discover",
+                "checkpoint": {},
+                "cursor": {},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "catalog-sync",
+                "config": {"credential_ref": "secret:test/key"},
+                "scope": {},
+            },
+        )
+        running_graph = repository.graph(run_uid=repository.get(run_key)["uid"])
+        assert running_graph["summary"]["edge_count"] == 3
+        assert {node["type"] for node in running_graph["nodes"]} == {
+            "source",
+            "process",
+            "business_domain",
+            "run",
+        }
+        assert {edge["run_status"] for edge in running_graph["edges"]} == {"running"}
+        run_lease = _uid()
+        repository.update(
+            run_key, status="running", attempt_count=1, lease_token=run_lease
+        )
+        repository.update(
+            run_key,
+            status="succeeded",
+            result=OperationResult(
+                records=({"asset_key": f"{source}:APP.ORDERS"},),
+                cursor={"page": 2},
+                checkpoint={"cursor": {"page": 2}, "snapshot": [{"key": "orders"}]},
+                evidence={"safe": True},
+            ),
+            checkpoint={"cursor": {"page": 2}},
+            cursor={"page": 2},
+            error_category=None,
+            expected_attempt=1,
+            lease_token=run_lease,
+        )
+        graph = repository.graph(source_uid=source, business_domain_uid=domain)
+        assert {node["type"] for node in graph["nodes"]} == {
+            "source",
+            "asset",
+            "process",
+            "business_domain",
+            "run",
+        }
+        assert graph["summary"]["edge_count"] == 5
+        process_graph = repository.graph(process_key="catalog-sync")
+        assert process_graph["summary"]["edge_count"] >= 1
+        assert {"process", "run"}.issubset(
+            {node["type"] for node in process_graph["nodes"]}
+        )
+
+        failed_key = uuid.uuid4().hex + uuid.uuid4().hex
+        repository.claim(
+            failed_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": source,
+                "operation": "discover",
+                "checkpoint": {"page": 4},
+                "cursor": {"page": 4},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "failed-catalog-sync",
+                "config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+                "scope": {},
+            },
+        )
+        failed_lease = _uid()
+        repository.update(
+            failed_key,
+            status="running",
+            attempt_count=1,
+            lease_token=failed_lease,
+        )
+        failed = repository.update(
+            failed_key,
+            status="failed",
+            error_category="upstream",
+            error_code="ConnectorUpstreamError",
+            expected_attempt=1,
+            lease_token=failed_lease,
+        ).record
+        assert failed["checkpoint"] == {"page": 4}
+        failed_graph = repository.graph(run_uid=failed["uid"])
+        assert failed_graph["summary"]["edge_count"] == 3
+        assert {edge["run_status"] for edge in failed_graph["edges"]} == {"failed"}
+
+        cancelled_key = uuid.uuid4().hex + uuid.uuid4().hex
+        repository.claim(
+            cancelled_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": source,
+                "operation": "discover",
+                "checkpoint": {"page": 1},
+                "cursor": {"page": 1},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "catalog-sync",
+                "config": {"credential_ref": "secret:test/key"},
+                "scope": {},
+            },
+        )
+        cancelled = Event()
+
+        def cancel_worker():
+            with Session(engine) as worker_session:
+                result = ConnectorRepository(worker_session, principal).cancel(
+                    cancelled_key
+                )
+            cancelled.set()
+            return result
+
+        def completion_worker():
+            assert cancelled.wait(timeout=5)
+            with Session(engine) as worker_session:
+                return ConnectorRepository(worker_session, principal).update(
+                    cancelled_key,
+                    status="succeeded",
+                    result=OperationResult(),
+                    checkpoint={},
+                    cursor={},
+                    error_category=None,
+                )
+
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            cancel_future = executor.submit(cancel_worker)
+            complete_future = executor.submit(completion_worker)
+            assert cancel_future.result()["status"] == "cancelled"
+            completion_outcome = complete_future.result()
+        assert completion_outcome.acquired is False
+        after = completion_outcome.record
+        assert after["status"] == "cancelled"
+        cancelled_graph = repository.graph(run_uid=after["uid"])
+        assert cancelled_graph["summary"]["edge_count"] == 3
+        assert {edge["run_status"] for edge in cancelled_graph["edges"]} == {
+            "cancelled"
+        }
+
+        with pytest.raises(IntegrityError), engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_runs(uid,idempotency_key,connector_id,connector_version,source_uid,
+                  operation,status,attempt_count,checkpoint,cursor,dry_run,actor_uid,safe_config,scope)
+                VALUES(CAST(:uid AS uuid),:key,:connector,:version,CAST(:source AS uuid),'discover','running',0,
+                  '{}'::jsonb,'{}'::jsonb,FALSE,CAST(:actor AS uuid),'{}'::jsonb,'{}'::jsonb)
+            """),
+                {
+                    "uid": _uid(),
+                    "key": uuid.uuid4().hex + uuid.uuid4().hex,
+                    "connector": connector_id,
+                    "version": version,
+                    "source": source,
+                    "actor": actor,
+                },
+            )
+
+        class RuntimeProbeConnector(Connector):
+            manifest = ConnectorManifest(
+                connector_id=connector_id,
+                version=version,
+                sdk_version=SDK_VERSION,
+                display_name="PostgreSQL Runtime Probe",
+                capabilities=(
+                    "discover",
+                    "snapshot",
+                    "incremental",
+                    "lineage",
+                    "profile",
+                    "cancel",
+                    "resume",
+                    "evidence",
+                ),
+                config_schema={
+                    "type": "object",
+                    "additionalProperties": False,
+                    "required": ["credential_ref"],
+                    "properties": {
+                        "credential_ref": {
+                            "type": "string",
+                            "pattern": SECRET_REF_PATTERN.pattern,
+                        }
+                    },
+                },
+            )
+
+            def __init__(self):
+                self.discover_calls = 0
+                self.resume_calls = 0
+                self.discover_succeeds = False
+                self.resume_failures = 2
+                self.call_lock = Lock()
+
+            def discover(self, request):
+                with self.call_lock:
+                    self.discover_calls += 1
+                if self.discover_succeeds:
+                    return OperationResult(evidence={"concurrent": True})
+                raise ConnectorUpstreamError()
+
+            snapshot = incremental = lineage = profile = evidence = discover
+
+            def cancel(self, request):
+                return OperationResult(status="cancelled")
+
+            def resume(self, request):
+                with self.call_lock:
+                    self.resume_calls += 1
+                    call = self.resume_calls
+                if call <= self.resume_failures:
+                    raise ConnectorUpstreamError()
+                return OperationResult(
+                    checkpoint={"page": 8},
+                    cursor={"page": 9},
+                    evidence={"resumed": True},
+                )
+
+        probe = RuntimeProbeConnector()
+        registry = ConnectorRegistry()
+        registry.register(probe)
+
+        capped_source = _uid()
+        capped_request = OperationRequest(
+            source_uid=capped_source,
+            operation="discover",
+            config={"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+            principal_uid=principal,
+            business_domain_uid=domain,
+            environment="staging",
+            process_key="attempt-cap",
+        )
+        capped_runtime = ConnectorRuntime(
+            registry,
+            store=ConnectorRepository(session, principal),
+            sleeper=lambda _seconds: None,
+        )
+        with pytest.raises(ConnectorUpstreamError):
+            capped_runtime.execute(connector_id, version, capped_request)
+        with pytest.raises(ConnectorUpstreamError):
+            capped_runtime.execute(connector_id, version, capped_request)
+        with pytest.raises(ConnectorConfigurationError):
+            capped_runtime.execute(connector_id, version, capped_request)
+        capped_key = capped_request.idempotency_key or deterministic_idempotency_key(
+            connector_id, version, capped_request
+        )
+        capped = ConnectorRepository(session).get(capped_key)
+        assert capped["attempt_count"] == 5 and probe.discover_calls == 5
+        attempts = session.execute(
+            text("""
+            SELECT COUNT(*),MAX(attempt_number)
+              FROM public.connector_run_attempts
+             WHERE run_uid=CAST(:run AS uuid)
+        """),
+            {"run": capped["uid"]},
+        ).one()
+        assert attempts == (5, 5)
+
+        rejected_source = _uid()
+        rejected_key = f"{connector_id}:{rejected_source}"
+        for _ in range(30):
+            ConnectorRepository(session).acquire_rate_limit(rejected_key)
+        rejected_request = OperationRequest(
+            source_uid=rejected_source,
+            operation="discover",
+            config={"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+            dry_run=True,
+        )
+        rejected_idempotency = deterministic_idempotency_key(
+            connector_id, version, rejected_request
+        )
+        with pytest.raises(ConnectorRateLimitError):
+            ConnectorRuntime(
+                registry, store=ConnectorRepository(session), sleeper=lambda _: None
+            ).execute(connector_id, version, rejected_request)
+        assert ConnectorRepository(session).get(rejected_idempotency) is None
+
+        resume_source = _uid()
+        resume_key = uuid.uuid4().hex + uuid.uuid4().hex
+        resume_repository = ConnectorRepository(session, principal)
+        resume_repository.claim(
+            resume_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": resume_source,
+                "operation": "discover",
+                "checkpoint": {"page": 7},
+                "cursor": {"page": 7},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "resume-state-machine",
+                "config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+                "scope": {},
+            },
+        )
+        resume_repository.update(
+            resume_key, status="running", attempt_count=1, lease_token=_uid()
+        )
+        resume_repository.cancel(resume_key)
+        resume_runtime = ConnectorRuntime(
+            registry,
+            store=resume_repository,
+            max_attempts=2,
+            sleeper=lambda _seconds: None,
+        )
+        with pytest.raises(ConnectorUpstreamError):
+            resume_runtime.resume(resume_key)
+        resume_failed = resume_repository.get(resume_key)
+        assert resume_failed["status"] == "failed"
+        assert resume_failed["attempt_count"] == 3
+        assert resume_failed["checkpoint"] == {"page": 7}
+        resumed_result = resume_runtime.resume(resume_key)
+        assert resumed_result.checkpoint == {"page": 8}
+        resumed = resume_repository.get(resume_key)
+        assert resumed["status"] == "succeeded" and resumed["attempt_count"] == 4
+        persisted = session.execute(
+            text("""
+            SELECT
+              (SELECT COUNT(*) FROM public.connector_run_attempts WHERE run_uid=CAST(:run AS uuid)),
+              (SELECT COUNT(*) FROM public.connector_evidence WHERE run_uid=CAST(:run AS uuid)),
+              (SELECT COUNT(*) FROM public.connector_checkpoints WHERE run_uid=CAST(:run AS uuid))
+        """),
+            {"run": resumed["uid"]},
+        ).one()
+        assert persisted == (4, 1, 1)
+
+        probe.discover_succeeds = True
+        probe.discover_calls = 0
+        retry_hint = uuid.uuid4().hex + uuid.uuid4().hex
+        retry_source = _uid()
+        retry_request = OperationRequest(
+            source_uid=retry_source,
+            operation="discover",
+            config={"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+            idempotency_key=retry_hint,
+            principal_uid=principal,
+            business_domain_uid=domain,
+            environment="staging",
+            process_key="concurrent-retry",
+        )
+        retry_key = deterministic_idempotency_key(
+            connector_id, version, retry_request
+        )
+        retry_seed = ConnectorRepository(session, principal)
+        retry_seed.claim(
+            retry_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": retry_source,
+                "operation": "discover",
+                "checkpoint": {"page": 1},
+                "cursor": {"page": 1},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "concurrent-retry",
+                "config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+                "scope": {},
+            },
+        )
+        retry_lease = _uid()
+        retry_seed.update(
+            retry_key, status="running", attempt_count=1, lease_token=retry_lease
+        )
+        retry_seed.update(
+            retry_key,
+            status="failed",
+            error_category="upstream",
+            error_code="ConnectorUpstreamError",
+            expected_attempt=1,
+            lease_token=retry_lease,
+        )
+        retry_gate = Barrier(2)
+
+        class RetryBarrierRepository(ConnectorRepository):
+            def claim(self, key, record):
+                claimed, created = super().claim(key, record)
+                if not created:
+                    retry_gate.wait(timeout=5)
+                return claimed, created
+
+        def competing_retry():
+            with Session(engine) as worker_session:
+                worker_session.execute(text("SET statement_timeout='5s'"))
+                worker_session.commit()
+                try:
+                    result = ConnectorRuntime(
+                        registry,
+                        store=RetryBarrierRepository(worker_session, principal),
+                        max_attempts=1,
+                        sleeper=lambda _seconds: None,
+                    ).execute(connector_id, version, retry_request)
+                    return "success", result.status
+                except Exception as error:
+                    return "error", type(error).__name__
+
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            retry_futures = [executor.submit(competing_retry) for _index in range(2)]
+            retry_results = [future.result(timeout=10) for future in retry_futures]
+        assert sorted(item[0] for item in retry_results) == ["error", "success"]
+        assert [item[1] for item in retry_results if item[0] == "error"] == [
+            "ConnectorConfigurationError"
+        ]
+        assert probe.discover_calls == 1
+        retried = ConnectorRepository(session).get(retry_key)
+        assert retried["status"] == "succeeded" and retried["attempt_count"] == 2
+        retry_attempts = session.execute(
+            text("""
+            SELECT ARRAY_AGG(attempt_number ORDER BY attempt_number)
+              FROM public.connector_run_attempts
+             WHERE run_uid=CAST(:run AS uuid)
+        """),
+            {"run": retried["uid"]},
+        ).scalar_one()
+        assert retry_attempts == [1, 2]
+
+        probe.resume_failures = 0
+        probe.resume_calls = 0
+        competing_resume_key = uuid.uuid4().hex + uuid.uuid4().hex
+        competing_resume_source = _uid()
+        resume_seed = ConnectorRepository(session, principal)
+        resume_seed.claim(
+            competing_resume_key,
+            {
+                "connector_id": connector_id,
+                "connector_version": version,
+                "source_uid": competing_resume_source,
+                "operation": "discover",
+                "checkpoint": {"page": 5},
+                "cursor": {"page": 5},
+                "dry_run": False,
+                "principal_uid": principal,
+                "business_domain_uid": domain,
+                "environment": "staging",
+                "process_key": "concurrent-resume",
+                "config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+                "scope": {},
+            },
+        )
+        resume_seed.update(
+            competing_resume_key,
+            status="running",
+            attempt_count=1,
+            lease_token=_uid(),
+        )
+        resume_seed.cancel(competing_resume_key)
+        resume_gate = Barrier(2)
+
+        class ResumeBarrierRepository(ConnectorRepository):
+            def __init__(self, worker_session, actor_uid):
+                super().__init__(worker_session, actor_uid)
+                self.waited = False
+
+            def get(self, key):
+                record = super().get(key)
+                if not self.waited and record and record.get("status") == "cancelled":
+                    self.waited = True
+                    resume_gate.wait(timeout=5)
+                return record
+
+        def competing_resume():
+            with Session(engine) as worker_session:
+                worker_session.execute(text("SET statement_timeout='5s'"))
+                worker_session.commit()
+                try:
+                    result = ConnectorRuntime(
+                        registry,
+                        store=ResumeBarrierRepository(worker_session, principal),
+                        max_attempts=1,
+                        sleeper=lambda _seconds: None,
+                    ).resume(competing_resume_key)
+                    return "success", result.status
+                except Exception as error:
+                    return "error", type(error).__name__
+
+        with ThreadPoolExecutor(max_workers=2) as executor:
+            resume_futures = [executor.submit(competing_resume) for _index in range(2)]
+            resume_results = [future.result(timeout=10) for future in resume_futures]
+        assert sorted(item[0] for item in resume_results) == ["error", "success"]
+        assert [item[1] for item in resume_results if item[0] == "error"] == [
+            "ConnectorConfigurationError"
+        ]
+        assert probe.resume_calls == 1
+        concurrently_resumed = ConnectorRepository(session).get(competing_resume_key)
+        assert concurrently_resumed["status"] == "succeeded"
+        assert concurrently_resumed["attempt_count"] == 2
+        resume_attempts = session.execute(
+            text("""
+            SELECT ARRAY_AGG(attempt_number ORDER BY attempt_number)
+              FROM public.connector_run_attempts
+             WHERE run_uid=CAST(:run AS uuid)
+        """),
+            {"run": concurrently_resumed["uid"]},
+        ).scalar_one()
+        assert resume_attempts == [1, 2]
+        session.close()
+
+        with engine.begin() as connection:
+            for table in (
+                "connector_audit_events",
+                "connector_graph_edges",
+                "connector_evidence",
+                "connector_checkpoints",
+                "connector_run_attempts",
+                "connector_runs",
+                "connector_machine_credentials",
+                "connector_principals",
+                "connector_source_bindings",
+                "connector_manifests",
+                "connector_rate_limits",
+            ):
+                connection.execute(text(f"DELETE FROM public.{table}"))
+        _alembic(url, "downgrade", "20260802_472")
+
+        ambiguous_connector = f"ambiguous-{uuid.uuid4().hex[:8]}"
+        ambiguous_source, ambiguous_domain = _uid(), _uid()
+        with engine.begin() as connection:
+            for ambiguous_version in ("1.0.0", "2.0.0"):
+                connection.execute(
+                    text("""
+                    INSERT INTO public.connector_manifests
+                      (uid,connector_id,connector_version,sdk_version,display_name,
+                       capabilities,config_schema,status,created_by)
+                    VALUES(CAST(:uid AS uuid),:connector,:version,'1.0','ambiguous',
+                           '["discover"]'::jsonb,'{"type":"object"}'::jsonb,
+                           'active',CAST(:actor AS uuid))
+                    """),
+                    {
+                        "uid": _uid(),
+                        "connector": ambiguous_connector,
+                        "version": ambiguous_version,
+                        "actor": actor,
+                    },
+                )
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_principals
+                  (uid,connector_id,source_uid,business_domain_uid,environment,
+                   allowed_operations,allowed_scopes,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging',ARRAY['discover'],
+                       '{}'::jsonb,'active',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": _uid(),
+                    "connector": ambiguous_connector,
+                    "source": ambiguous_source,
+                    "domain": ambiguous_domain,
+                    "actor": actor,
+                },
+            )
+        with pytest.raises(subprocess.CalledProcessError) as ambiguous_upgrade:
+            _alembic(url, "upgrade", "20260802_473")
+        assert "rejected before backfill" in ambiguous_upgrade.value.stderr
+        with engine.connect() as connection:
+            assert connection.execute(
+                text("SELECT version_num FROM alembic_version")
+            ).scalar_one() == "20260802_472"
+        assert "connector_version" not in {
+            item["name"]
+            for item in inspect(engine).get_columns(
+                "connector_principals", schema="public"
+            )
+        }
+
+        single_connector = f"single-{uuid.uuid4().hex[:8]}"
+        single_principal, single_source, single_domain = _uid(), _uid(), _uid()
+        with engine.begin() as connection:
+            connection.execute(
+                text("DELETE FROM public.connector_principals")
+            )
+            connection.execute(text("DELETE FROM public.connector_manifests"))
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_manifests
+                  (uid,connector_id,connector_version,sdk_version,display_name,
+                   capabilities,config_schema,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,'1.0.0','1.0','single',
+                       '["discover"]'::jsonb,'{"type":"object"}'::jsonb,
+                       'active',CAST(:actor AS uuid))
+                """),
+                {"uid": _uid(), "connector": single_connector, "actor": actor},
+            )
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_principals
+                  (uid,connector_id,source_uid,business_domain_uid,environment,
+                   allowed_operations,allowed_scopes,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging',ARRAY['discover'],
+                       '{}'::jsonb,'active',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": single_principal,
+                    "connector": single_connector,
+                    "source": single_source,
+                    "domain": single_domain,
+                    "actor": actor,
+                },
+            )
+        _alembic(url, "upgrade", "20260802_475")
+        with pytest.raises(subprocess.CalledProcessError) as unbound_upgrade:
+            _alembic(url, "upgrade", "20260802_476")
+        assert "bind every active enterprise principal first" in (
+            unbound_upgrade.value.stderr
+        )
+
+        binding_uid = _uid()
+        revoked_principal = _uid()
+        revoked_source, revoked_domain, revoked_binding = _uid(), _uid(), _uid()
+        with engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_source_bindings
+                  (uid,binding_version,connector_id,connector_version,source_uid,
+                   business_domain_uid,environment,approved_config,status,approved_by)
+                VALUES(CAST(:uid AS uuid),1,:connector,'1.0.0',CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging','{}'::jsonb,'approved',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": binding_uid,
+                    "connector": single_connector,
+                    "source": single_source,
+                    "domain": single_domain,
+                    "actor": actor,
+                },
+            )
+            connection.execute(
+                text("""
+                UPDATE public.connector_principals
+                   SET source_binding_uid=CAST(:binding AS uuid),source_binding_version=1
+                 WHERE uid=CAST(:principal AS uuid)
+                """),
+                {"binding": binding_uid, "principal": single_principal},
+            )
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_principals
+                  (uid,connector_id,connector_version,source_uid,business_domain_uid,
+                   environment,allowed_operations,allowed_scopes,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,'1.0.0',CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging',ARRAY['discover'],
+                       '{}'::jsonb,'revoked',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": revoked_principal,
+                    "connector": single_connector,
+                    "source": revoked_source,
+                    "domain": revoked_domain,
+                    "actor": actor,
+                },
+            )
+        _alembic(url, "upgrade", "head")
+        with pytest.raises(DBAPIError), engine.begin() as connection:
+            connection.execute(
+                text("""
+                UPDATE public.connector_principals SET status='active'
+                 WHERE uid=CAST(:principal AS uuid)
+                """),
+                {"principal": revoked_principal},
+            )
+        with engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_source_bindings
+                  (uid,binding_version,connector_id,connector_version,source_uid,
+                   business_domain_uid,environment,approved_config,status,approved_by)
+                VALUES(CAST(:uid AS uuid),1,:connector,'1.0.0',CAST(:source AS uuid),
+                       CAST(:domain AS uuid),'staging','{}'::jsonb,'approved',CAST(:actor AS uuid))
+                """),
+                {
+                    "uid": revoked_binding,
+                    "connector": single_connector,
+                    "source": revoked_source,
+                    "domain": revoked_domain,
+                    "actor": actor,
+                },
+            )
+            connection.execute(
+                text("""
+                UPDATE public.connector_principals
+                   SET source_binding_uid=CAST(:binding AS uuid),source_binding_version=1
+                 WHERE uid=CAST(:principal AS uuid)
+                """),
+                {"binding": revoked_binding, "principal": revoked_principal},
+            )
+            connection.execute(
+                text("""
+                UPDATE public.connector_principals SET status='active'
+                 WHERE uid=CAST(:principal AS uuid)
+                """),
+                {"principal": revoked_principal},
+            )
+            assert connection.execute(
+                text("""
+                SELECT status FROM public.connector_principals
+                 WHERE uid=CAST(:principal AS uuid)
+                """),
+                {"principal": revoked_principal},
+            ).scalar_one() == "active"
+        with pytest.raises(DBAPIError), engine.begin() as connection:
+            connection.execute(
+                text("""
+                INSERT INTO public.connector_manifests
+                  (uid,connector_id,connector_version,sdk_version,display_name,
+                   capabilities,config_schema,status,created_by)
+                VALUES(CAST(:uid AS uuid),:connector,'2.0.0','1.0','ambiguous',
+                       '["discover"]'::jsonb,'{"type":"object"}'::jsonb,
+                       'active',CAST(:actor AS uuid))
+                """),
+                {"uid": _uid(), "connector": single_connector, "actor": actor},
+            )
+        with pytest.raises(subprocess.CalledProcessError) as guarded_downgrade:
+            _alembic(url, "downgrade", "20260802_474")
+        assert "explicitly remove source bindings" in guarded_downgrade.value.stderr
+        _alembic(url, "downgrade", "20260802_475")
+        with engine.begin() as connection:
+            connection.execute(
+                text("""
+                UPDATE public.connector_principals
+                   SET source_binding_uid=NULL,source_binding_version=NULL
+                 WHERE uid IN (CAST(:principal AS uuid),CAST(:revoked AS uuid))
+                """),
+                {"principal": single_principal, "revoked": revoked_principal},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.connector_source_bindings WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": binding_uid},
+            )
+            connection.execute(
+                text(
+                    "DELETE FROM public.connector_source_bindings WHERE uid=CAST(:uid AS uuid)"
+                ),
+                {"uid": revoked_binding},
+            )
+        _alembic(url, "downgrade", "20260802_474")
+        assert "connector_source_bindings" not in inspect(engine).get_table_names(
+            schema="public"
+        )
+        _clear_connector_test_data(engine)
+        _alembic(url, "upgrade", "head")
+    finally:
+        _clear_connector_test_data(engine)
+        _alembic(url, "upgrade", "head")
+        engine.dispose()

+ 1694 - 0
tests/test_phase3_wp03_enterprise_connectors.py

@@ -0,0 +1,1694 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+import yaml
+from jsonschema import Draft202012Validator
+
+from app.core.connectors import (
+    SDK_VERSION,
+    Connector,
+    ConnectorConfigurationError,
+    ConnectorManifest,
+    ConnectorRegistry,
+    ConnectorUpstreamError,
+    EnvironmentSecretResolver,
+    OperationRequest,
+    OperationResult,
+    validate_config,
+)
+from app.core.connectors.builtin import (
+    OracleConnector,
+    RestCatalogConnector,
+    SafeRestTransport,
+    SqlServerConnector,
+)
+from app.core.connectors.errors import (
+    ConnectorAuthenticationError,
+    ConnectorContractError,
+    ConnectorDriverUnavailableError,
+    ConnectorPermissionError,
+    ConnectorRateLimitError,
+    ConnectorTimeoutError,
+    classify_error,
+)
+from app.core.connectors.identity import ConnectorIdentityRepository
+from app.core.connectors.runtime import (
+    MAX_TOTAL_ATTEMPTS,
+    ConnectorRuntime,
+    InMemoryRunStore,
+    SlidingWindowLimiter,
+    deterministic_idempotency_key,
+    redact_evidence,
+    snapshot_diff,
+)
+from app.core.data_source.adapters.oracle import OracleAdapter
+from app.core.data_source.adapters.sqlserver import SqlServerAdapter
+from app.core.data_source.errors import (
+    DataSourceConfigurationInvalid,
+    DataSourceDriverUnavailable,
+)
+from app.core.data_source.models import DataSourceCredential, DataSourceDefinition
+
+ROOT = Path(__file__).resolve().parents[1]
+ALL_CAPABILITIES = (
+    "discover",
+    "snapshot",
+    "incremental",
+    "lineage",
+    "profile",
+    "cancel",
+    "resume",
+    "evidence",
+)
+
+
+class _Rows:
+    def __init__(self, rows):
+        self.rows = rows
+
+    def mappings(self):
+        return self
+
+    def all(self):
+        return self.rows
+
+
+class _Connection:
+    def __init__(self, rows):
+        self.rows = rows
+        self.statement = None
+        self.parameters = None
+
+    def execute(self, statement, parameters):
+        self.statement = str(statement)
+        self.parameters = parameters
+        return _Rows(self.rows)
+
+
+@contextmanager
+def _provider(_source, purpose):
+    assert purpose == "metadata_collection"
+    yield _Connection(
+        [
+            {
+                "schema_name": "APP",
+                "asset_name": "ORDERS",
+                "asset_type": "TABLE",
+                "column_name": "ID",
+                "ordinal_position": 1,
+                "data_type": "NUMBER",
+                "is_nullable": "NO",
+                "column_default": None,
+                "column_comment": "key",
+            }
+        ]
+    )
+
+
+def _request(operation="discover", **values):
+    defaults = {
+        "source_uid": "11111111-1111-4111-8111-111111111111",
+        "operation": operation,
+        "config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+        "scope": {},
+    }
+    defaults.update(values)
+    return OperationRequest(**defaults)
+
+
+def test_manifest_config_and_secret_boundary_are_strict():
+    manifest = OracleConnector.manifest
+    assert manifest.sdk_version == SDK_VERSION
+    assert set(manifest.capabilities) == {
+        "discover",
+        "snapshot",
+        "incremental",
+        "cancel",
+        "resume",
+        "evidence",
+    }
+    registry = ConnectorRegistry()
+    registry.register(OracleConnector(_provider))
+    with pytest.raises(ConnectorConfigurationError):
+        registry.resolve("oracle", "1.0.0", "lineage")
+    assert registry.validate("oracle", "1.0.0", {"credential_ref": "vault:team/oracle"})
+    with pytest.raises(ConnectorConfigurationError):
+        registry.validate("oracle", "1.0.0", {"password": "plain"})
+    with pytest.raises(ConnectorConfigurationError):
+        registry.validate("oracle", "9.9.9", {"credential_ref": "env:X"})
+    with pytest.raises(ConnectorConfigurationError):
+        ConnectorManifest(
+            "bad",
+            "1",
+            SDK_VERSION,
+            "Bad",
+            ("discover",),
+            {"type": "object", "additionalProperties": True, "properties": {}},
+        )
+
+
+def test_draft_202012_schema_recursively_enforces_nested_arrays_bounds_and_one_of():
+    schema = {
+        "$schema": "https://json-schema.org/draft/2020-12/schema",
+        "type": "object",
+        "additionalProperties": False,
+        "required": ["nested"],
+        "properties": {
+            "nested": {
+                "type": "object",
+                "additionalProperties": False,
+                "required": ["items", "mode"],
+                "properties": {
+                    "items": {
+                        "type": "array",
+                        "minItems": 1,
+                        "maxItems": 2,
+                        "items": {"type": "integer", "minimum": 1, "maximum": 5},
+                    },
+                    "mode": {"oneOf": [{"const": "a"}, {"const": "b"}]},
+                },
+            }
+        },
+    }
+    assert validate_config(schema, {"nested": {"items": [1, 5], "mode": "a"}})
+    for invalid in (
+        {"nested": {"items": [], "mode": "a"}},
+        {"nested": {"items": [6], "mode": "a"}},
+        {"nested": {"items": [1], "mode": "c"}},
+        {"nested": {"items": [1], "mode": "a", "extra": True}},
+    ):
+        with pytest.raises(ConnectorConfigurationError):
+            validate_config(schema, invalid)
+    with pytest.raises(ConnectorConfigurationError):
+        ConnectorManifest(
+            "nested-secret",
+            "1.0.0",
+            SDK_VERSION,
+            "Nested Secret",
+            ("discover",),
+            {
+                "type": "object",
+                "additionalProperties": False,
+                "properties": {
+                    "nested": {
+                        "type": "object",
+                        "properties": {"password": {"type": "string"}},
+                    }
+                },
+            },
+        )
+
+
+@pytest.mark.parametrize("operation", ALL_CAPABILITIES)
+def test_all_unified_interfaces_are_callable_and_undeclared_capability_is_denied(
+    operation,
+):
+    connector = OracleConnector(_provider)
+    result = getattr(connector, operation)(_request(operation))
+    assert isinstance(result, OperationResult)
+
+    class DiscoverOnly(Connector):
+        manifest = ConnectorManifest(
+            "discover-only",
+            "1.0.0",
+            SDK_VERSION,
+            "Discover",
+            ("discover",),
+            {"type": "object", "additionalProperties": False, "properties": {}},
+        )
+
+        def discover(self, request):
+            return OperationResult()
+
+        snapshot = incremental = lineage = profile = cancel = resume = evidence = (
+            discover
+        )
+
+    registry = ConnectorRegistry()
+    registry.register(DiscoverOnly())
+    with pytest.raises(ConnectorConfigurationError):
+        registry.resolve("discover-only", "1.0.0", "snapshot")
+
+
+@pytest.mark.parametrize(
+    "connector_cls, sql_marker",
+    [(OracleConnector, "all_tab_columns"), (SqlServerConnector, "sys.columns")],
+)
+def test_database_connectors_normalize_scope_and_use_read_only_catalog_sql(
+    connector_cls, sql_marker
+):
+    connector = connector_cls(_provider)
+    result = connector.discover(
+        _request(scope={"include_schemas": ["APP"], "include_tables": ["ORDERS"]})
+    )
+    assert result.records[0]["asset_key"].endswith(":APP.ORDERS")
+    assert result.records[0]["nullable"] is False
+    assert result.evidence["query_kind"] == "read_only_metadata"
+    assert sql_marker in connector.catalog_sql
+    assert not any(
+        word in connector.catalog_sql.upper()
+        for word in ("INSERT ", "UPDATE ", "DELETE ")
+    )
+
+
+def test_database_incremental_persists_bounded_snapshot_summary():
+    result = OracleConnector(_provider).incremental(
+        _request(
+            "incremental",
+            checkpoint={"snapshot_summary": {"sha256": "old", "record_count": 2}},
+        )
+    )
+    diff = result.evidence["diff"]
+    assert diff == {
+        "changed": True,
+        "previous_record_count": 2,
+        "current_record_count": 1,
+        "details_materialized": False,
+    }
+    assert "snapshot" not in result.checkpoint
+    assert result.checkpoint["snapshot_summary"]["record_count"] == 1
+
+
+class _HttpResponse:
+    def __init__(self, status, body, content_type="application/json"):
+        self.status = status
+        self.body = body
+        self.headers = {"Content-Type": content_type}
+        self.released = False
+
+    def stream(self, **_kwargs):
+        for offset in range(0, len(self.body), 65536):
+            yield self.body[offset : offset + 65536]
+
+    def release_conn(self):
+        self.released = True
+
+
+class _HttpsPool:
+    def __init__(self, calls, response, **kwargs):
+        self.calls = calls
+        self.response = response
+        self.calls.append(("pool", kwargs))
+
+    def urlopen(self, *args, **kwargs):
+        self.calls.append(("request", {"args": args, **kwargs}))
+        return self.response.pop(0) if isinstance(self.response, list) else self.response
+
+    def close(self):
+        self.calls.append(("close", {}))
+
+
+def _safe_transport(calls, response, resolver=None, secret_resolver=None):
+    return SafeRestTransport(
+        secret_resolver=secret_resolver or (lambda ref: "resolved-token"),
+        resolver=resolver
+        or (lambda *_args, **_kwargs: [(2, 1, 6, "", ("8.8.8.8", 443))]),
+        pool_factory=lambda **kwargs: _HttpsPool(calls, response, **kwargs),
+    )
+
+
+def test_reference_rest_connector_uses_same_registry_without_core_dispatcher_changes():
+    calls, resolutions = [], []
+
+    def resolver(host, port, type):
+        return [(2, 1, 6, "", ("8.8.8.8", port))]
+
+    def secret_resolver(reference):
+        resolutions.append(reference)
+        return "resolved-token"
+
+    registry = ConnectorRegistry()
+    page = b'{"assets":[{"key":"a","name":"Orders","namespace":"sales","type":"table"}],"next_cursor":{"page":2}}'
+    end = b'{"assets":[]}'
+    response = [
+        _HttpResponse(200, page),
+        _HttpResponse(200, end),
+        _HttpResponse(200, page),
+        _HttpResponse(200, end),
+    ]
+    registry.register(
+        RestCatalogConnector(
+            _safe_transport(calls, response, resolver, secret_resolver)
+        )
+    )
+    result = ConnectorRuntime(registry, sleeper=lambda _: None).execute(
+        "rest-catalog",
+        "1.0.0",
+        _request(
+            config={
+                "base_url": "https://catalog.example.test",
+                "allowed_host": "catalog.example.test",
+                "credential_ref": "secret:catalog/key",
+            }
+        ),
+    )
+    assert result.records[0]["name"] == "Orders"
+    pool_call = next(value for kind, value in calls if kind == "pool")
+    request_call = next(value for kind, value in calls if kind == "request")
+    assert pool_call["host"] == "8.8.8.8"
+    assert (
+        pool_call["server_hostname"]
+        == pool_call["assert_hostname"]
+        == "catalog.example.test"
+    )
+    assert pool_call["cert_reqs"] == "CERT_REQUIRED" and pool_call["retries"] is False
+    assert request_call["headers"]["Host"] == "catalog.example.test"
+    assert request_call["headers"]["Authorization"] == "Bearer resolved-token"
+    assert request_call["redirect"] is False and request_call["retries"] is False
+    assert resolutions == ["secret:catalog/key", "secret:catalog/key"]
+    assert "resolved-token" not in str(result.evidence)
+    incremental = ConnectorRuntime(registry, sleeper=lambda _: None).execute(
+        "rest-catalog",
+        "1.0.0",
+        _request(
+            "incremental",
+            config={
+                "base_url": "https://catalog.example.test",
+                "allowed_host": "catalog.example.test",
+                "credential_ref": "secret:catalog/key",
+            },
+            checkpoint={
+                "cursor": {"page": 1},
+                "snapshot_summary": {"sha256": "old", "record_count": 1},
+            },
+        ),
+    )
+    incremental_request = [value for kind, value in calls if kind == "request"][-1]
+    assert "cursor=" in incremental_request["args"][1]
+    assert incremental.cursor["state"] == "complete"
+    assert incremental.cursor["completion_marker"]
+    assert incremental.evidence["diff"]["changed"] is True
+    assert incremental.evidence["diff"]["details_materialized"] is False
+    assert resolutions == ["secret:catalog/key"] * 4
+    with pytest.raises(ConnectorConfigurationError):
+        _safe_transport(
+            calls,
+            response,
+            lambda *_args, **_kwargs: [(2, 1, 6, "", ("127.0.0.1", 443))],
+        ).get_json(
+            url="https://localhost/x",
+            allowed_host="localhost",
+            credential_ref="secret:test/key",
+        )
+    core = (ROOT / "app/core/connectors/registry.py").read_text() + (
+        ROOT / "app/core/connectors/runtime.py"
+    ).read_text()
+    assert all(
+        connector_id not in core
+        for connector_id in ("oracle", "sqlserver", "rest-catalog")
+    )
+
+
+@pytest.mark.parametrize(
+    "status,body,error",
+    [
+        (302, b"{}", ConnectorUpstreamError),
+        (200, b"x" * (2 * 1024 * 1024 + 1), Exception),
+        (200, b"not-json", Exception),
+    ],
+)
+def test_rest_transport_rejects_redirect_oversize_and_invalid_json(status, body, error):
+    transport = _safe_transport([], _HttpResponse(status, body))
+    with pytest.raises(error):
+        transport.get_json(
+            url="https://catalog.example.test/v1/catalog",
+            allowed_host="catalog.example.test",
+            credential_ref="secret:test/key",
+        )
+    with pytest.raises(ConnectorConfigurationError):
+        transport.get_json(
+            url="http://catalog.example.test/v1/catalog",
+            allowed_host="catalog.example.test",
+            credential_ref="secret:test/key",
+        )
+    with pytest.raises(ConnectorConfigurationError):
+        transport.get_json(
+            url="https://evil.example.test/v1/catalog",
+            allowed_host="catalog.example.test",
+            credential_ref="secret:test/key",
+        )
+
+
+def test_rest_transport_without_secret_resolver_fails_before_dns_or_socket():
+    calls = []
+    transport = SafeRestTransport(
+        resolver=lambda *_args, **_kwargs: calls.append("dns"),
+        pool_factory=lambda **_kwargs: calls.append("pool"),
+    )
+    with pytest.raises(ConnectorConfigurationError):
+        transport.get_json(
+            url="https://catalog.example.test/v1/catalog",
+            allowed_host="catalog.example.test",
+            credential_ref="secret:test/key",
+        )
+    assert calls == []
+
+
+def test_default_environment_secret_resolver_is_deployable_and_fail_closed(
+    monkeypatch,
+):
+    resolver = EnvironmentSecretResolver(
+        {"DATAOPS_CONNECTOR_CATALOG_TOKEN": "runtime-only-value"}
+    )
+    assert resolver("env:DATAOPS_CONNECTOR_CATALOG_TOKEN") == "runtime-only-value"
+    for reference in (
+        "env:HOME",
+        "env:DATAOPS_DATABASE_PASSWORD",
+        "vault:catalog/token",
+        "secret:catalog/token",
+    ):
+        with pytest.raises(ConnectorConfigurationError):
+            resolver(reference)
+
+    from flask import Flask
+
+    from app.api.data_source import routes
+
+    monkeypatch.setenv("DATAOPS_CONNECTOR_CATALOG_TOKEN", "assembled-secret")
+    monkeypatch.setattr(
+        "app.core.data_source.runtime.get_data_source_manager",
+        lambda: SimpleNamespace(connect=_provider),
+    )
+    app = Flask(__name__)
+    app.config["CONNECTOR_SECRET_RESOLVER"] = None
+    with app.app_context():
+        registry = routes._connector_registry()
+        transport = registry.resolve("rest-catalog", "1.0.0").transport
+        assert (
+            transport.secret_resolver("env:DATAOPS_CONNECTOR_CATALOG_TOKEN")
+            == "assembled-secret"
+        )
+        with pytest.raises(ConnectorConfigurationError):
+            transport.secret_resolver("env:HOME")
+
+
+@pytest.mark.parametrize(
+    "adapter_cls,module", [(OracleAdapter, "oracledb"), (SqlServerAdapter, "pyodbc")]
+)
+def test_optional_enterprise_drivers_fail_with_stable_safe_classification(
+    monkeypatch, adapter_cls, module
+):
+    monkeypatch.setattr(
+        "importlib.util.find_spec", lambda name: None if name == module else object()
+    )
+    definition = DataSourceDefinition(
+        uid="u",
+        name_en="x",
+        name_zh="x",
+        database_type=adapter_cls.database_type,
+        host="db.example",
+        port=1521,
+        database="service",
+        schema=None,
+        credential_ref="u",
+        credential_version=1,
+        pool_size=None,
+        max_overflow=None,
+        tls_options={},
+        status=True,
+        description=None,
+        extra_properties={},
+    )
+    with pytest.raises(DataSourceDriverUnavailable) as caught:
+        adapter_cls().build_url(
+            definition, DataSourceCredential("reader", "not-logged", {})
+        )
+    assert caught.value.code == "DATASOURCE_DRIVER_UNAVAILABLE"
+    assert classify_error(caught.value).category == "configuration"
+
+
+@pytest.mark.parametrize(
+    "connector_cls,module",
+    [(OracleConnector, "oracledb"), (SqlServerConnector, "pyodbc")],
+)
+def test_health_and_compatibility_report_missing_driver_without_crash(
+    monkeypatch, connector_cls, module
+):
+    monkeypatch.setattr(
+        "importlib.util.find_spec",
+        lambda name: None if name == module else object(),
+    )
+    connector = connector_cls(_provider)
+    health = connector.health({"credential_ref": "env:DATAOPS_CONNECTOR_TEST"})
+    compatibility = connector.compatibility()
+    assert health.status == "degraded" and "driver" in health.detail
+    assert compatibility.compatible is False
+    assert compatibility.connector_version == "1.0.0"
+
+
+def test_runtime_idempotency_retry_cancel_resume_diff_and_bounded_redaction():
+    class Flaky(Connector):
+        manifest = OracleConnector.manifest
+
+        def __init__(self):
+            self.calls = 0
+
+        def discover(self, request):
+            self.calls += 1
+            if self.calls == 1:
+                raise ConnectorUpstreamError()
+            return OperationResult(
+                records=({"x": 1},),
+                checkpoint={"page": 1},
+                evidence={"password": "hidden"},
+            )
+
+        snapshot = incremental = lineage = profile = resume = evidence = discover
+
+        def cancel(self, request):
+            return OperationResult(status="cancelled")
+
+    connector = Flaky()
+    registry = ConnectorRegistry()
+    registry.register(connector)
+    store = InMemoryRunStore()
+    runtime = ConnectorRuntime(registry, store=store, sleeper=lambda _: None)
+    request = _request()
+    first = runtime.execute("oracle", "1.0.0", request)
+    second = runtime.execute("oracle", "1.0.0", request)
+    assert connector.calls == 2 and second == first
+    assert first.evidence["password"] == "[REDACTED]"
+    assert len(deterministic_idempotency_key("oracle", "1.0.0", request)) == 64
+    assert snapshot_diff(({"a": 1},), ({"a": 2},))["changed"] == (
+        {"before": {"a": 1}, "after": {"a": 2}},
+    )
+    assert len(str(redact_evidence({"data": "x" * 40000})["data"])) == 4096
+    key = "a" * 64
+    store.claim(
+        key,
+        {
+            "status": "running",
+            "connector_id": "oracle",
+            "connector_version": "1.0.0",
+            "source_uid": request.source_uid,
+            "config": dict(request.config),
+            "scope": {},
+            "cursor": {},
+            "checkpoint": {"page": 7},
+            "attempt_count": 1,
+            "dry_run": False,
+        },
+    )
+    assert runtime.cancel(key)["status"] == "cancelled"
+    assert isinstance(runtime.resume(key), OperationResult)
+    assert store.get(key)["status"] == "succeeded"
+    assert store.get(key)["attempt_count"] == 2
+    assert store.get(deterministic_idempotency_key("oracle", "1.0.0", request))[
+        "checkpoint"
+    ] == {"page": 1}
+
+
+def test_human_rest_dry_run_never_resolves_secret_or_opens_transport():
+    class NoNetwork:
+        def __init__(self):
+            self.calls = 0
+
+        def get_json(self, **_values):
+            self.calls += 1
+            raise AssertionError("dry-run must not use network")
+
+    transport = NoNetwork()
+    registry = ConnectorRegistry()
+    registry.register(RestCatalogConnector(transport))
+    result = ConnectorRuntime(registry).execute(
+        "rest-catalog",
+        "1.0.0",
+        _request(
+            dry_run=True,
+            config={
+                "base_url": "https://attacker.example.test",
+                "allowed_host": "attacker.example.test",
+                "credential_ref": "env:DATAOPS_CONNECTOR_GUESSED",
+            },
+        ),
+    )
+    assert result.status == "dry_run"
+    assert result.evidence["network_requested"] is False
+    assert transport.calls == 0
+
+
+def test_cancelled_human_dry_run_resumes_validation_only_without_network():
+    class NoNetwork:
+        def __init__(self):
+            self.calls = 0
+
+        def get_json(self, **_values):
+            self.calls += 1
+            raise AssertionError("human dry-run resume must not use transport")
+
+    transport = NoNetwork()
+    registry = ConnectorRegistry()
+    registry.register(RestCatalogConnector(transport))
+    store = InMemoryRunStore()
+    key = "d" * 64
+    config = {
+        "base_url": "https://catalog.example.test",
+        "allowed_host": "catalog.example.test",
+        "credential_ref": "env:DATAOPS_CONNECTOR_TEST",
+    }
+    store.claim(
+        key,
+        {
+            "idempotency_key": key,
+            "status": "running",
+            "connector_id": "rest-catalog",
+            "connector_version": "1.0.0",
+            "source_uid": _request().source_uid,
+            "config": config,
+            "scope": {},
+            "cursor": {},
+            "checkpoint": {},
+            "attempt_count": 1,
+            "attempt_lease_token": "old-lease",
+            "dry_run": True,
+            "principal_uid": None,
+        },
+    )
+    runtime = ConnectorRuntime(registry, store=store)
+    assert runtime.cancel(key)["cancel_requested"] is True
+    resumed = runtime.resume(key)
+    assert resumed.status == "dry_run"
+    assert resumed.evidence["validation_only"] is True
+    assert resumed.evidence["network_requested"] is False
+    assert transport.calls == 0
+
+
+def test_runtime_rejects_nonterminal_operation_result_status():
+    class InvalidStatus(Connector):
+        manifest = OracleConnector.manifest
+
+        def discover(self, _request):
+            return OperationResult(status="running")
+
+        snapshot = incremental = lineage = profile = resume = evidence = discover
+
+        def cancel(self, _request):
+            return OperationResult(status="cancelled")
+
+    registry = ConnectorRegistry()
+    registry.register(InvalidStatus())
+    store = InMemoryRunStore()
+    request = _request(idempotency_key="invalid-status")
+    with pytest.raises(ConnectorConfigurationError):
+        ConnectorRuntime(registry, store=store, max_attempts=1).execute(
+            "oracle", "1.0.0", request
+        )
+    key = deterministic_idempotency_key("oracle", "1.0.0", request)
+    assert store.get(key)["status"] == "failed"
+
+
+def test_cancelling_one_run_does_not_cancel_another_run_for_the_same_source():
+    class Probe(Connector):
+        manifest = OracleConnector.manifest
+
+        def __init__(self):
+            self.discover_calls = 0
+            self.cancel_request = None
+
+        def discover(self, _request):
+            self.discover_calls += 1
+            return OperationResult(records=({"asset_key": "APP.ORDERS"},))
+
+        snapshot = incremental = lineage = profile = resume = evidence = discover
+
+        def cancel(self, request):
+            self.cancel_request = request
+            return OperationResult(status="cancelled")
+
+    connector = Probe()
+    registry = ConnectorRegistry()
+    registry.register(connector)
+    store = InMemoryRunStore()
+    runtime = ConnectorRuntime(registry, store=store)
+    cancelled_key = "c" * 64
+    store.claim(
+        cancelled_key,
+        {
+            "status": "running",
+            "connector_id": "oracle",
+            "connector_version": "1.0.0",
+            "source_uid": _request().source_uid,
+            "config": dict(_request().config),
+            "scope": {},
+            "cursor": {},
+            "checkpoint": {},
+            "attempt_count": 1,
+            "attempt_lease_token": "old-lease",
+            "dry_run": False,
+        },
+    )
+    assert runtime.cancel(cancelled_key)["status"] == "cancelled"
+    assert connector.cancel_request.run_key == cancelled_key
+    assert connector.cancel_request.cancel_probe() is True
+
+    sibling = runtime.execute(
+        "oracle",
+        "1.0.0",
+        _request(idempotency_key="sibling-run"),
+    )
+    assert sibling.status == "succeeded"
+    assert connector.discover_calls == 1
+
+
+def test_rest_completed_cursor_restarts_full_scan_without_false_removed():
+    class Pages:
+        def __init__(self):
+            self.urls = []
+
+        def get_json(self, **values):
+            self.urls.append(values["url"])
+            return {
+                "assets": [
+                    {
+                        "key": "orders",
+                        "name": "Orders",
+                        "namespace": "sales",
+                        "type": "table",
+                    }
+                ],
+                "completion_token": "server-complete-42",
+            }
+
+    pages = Pages()
+    connector = RestCatalogConnector(pages)
+    config = {
+        "base_url": "https://catalog.example.test",
+        "allowed_host": "catalog.example.test",
+        "credential_ref": "env:DATAOPS_CONNECTOR_TEST",
+    }
+    first = connector.snapshot(_request("snapshot", config=config))
+    second = connector.incremental(
+        _request(
+            "incremental",
+            config=config,
+            cursor=first.cursor,
+            checkpoint=first.checkpoint,
+        )
+    )
+    assert pages.urls == [
+        "https://catalog.example.test/v1/catalog",
+        "https://catalog.example.test/v1/catalog",
+    ]
+    assert second.cursor == {
+        "state": "complete",
+        "completion_marker": "server-complete-42",
+    }
+    assert second.evidence["diff"] == {
+        "changed": False,
+        "previous_record_count": 1,
+        "current_record_count": 1,
+        "details_materialized": False,
+    }
+    assert "removed" not in second.evidence["diff"]
+    with pytest.raises(ConnectorContractError):
+        connector.incremental(
+            _request("incremental", config=config, cursor={"state": "invalid"})
+        )
+
+
+def test_runtime_sanitizes_every_output_channel_and_rejects_oversize_records():
+    class Unsafe(Connector):
+        manifest = OracleConnector.manifest
+
+        def discover(self, _request):
+            return OperationResult(
+                records=({"password": "raw", "url": "https://u:p@host/x"},),
+                cursor={"token": "raw"},
+                checkpoint={"authorization": "Bearer raw"},
+                evidence={"url": "https://u:p@host/x?token=raw"},
+            )
+
+        snapshot = incremental = lineage = profile = resume = evidence = discover
+
+        def cancel(self, _request):
+            return OperationResult(status="cancelled")
+
+    registry = ConnectorRegistry()
+    registry.register(Unsafe())
+    result = ConnectorRuntime(registry).execute("oracle", "1.0.0", _request())
+    serialized = str(result)
+    assert "raw" not in serialized and "u:p" not in serialized
+    assert "[REDACTED]" in serialized
+
+    class Oversize(Unsafe):
+        def discover(self, _request):
+            return OperationResult(records=({"value": "x" * 1_100_000},))
+
+    oversized = ConnectorRegistry()
+    oversized.register(Oversize())
+    with pytest.raises(ConnectorConfigurationError):
+        ConnectorRuntime(oversized, max_attempts=1).execute(
+            "oracle", "1.0.0", _request(idempotency_key="oversize")
+        )
+
+
+def test_rest_pagination_rejects_repeated_and_non_monotonic_cursors():
+    class Repeating:
+        def get_json(self, **_values):
+            return {"assets": [], "next_cursor": {"page": 1}}
+
+    connector = RestCatalogConnector(Repeating())
+    with pytest.raises(ConnectorContractError):
+        connector.discover(
+            _request(
+                config={
+                    "base_url": "https://catalog.example.test",
+                    "allowed_host": "catalog.example.test",
+                    "credential_ref": "env:DATAOPS_CONNECTOR_TEST",
+                },
+                cursor={"page": 1},
+            )
+        )
+
+
+def test_sqlserver_tls_defaults_secure_and_insecure_policy_is_development_only(
+    monkeypatch,
+):
+    monkeypatch.setattr("importlib.util.find_spec", lambda _name: object())
+
+    def definition(tls_options):
+        return DataSourceDefinition(
+            uid="u",
+            name_en="sqlserver",
+            name_zh="sqlserver",
+            database_type="sqlserver",
+            host="db.example",
+            port=1433,
+            database="catalog",
+            credential_ref="ref",
+            credential_version=1,
+            tls_options=tls_options,
+        )
+
+    credential = DataSourceCredential("reader", "secret")
+    query = SqlServerAdapter().build_url(definition({}), credential).query
+    assert query["Encrypt"] == "yes"
+    assert query["TrustServerCertificate"] == "no"
+    with pytest.raises(DataSourceConfigurationInvalid):
+        SqlServerAdapter().build_url(
+            definition(
+                {
+                    "Environment": "production",
+                    "Encrypt": "no",
+                    "TrustServerCertificate": "yes",
+                }
+            ),
+            credential,
+        )
+    with pytest.raises(DataSourceConfigurationInvalid):
+        SqlServerAdapter().build_url(
+            definition(
+                {
+                    "Environment": "development",
+                    "Encrypt": "no",
+                    "TrustServerCertificate": "yes",
+                    "AllowInsecureDevelopment": "yes",
+                }
+            ),
+            credential,
+        )
+    development = SqlServerAdapter().build_url_for_environment(
+        definition(
+            {
+                "Encrypt": "no",
+                "TrustServerCertificate": "yes",
+            }
+        ),
+        credential,
+        trusted_environment="development",
+        allow_insecure_development=True,
+    )
+    assert development.query["Encrypt"] == "no"
+
+
+def test_runtime_resume_failure_is_failed_retryable_and_preserves_checkpoint():
+    class ResumeFlaky(Connector):
+        manifest = OracleConnector.manifest
+
+        def __init__(self):
+            self.resume_calls = 0
+
+        def resume(self, request):
+            self.resume_calls += 1
+            if self.resume_calls <= 2:
+                raise ConnectorUpstreamError()
+            return OperationResult(checkpoint={"page": 8}, evidence={"resumed": True})
+
+        discover = snapshot = incremental = lineage = profile = evidence = resume
+
+        def cancel(self, request):
+            return OperationResult(status="cancelled")
+
+    class RecordingLimiter:
+        def __init__(self):
+            self.keys = []
+
+        def acquire(self, key):
+            self.keys.append(key)
+
+    connector, store, limiter = ResumeFlaky(), InMemoryRunStore(), RecordingLimiter()
+    registry = ConnectorRegistry()
+    registry.register(connector)
+    key = "b" * 64
+    original_checkpoint = {"page": 7, "snapshot": [{"key": "before"}]}
+    store.claim(
+        key,
+        {
+            "uid": "11111111-1111-4111-8111-111111111112",
+            "status": "cancelled",
+            "connector_id": "oracle",
+            "connector_version": "1.0.0",
+            "source_uid": _request().source_uid,
+            "config": dict(_request().config),
+            "scope": {},
+            "cursor": {"page": 7},
+            "checkpoint": original_checkpoint,
+            "attempt_count": 1,
+            "dry_run": False,
+        },
+    )
+    runtime = ConnectorRuntime(
+        registry, store=store, limiter=limiter, max_attempts=2, sleeper=lambda _: None
+    )
+    with pytest.raises(ConnectorUpstreamError):
+        runtime.resume(key)
+    failed = store.get(key)
+    assert failed["status"] == "failed" and failed["attempt_count"] == 3
+    assert failed["error_category"] == "upstream"
+    assert failed["checkpoint"] == original_checkpoint
+    result = runtime.resume(key)
+    assert result.checkpoint == {"page": 8}
+    assert store.get(key)["attempt_count"] == 4
+    assert len(limiter.keys) == 2
+
+
+def test_runtime_never_exceeds_five_total_attempts_and_rate_limit_precedes_claim():
+    class AlwaysFails(Connector):
+        manifest = OracleConnector.manifest
+
+        def __init__(self):
+            self.calls = 0
+
+        def discover(self, request):
+            self.calls += 1
+            raise ConnectorUpstreamError()
+
+        snapshot = incremental = lineage = profile = resume = evidence = discover
+
+        def cancel(self, request):
+            return OperationResult(status="cancelled")
+
+    connector, store = AlwaysFails(), InMemoryRunStore()
+    registry = ConnectorRegistry()
+    registry.register(connector)
+    runtime = ConnectorRuntime(registry, store=store, sleeper=lambda _: None)
+    request = _request()
+    for expected_attempts in (3, MAX_TOTAL_ATTEMPTS):
+        with pytest.raises(ConnectorUpstreamError):
+            runtime.execute("oracle", "1.0.0", request)
+        key = deterministic_idempotency_key("oracle", "1.0.0", request)
+        assert store.get(key)["attempt_count"] == expected_attempts
+    with pytest.raises(ConnectorConfigurationError):
+        runtime.execute("oracle", "1.0.0", request)
+    assert connector.calls == MAX_TOTAL_ATTEMPTS
+
+    class RejectingLimiter:
+        def acquire(self, _key):
+            raise ConnectorRateLimitError()
+
+    empty_store = InMemoryRunStore()
+    with pytest.raises(ConnectorRateLimitError):
+        ConnectorRuntime(
+            registry, store=empty_store, limiter=RejectingLimiter()
+        ).execute("oracle", "1.0.0", request)
+    assert empty_store.list() == []
+
+
+def test_in_memory_attempt_cas_returns_explicit_single_owner_outcome():
+    store = InMemoryRunStore()
+    key = "c" * 64
+    store.claim(key, {"status": "failed", "attempt_count": 1})
+    winner = store.update(key, status="running", attempt_count=2)
+    loser = store.update(key, status="running", attempt_count=2)
+    assert winner.acquired is True and winner.record["attempt_count"] == 2
+    assert loser.acquired is False and loser.record["attempt_count"] == 2
+
+
+def test_idempotency_changes_with_safe_config_and_checkpoint():
+    base = _request(checkpoint={"page": 1})
+    changed_checkpoint = _request(checkpoint={"page": 2})
+    changed_config = _request(
+        config={"credential_ref": "env:DATAOPS_CONNECTOR_OTHER"}, checkpoint={"page": 1}
+    )
+    keys = {
+        deterministic_idempotency_key("oracle", "1.0.0", item)
+        for item in (base, changed_checkpoint, changed_config)
+    }
+    assert len(keys) == 3
+
+
+def test_rate_limit_and_error_taxonomy_are_deterministic():
+    now = [0.0]
+    limiter = SlidingWindowLimiter(limit=2, window_seconds=10, clock=lambda: now[0])
+    limiter.acquire("source")
+    limiter.acquire("source")
+    with pytest.raises(ConnectorRateLimitError):
+        limiter.acquire("source")
+    now[0] = 11
+    limiter.acquire("source")
+    assert isinstance(classify_error(TimeoutError()), ConnectorTimeoutError)
+    assert isinstance(classify_error(PermissionError()), ConnectorPermissionError)
+    assert isinstance(
+        classify_error(ConnectorDriverUnavailableError()),
+        ConnectorDriverUnavailableError,
+    )
+
+
+class _Result:
+    def __init__(self, scalar=None, mapping=None, rowcount=1):
+        self.scalar = scalar
+        self.mapping = mapping
+        self.rowcount = rowcount
+
+    def scalar_one_or_none(self):
+        return self.scalar
+
+    def mappings(self):
+        return self
+
+    def one_or_none(self):
+        return self.mapping
+
+
+class _Session:
+    def __init__(self, results):
+        self.results = list(results)
+        self.calls = []
+        self.commits = 0
+        self.rollbacks = 0
+
+    def execute(self, statement, parameters=None):
+        self.calls.append((str(statement), parameters or {}))
+        return self.results.pop(0) if self.results else _Result()
+
+    def commit(self):
+        self.commits += 1
+
+    def rollback(self):
+        self.rollbacks += 1
+
+
+def test_machine_credential_ttl_one_time_return_and_no_plaintext_persistence():
+    session = _Session([_Result(), _Result("active"), _Result(), _Result()])
+    repository = ConnectorIdentityRepository(session)
+    issued = repository.issue(
+        "11111111-1111-4111-8111-111111111111",
+        ttl_seconds=900,
+        actor_uid="22222222-2222-4222-8222-222222222222",
+    )
+    assert issued["credential"].startswith("dopc_") and issued["returned_once"] is True
+    persisted = [
+        params
+        for sql, params in session.calls
+        if "connector_machine_credentials" in sql and "INSERT" in sql
+    ][0]
+    assert "credential" not in persisted and len(persisted["hash"]) == 64
+    with pytest.raises(ConnectorConfigurationError):
+        repository.issue("x", ttl_seconds=901, actor_uid="y")
+
+
+def test_machine_credential_scope_replay_revoke_and_rotation():
+    replay_identity = {
+        "uid": "c",
+        "principal_uid": "p",
+        "use_count": 1,
+        "connector_id": "oracle",
+        "connector_version": "1.0.0",
+        "source_uid": "s",
+        "business_domain_uid": "d",
+        "environment": "staging",
+        "allowed_operations": ["discover"],
+        "allowed_scopes": {},
+        "source_binding_uid": "binding",
+        "source_binding_version": 1,
+        "approved_config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+    }
+    session = _Session(
+        [_Result(mapping=None), _Result(mapping=replay_identity), _Result(), _Result()]
+    )
+    with pytest.raises(ConnectorAuthenticationError):
+        ConnectorIdentityRepository(session).authenticate(
+            "token",
+            connector_id="oracle",
+            connector_version="1.0.0",
+            source_uid="s",
+            business_domain_uid="d",
+            environment="staging",
+            operation="discover",
+            scope={},
+        )
+    assert any(
+        "credential_replay_rejected" in params.values()
+        for _sql, params in session.calls
+    )
+
+    scope_identity = {
+        **replay_identity,
+        "use_count": 0,
+        "allowed_scopes": {"include_schemas": ["APP"]},
+    }
+    session = _Session(
+        [_Result(mapping=None), _Result(mapping=scope_identity), _Result()]
+    )
+    with pytest.raises(ConnectorPermissionError):
+        ConnectorIdentityRepository(session).authenticate(
+            "token",
+            connector_id="oracle",
+            connector_version="1.0.0",
+            source_uid="s",
+            business_domain_uid="d",
+            environment="staging",
+            operation="discover",
+            scope={"include_schemas": ["SYS"]},
+        )
+
+    session = _Session([_Result("principal"), _Result()])
+    assert ConnectorIdentityRepository(session).revoke("credential", "actor") is True
+    rotation = _Session(
+        [
+            _Result("principal"),
+            _Result(),
+            _Result(),
+            _Result("active"),
+            _Result(),
+            _Result(),
+        ]
+    )
+    rotated = ConnectorIdentityRepository(rotation).rotate(
+        "credential", ttl_seconds=300, actor_uid="actor"
+    )
+    assert rotated["returned_once"] is True and rotated["expires_in"] == 300
+
+
+def test_machine_credential_expiry_is_rejected_and_audited():
+    expired = {"uid": "credential", "principal_uid": "principal"}
+    session = _Session([_Result(mapping=expired), _Result(), _Result()])
+    with pytest.raises(ConnectorAuthenticationError):
+        ConnectorIdentityRepository(session).authenticate(
+            "expired",
+            connector_id="oracle",
+            connector_version="1.0.0",
+            source_uid="source",
+            business_domain_uid="domain",
+            environment="staging",
+            operation="discover",
+            scope={},
+        )
+    assert any(
+        "credential_expired_rejected" in params.values()
+        for _sql, params in session.calls
+    )
+
+
+def test_machine_credential_success_is_scoped_and_audited():
+    identity = {
+        "uid": "credential",
+        "principal_uid": "principal",
+        "use_count": 0,
+        "connector_id": "oracle",
+        "connector_version": "1.0.0",
+        "source_uid": "source",
+        "business_domain_uid": "domain",
+        "environment": "staging",
+        "allowed_operations": ["discover"],
+        "allowed_scopes": {"include_schemas": ["APP"]},
+        "source_binding_uid": "binding",
+        "source_binding_version": 1,
+        "approved_config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+    }
+    session = _Session([_Result(mapping=None), _Result(mapping=identity), _Result()])
+    result = ConnectorIdentityRepository(session).authenticate(
+        "token",
+        connector_id="oracle",
+        connector_version="1.0.0",
+        source_uid="source",
+        business_domain_uid="domain",
+        environment="staging",
+        operation="discover",
+        scope={"include_schemas": ["APP"]},
+    )
+    assert result["principal_uid"] == "principal" and session.commits == 1
+    assert any(
+        "credential_authenticated" in params.values() for _sql, params in session.calls
+    )
+
+
+@pytest.mark.parametrize(
+    "override",
+    [
+        {"connector_version": "2.0.0"},
+        {"business_domain_uid": "other-domain"},
+        {"environment": "production"},
+    ],
+)
+def test_machine_credential_rejects_cross_binding(override):
+    identity = {
+        "uid": "credential",
+        "principal_uid": "principal",
+        "use_count": 0,
+        "connector_id": "oracle",
+        "connector_version": "1.0.0",
+        "source_uid": "source",
+        "business_domain_uid": "domain",
+        "environment": "staging",
+        "allowed_operations": ["discover"],
+        "allowed_scopes": {},
+        "source_binding_uid": "binding",
+        "source_binding_version": 1,
+        "approved_config": {"credential_ref": "env:DATAOPS_CONNECTOR_TEST"},
+    }
+    session = _Session([_Result(mapping=None), _Result(mapping=identity), _Result()])
+    binding = {
+        "connector_id": "oracle",
+        "connector_version": "1.0.0",
+        "source_uid": "source",
+        "business_domain_uid": "domain",
+        "environment": "staging",
+        "operation": "discover",
+        "scope": {},
+        **override,
+    }
+    with pytest.raises(ConnectorPermissionError):
+        ConnectorIdentityRepository(session).authenticate("token", **binding)
+
+
+def test_machine_scope_rejects_unknown_key_even_when_empty():
+    session = _Session([])
+    with pytest.raises(ConnectorConfigurationError):
+        ConnectorIdentityRepository(session).authenticate(
+            "token",
+            connector_id="oracle",
+            connector_version="1.0.0",
+            source_uid="source",
+            business_domain_uid="domain",
+            environment="staging",
+            operation="discover",
+            scope={"unknown": []},
+        )
+    assert session.calls == []
+
+
+def test_api_permissions_and_machine_boundary_fail_closed_without_credential():
+    from app import create_app
+    from app.core.system.permissions import permission_for_request
+
+    assert permission_for_request("/api/datasource/connectors/manifests", "GET") == (
+        "connectors:read",
+    )
+    assert permission_for_request("/api/datasource/connectors/runs", "POST") == (
+        "connectors:operate",
+    )
+    assert permission_for_request("/api/datasource/connectors/principals", "POST") == (
+        "connectors:manage",
+    )
+    assert permission_for_request(
+        "/api/datasource/connectors/machine/runs", "POST"
+    ) == ("public",)
+    app = create_app()
+    app.config.update(TESTING=True)
+    response = app.test_client().post(
+        "/api/datasource/connectors/machine/runs", json={}
+    )
+    assert (
+        response.status_code == 401
+        and "credential" not in response.get_data(as_text=True).lower()
+    )
+
+
+def test_migration_permissions_frontend_docs_and_release_parity_contract():
+    migration = (
+        ROOT / "migrations/versions/20260802_472_enterprise_connectors.py"
+    ).read_text()
+    for table in (
+        "connector_manifests",
+        "connector_principals",
+        "connector_machine_credentials",
+        "connector_runs",
+        "connector_run_attempts",
+        "connector_checkpoints",
+        "connector_evidence",
+        "connector_graph_edges",
+        "connector_audit_events",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    for marker in (
+        "INTERVAL '15 minutes'",
+        "idempotency_key CHAR(64) NOT NULL UNIQUE",
+        "use_count",
+        "replayed",
+        "DROP TABLE IF EXISTS",
+    ):
+        assert marker in migration
+    hardening = (
+        ROOT / "migrations/versions/20260802_473_connector_runtime_hardening.py"
+    ).read_text()
+    assert 'down_revision = "20260802_472"' in hardening
+    for marker in (
+        "connector_version VARCHAR(40)",
+        "connector_rate_limits",
+        "safe_config JSONB",
+        "business_domain_uid",
+        "process_key",
+        "ck_connector_machine_run_binding",
+    ):
+        assert marker in hardening
+    guardrails = (
+        ROOT / "migrations/versions/20260802_474_connector_version_guardrails.py"
+    ).read_text()
+    assert 'down_revision = "20260802_473"' in guardrails
+    assert "multiple active manifest versions" in guardrails
+    assert "multiple principal versions" in guardrails
+    security_bindings = (
+        ROOT / "migrations/versions/20260802_475_connector_security_bindings.py"
+    ).read_text()
+    assert 'down_revision = "20260802_474"' in security_bindings
+    for marker in (
+        "connector_source_bindings",
+        "request_hash",
+        "client_hint_hash",
+        "attempt_lease_token",
+        "cancel_requested",
+        "explicitly remove source bindings",
+    ):
+        assert marker in security_bindings
+    binding_enforcement = (
+        ROOT / "migrations/versions/20260802_476_connector_binding_enforcement.py"
+    ).read_text()
+    assert 'down_revision = "20260802_475"' in binding_enforcement
+    assert "bind every active enterprise principal first" in binding_enforcement
+    assert (
+        "process_key = db.Column(db.String(300))"
+        in (ROOT / "app/models/connectors.py").read_text()
+    )
+    permissions = (ROOT / "app/core/system/permissions.py").read_text()
+    for permission in ("connectors:read", "connectors:operate", "connectors:manage"):
+        assert permission in permissions
+    api = (ROOT / "app/api/data_source/routes.py").read_text()
+    for endpoint in (
+        "/connectors/manifests",
+        "/connectors/config/validate",
+        "/compatibility",
+        "/connectors/runs",
+        "/cancel",
+        "/resume",
+        "/connectors/principals",
+        "/credentials",
+    ):
+        assert endpoint in api
+    assert "DATASOURCE_GRAPH_NOT_IMPLEMENTED" not in api
+    page = (
+        ROOT / "frontend/src/views/dataGovernance/development/enterpriseConnectors.vue"
+    ).read_text()
+    assert "真实企业账号、版本、网络与 UAT 尚未提供" in page
+    assert "{{ item.credential" not in page and "{{ credential" not in page
+    assert "$store.state.user.userInfo.permissions" in page
+    assert "$store.getters.roles" not in page
+    for permission in ("connectors:read", "connectors:operate", "connectors:manage"):
+        assert permission in page
+    assert "isHumanDryRun(item)" in page and 'v-if="canManage"' in page
+    assert "checkpoint_summary" in page and "cursor_summary" in page
+    assert "仅机器凭证可操作" in page
+    assert "human connector runs must use dry_run=true" in api
+    connector_files = [
+        path.relative_to(ROOT).as_posix()
+        for path in (ROOT / "app/core/connectors").rglob("*.py")
+    ]
+    for relative in (
+        *connector_files,
+        "app/core/data_source/adapters/__init__.py",
+        "app/core/data_source/adapters/oracle.py",
+        "app/core/data_source/adapters/sqlserver.py",
+        "app/core/data_source/errors.py",
+        "app/core/data_source/service.py",
+        "app/models/connectors.py",
+        "app/models/__init__.py",
+        "app/api/data_source/routes.py",
+        "app/core/system/permissions.py",
+    ):
+        assert (ROOT / relative).read_bytes() == (
+            ROOT / "deployment" / relative
+        ).read_bytes()
+    assert (
+        ROOT / "migrations/versions/20260802_472_enterprise_connectors.py"
+    ).read_bytes() == (
+        ROOT / "deployment/migrations/versions/20260802_472_enterprise_connectors.py"
+    ).read_bytes()
+
+
+def test_openapi_connector_contract_is_strict_and_matches_behavior():
+    contract = yaml.safe_load((ROOT / "docs/architecture/OPENAPI.yaml").read_text())
+    paths = contract["paths"]
+    machine = paths["/api/datasource/connectors/machine/runs"]["post"]
+    assert machine["requestBody"]["required"] is True
+    assert machine["requestBody"]["content"]["application/json"]["schema"][
+        "$ref"
+    ].endswith("MachineConnectorRunRequest")
+    header = next(
+        item
+        for item in machine["parameters"]
+        if item["name"] == "X-Connector-Credential"
+    )
+    assert header["in"] == "header" and header["required"] is True
+    assert set(machine["responses"]) == {"201", "default"}
+    assert machine["responses"]["201"]["content"]["application/json"]["schema"][
+        "$ref"
+    ].endswith("ConnectorRunResultEnvelope")
+    assert machine["x-required-permission"] == "machine-credential"
+    for action in ("cancel", "resume"):
+        operation = paths[
+            f"/api/datasource/connectors/runs/{{idempotency_key}}/{action}"
+        ]["post"]
+        assert "requestBody" not in operation
+    human = paths["/api/datasource/connectors/runs"]["post"]
+    assert (
+        human["responses"].get("201")
+        and human["x-required-permission"] == "connectors:operate"
+    )
+    schemas = contract["components"]["schemas"]
+    assert schemas["ConnectorRunRequest"]["properties"]["dry_run"] == {"const": True}
+    assert schemas["ConnectorScope"]["additionalProperties"] is False
+    assert schemas["ConnectorRunResultEnvelope"]["additionalProperties"] is False
+    assert "config" not in schemas["MachineConnectorRunRequest"]["properties"]
+    assert schemas["ConnectorOperationResult"]["properties"]["status"]["enum"] == [
+        "succeeded",
+        "dry_run",
+    ]
+    assert "allOf" not in schemas["ConnectorHealthEnvelope"]
+    categories = schemas["ConnectorErrorEnvelope"]["properties"]["error"]["properties"][
+        "category"
+    ]["enum"]
+    assert {
+        "configuration",
+        "authentication",
+            "permission",
+            "conflict",
+        "rate_limit",
+        "timeout",
+        "upstream",
+        "contract",
+        "cancelled",
+        } == set(categories)
+    for action in ("cancel", "resume"):
+        machine_action = paths[
+            f"/api/datasource/connectors/machine/runs/{{idempotency_key}}/{action}"
+        ]["post"]
+        assert "requestBody" not in machine_action
+        assert machine_action["x-required-permission"] == "machine-credential"
+    credential_action = paths[
+        "/api/datasource/connectors/credentials/{credential_uid}/{action}"
+    ]["post"]
+    assert credential_action["requestBody"]["required"] is False
+    credential_issue = paths[
+        "/api/datasource/connectors/principals/{principal_uid}/credentials"
+    ]["post"]
+    assert credential_issue["requestBody"]["required"] is False
+    binding_create = paths["/api/datasource/connectors/source-bindings"]["post"]
+    assert set(binding_create["responses"]) == {"201", "default"}
+    assert binding_create["responses"]["201"]["content"]["application/json"][
+        "schema"
+    ]["$ref"].endswith("ConnectorSourceBindingEnvelope")
+    action_parameter = next(
+        item for item in credential_action["parameters"] if item["name"] == "action"
+    )
+    assert action_parameter["schema"]["enum"] == ["revoke", "rotate"]
+    assert paths["/api/datasource/graph"]["post"]["x-required-permission"] == (
+        "connectors:read"
+    )
+    for envelope in (
+        "ConnectorRunResultEnvelope",
+        "ConnectorErrorEnvelope",
+        "ConnectorHealthEnvelope",
+    ):
+        properties = schemas[envelope]["properties"]
+        assert "success" not in properties and "timestamp" not in properties
+        assert {"code", "message", "data"}.issubset(properties)
+    assert (
+        ROOT / "migrations/versions/20260802_473_connector_runtime_hardening.py"
+    ).read_bytes() == (
+        ROOT
+        / "deployment/migrations/versions/20260802_473_connector_runtime_hardening.py"
+    ).read_bytes()
+    assert (
+        ROOT / "migrations/versions/20260802_474_connector_version_guardrails.py"
+    ).read_bytes() == (
+        ROOT
+        / "deployment/migrations/versions/20260802_474_connector_version_guardrails.py"
+    ).read_bytes()
+    assert (
+        ROOT / "migrations/versions/20260802_475_connector_security_bindings.py"
+    ).read_bytes() == (
+        ROOT
+        / "deployment/migrations/versions/20260802_475_connector_security_bindings.py"
+    ).read_bytes()
+    assert (
+        ROOT / "migrations/versions/20260802_476_connector_binding_enforcement.py"
+    ).read_bytes() == (
+        ROOT
+        / "deployment/migrations/versions/20260802_476_connector_binding_enforcement.py"
+    ).read_bytes()
+
+
+def test_flask_machine_responses_validate_against_openapi_201_and_401(
+    monkeypatch,
+):
+    from app import create_app
+    from app.core.connectors.identity import ConnectorIdentityRepository
+    from app.core.connectors.repository import ConnectorRepository
+    from app.core.connectors.runtime import ConnectorRuntime
+
+    contract = yaml.safe_load((ROOT / "docs/architecture/OPENAPI.yaml").read_text())
+
+    def expand(schema):
+        if isinstance(schema, dict) and "$ref" in schema:
+            target = contract
+            for part in schema["$ref"].removeprefix("#/").split("/"):
+                target = target[part]
+            return expand(target)
+        if isinstance(schema, dict):
+            return {key: expand(value) for key, value in schema.items()}
+        if isinstance(schema, list):
+            return [expand(value) for value in schema]
+        return schema
+
+    monkeypatch.setattr(
+        ConnectorIdentityRepository,
+        "authenticate",
+        lambda _self, _token, **_binding: {
+            "principal_uid": "principal",
+            "source_binding_uid": "binding",
+            "source_binding_version": 1,
+            "approved_config": {
+                "credential_ref": "env:DATAOPS_CONNECTOR_TEST"
+            },
+        },
+    )
+    monkeypatch.setattr(
+        ConnectorRepository,
+        "register_manifest",
+        lambda _self, _manifest, _actor: None,
+    )
+    monkeypatch.setattr(
+        ConnectorRuntime,
+        "execute",
+        lambda _self, _connector, _version, _request: OperationResult(
+            records=({"asset_key": "source:APP.ORDERS"},),
+            checkpoint={"page": 1},
+            cursor={"page": 2},
+            evidence={"safe": True},
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    app.extensions["connector_registry"] = SimpleNamespace(
+        resolve=lambda *_args, **_kwargs: SimpleNamespace(
+            manifest=OracleConnector.manifest
+        )
+    )
+    client = app.test_client()
+    path = "/api/datasource/connectors/machine/runs"
+    unauthorized = client.post(path, json={})
+    assert unauthorized.status_code == 401
+    error_schema = expand(
+        contract["paths"][path]["post"]["responses"]["default"]["content"][
+            "application/json"
+        ]["schema"]
+    )
+    Draft202012Validator(error_schema).validate(unauthorized.get_json())
+    assert set(unauthorized.get_json()) == {"code", "message", "data", "error"}
+    assert unauthorized.get_json()["error"] == {
+        "code": "CONNECTOR_ERROR",
+        "category": "authentication",
+        "retryable": False,
+    }
+
+    created = client.post(
+        path,
+        headers={"X-Connector-Credential": "one-time-token"},
+        json={
+            "connector_id": "oracle",
+            "version": "1.0.0",
+            "source_uid": "11111111-1111-4111-8111-111111111111",
+            "business_domain_uid": "22222222-2222-4222-8222-222222222222",
+            "environment": "staging",
+            "process_key": "catalog-sync",
+            "operation": "discover",
+            "scope": {},
+        },
+    )
+    assert created.status_code == 201
+    success_schema = expand(
+        contract["paths"][path]["post"]["responses"]["201"]["content"][
+            "application/json"
+        ]["schema"]
+    )
+    Draft202012Validator(success_schema).validate(created.get_json())
+    assert set(created.get_json()) == {"code", "message", "data"}
+    assert created.get_json()["code"] == created.status_code == 201
+
+    invalid = client.post(
+        path,
+        headers={"X-Connector-Credential": "one-time-token"},
+        json={
+            "connector_id": "oracle",
+            "version": "1.0.0",
+            "source_uid": "11111111-1111-4111-8111-111111111111",
+            "business_domain_uid": "22222222-2222-4222-8222-222222222222",
+            "environment": "staging",
+            "process_key": "catalog-sync",
+            "operation": "cancel",
+            "scope": {},
+        },
+    )
+    assert invalid.status_code == invalid.get_json()["code"] == 400
+    Draft202012Validator(error_schema).validate(invalid.get_json())
+    assert invalid.get_json()["error"]["category"] == "configuration"
+
+
+def test_human_run_requires_explicit_dry_run():
+    from app.api.data_source.routes import (
+        _require_collection_operation,
+        _require_human_dry_run,
+    )
+
+    _require_human_dry_run({"dry_run": True})
+    with pytest.raises(ConnectorConfigurationError):
+        _require_human_dry_run({"dry_run": False})
+    with pytest.raises(ConnectorConfigurationError):
+        _require_human_dry_run({})
+    _require_collection_operation({"operation": "incremental"})
+    with pytest.raises(ConnectorConfigurationError):
+        _require_collection_operation({"operation": "cancel"})